Compare commits

..

75 Commits

Author SHA1 Message Date
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 are pending
Gitea CI / test (push) Waiting to run
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 are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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 are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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 are pending
Gitea CI / test (pull_request) Waiting to run
Gitea CI / test (push) Waiting to run
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 are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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 are pending
Gitea CI / test (pull_request) Waiting to run
Gitea CI / test (push) Waiting to run
2026-02-28 17:17:21 +09:00
agentson
9267f1fb77 test: add US minimum price boundary and KR-scope coverage (#320)
Some checks are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
2026-02-28 17:15:10 +09:00
agentson
fd0246769a test: add sell qty fallback guard and quantity-basis coverage (#322)
Some checks are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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 (pull_request) Waiting to run
Gitea CI / test (push) Has been cancelled
2026-02-28 14:40:19 +09:00
agentson
5c107d2435 fix: persist blackout recovery executions to trades log (#324)
Some checks are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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 (pull_request) Waiting to run
Gitea CI / test (push) 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 are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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 (pull_request) Waiting to run
Gitea CI / test (push) Has been cancelled
2026-02-28 14:36:05 +09:00
agentson
c641097fe7 feat: support minute-based triple barrier horizon (#329)
Some checks are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
2026-02-28 14:35:55 +09:00
agentson
2f3b2149d5 fix: add syntax guard for evolved strategy generation (#321)
Some checks are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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 are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
- 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 are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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 are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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 are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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 are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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 are pending
Gitea CI / test (push) Waiting to run
Gitea CI / test (pull_request) Waiting to run
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
7efc254ab5 Merge pull request '[RISK-EMERGENCY] TKT-P1-008 오버나잇 예외 vs Kill Switch 우선순위' (#300) from feature/issue-tkt-p1-008-overnight-killswitch-priority into feature/v3-session-policy-stream 2026-02-27 08:57:25 +09:00
agentson
2742628b78 feat: prioritize kill-switch over overnight exception policy (TASK-CODE-012) 2026-02-27 08:55:24 +09:00
d60fd8947b Merge pull request '[EXEC-POLICY] TKT-P1-007 session_id 로그 원장 강제' (#298) from feature/issue-tkt-p1-007-session-id-ledger into feature/v3-session-policy-stream 2026-02-27 08:51:27 +09:00
agentson
694d73b212 fix: lazy session resolver and one-time session_id backfill 2026-02-27 08:51:00 +09:00
agentson
b2b02b6f57 feat: enforce session_id persistence in trade ledger (TASK-CODE-007) 2026-02-27 08:49:04 +09:00
2dbe98615d Merge pull request '[FX-ACCOUNTING] TKT-P1-006 전략/환율 PnL 분리 회계' (#296) from feature/issue-tkt-p1-006-fx-pnl-separation into feature/v3-session-policy-stream 2026-02-27 08:46:56 +09:00
agentson
34cf081c96 fix: backfill split pnl migration and harden partial pnl inputs 2026-02-27 08:46:22 +09:00
agentson
7bc4e88335 feat: separate strategy and fx pnl fields in trade logs (TASK-CODE-011) 2026-02-27 08:44:05 +09:00
386e039ff6 Merge pull request '[BACKTEST-MODEL] TKT-P1-005 보수적 체결 모델 구현' (#294) from feature/issue-tkt-p1-005-conservative-fill-model into feature/v3-session-policy-stream 2026-02-27 08:42:22 +09:00
agentson
13ba9e8081 fix: validate execution assumption ranges in backtest model 2026-02-27 08:41:56 +09:00
agentson
5b52f593a8 feat: add conservative backtest execution simulator (TASK-CODE-010) 2026-02-27 08:40:23 +09:00
2798558bf3 Merge pull request '[BACKTEST-MODEL] TKT-P1-002 백테스트 비용/슬리피지 옵션 필수화' (#292) from feature/issue-tkt-p1-002-backtest-cost-mandatory into feature/v3-session-policy-stream 2026-02-27 08:37:15 +09:00
agentson
2331d80915 fix: reject non-finite backtest cost assumptions 2026-02-27 08:36:38 +09:00
agentson
7d72669cb8 feat: enforce mandatory backtest cost assumptions (TASK-CODE-006) 2026-02-27 08:34:44 +09:00
74a4784b7a Merge pull request '[BACKTEST-MODEL] TKT-P1-004 Walk-forward + Purge/Embargo 분할 유틸' (#290) from feature/issue-tkt-p1-004-walkforward-purge-embargo into feature/v3-session-policy-stream 2026-02-27 08:33:01 +09:00
agentson
dc70311aed fix: keep embargo tied to accepted folds and enforce PR-comment decision logs 2026-02-27 08:32:09 +09:00
agentson
e56819e9e2 feat: add walk-forward splitter with purge and embargo controls (TASK-CODE-005) 2026-02-27 08:28:11 +09:00
cfd5351b58 Merge pull request '[FX-ACCOUNTING] TKT-P1-001 USD/KRW 버퍼 진입 제한' (#288) from feature/issue-tkt-p1-001-fx-buffer-guard into feature/v3-session-policy-stream 2026-02-27 00:53:21 +09:00
agentson
b206c23fc9 fix: scope USD buffer guard to US markets and add boundary tests 2026-02-27 00:52:44 +09:00
agentson
4d9f3e2cfc feat: enforce overseas buy guard with USD buffer threshold (TASK-V3-014) 2026-02-27 00:50:12 +09:00
a93a5c616b Merge pull request '[BACKTEST-MODEL] TKT-P1-003 Triple Barrier 라벨러 구현' (#286) from feature/issue-tkt-p1-003-triple-barrier-labeler into feature/v3-session-policy-stream 2026-02-27 00:47:37 +09:00
agentson
9f64c9944a fix: correct short-side tie-break semantics in triple barrier 2026-02-27 00:47:09 +09:00
agentson
bb391d502c feat: add triple barrier labeler with first-touch logic (TASK-CODE-004) 2026-02-27 00:45:18 +09:00
b0100fde10 Merge pull request '[RISK-EMERGENCY][SCN-FAIL-003] TKT-P0-002 Kill Switch 순서 강제 검증 자동화' (#284) from feature/issue-tkt-p0-002-killswitch-ordering into feature/v3-session-policy-stream 2026-02-27 00:42:16 +09:00
agentson
0a4e69d40c fix: record kill switch cancel failures and add failure-path tests 2026-02-27 00:41:13 +09:00
agentson
25401ac132 feat: enforce operational kill switch callbacks in runtime flow (TASK-CODE-003) 2026-02-27 00:38:26 +09:00
1381b140ab Merge pull request '[EXEC-POLICY][SCN-FAIL-001] TKT-P0-001 블랙아웃 차단/큐/복구 재검증' (#282) from feature/issue-tkt-p0-001-blackout-queue-revalidate into feature/v3-session-policy-stream 2026-02-27 00:32:59 +09:00
agentson
356d085ab0 feat: implement blackout queue and recovery revalidation (TASK-CODE-008) 2026-02-27 00:31:29 +09:00
46 changed files with 6338 additions and 150 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,53 @@
## Linked Issue
- Closes #N
## Scope
- REQ: `REQ-...`
- TASK: `TASK-...`
- TEST: `TEST-...`
## 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

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

@@ -0,0 +1,51 @@
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
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: 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,15 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pip install ".[dev]" run: pip install ".[dev]"
- name: Session handover gate
run: python3 scripts/session_handover_check.py --strict
- name: Validate governance assets
run: python3 scripts/validate_governance_assets.py
- name: Validate Ouroboros docs
run: python3 scripts/validate_ouroboros_docs.py
- name: Lint - name: Lint
run: ruff check src/ tests/ run: ruff check src/ tests/

View File

@@ -12,6 +12,8 @@ It is distinct from `docs/requirements-log.md`, which records **project/product
1. **Workflow enforcement** 1. **Workflow enforcement**
- Follow `docs/workflow.md` for all changes. - 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. - Create a Gitea issue before any code or documentation change.
- Work on a feature branch `feature/issue-{N}-{short-description}` and open a PR. - Work on a feature branch `feature/issue-{N}-{short-description}` and open a PR.
- Never commit directly to `main`. - 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. (or in a dedicated policy doc) and reference it when working.
- Keep entries short and concrete, with dates. - 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 ## Change Control
- Changes to this file follow the same workflow as code changes. - 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. - When work requires guidance, consult the relevant `docs/` policies first.
- Any code change must be accompanied by relevant documentation updates. - Any code change must be accompanied by relevant documentation updates.
- Persist user constraints across sessions by recording them in this document. - 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

@@ -4,6 +4,23 @@
**Critical: Learn from failures. Never repeat the same failed command without modification.** **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) ### tea CLI (Gitea Command Line Tool)
#### ❌ TTY Error - Interactive Confirmation Fails #### ❌ TTY Error - Interactive Confirmation Fails
@@ -140,6 +157,18 @@ python -m src.main --mode=paper
# Run with dashboard enabled # Run with dashboard enabled
python -m src.main --mode=paper --dashboard 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
docker compose up -d ouroboros # Run agent docker compose up -d ouroboros # Run agent
docker compose --profile test up test # Run tests in container docker compose --profile test up test # Run tests in container

View File

@@ -34,6 +34,12 @@ Main Agent 아이디에이션 책임:
- DCP-03 구현 착수: Phase 2 종료 전 Main Agent 승인 필수 - DCP-03 구현 착수: Phase 2 종료 전 Main Agent 승인 필수
- DCP-04 배포 승인: Phase 4 종료 후 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 Control Gates
### Phase 0: Scenario Intake and Scope Lock ### Phase 0: Scenario Intake and Scope Lock
@@ -112,7 +118,10 @@ Exit criteria:
Control checks: Control checks:
- Verifier가 테스트 증적(로그/리포트/실행 커맨드) 첨부 - Verifier가 테스트 증적(로그/리포트/실행 커맨드) 첨부
- Verifier가 `Coverage Matrix`(`REQ/TASK/TEST` x `PASS/FAIL/NOT_OBSERVED`) 첨부
- `NOT_OBSERVED` 항목 수가 0인지 확인(0이 아니면 Gate 실패)
- Runtime Verifier가 스테이징/실운영 모니터링 계획 승인 - Runtime Verifier가 스테이징/실운영 모니터링 계획 승인
- 정적 Verifier 승인 + Runtime Verifier 승인 2개 모두 확인
- 산출물: 수용 승인 레코드 - 산출물: 수용 승인 레코드
### Phase 5: Release and Post-Release Control ### Phase 5: Release and Post-Release Control
@@ -149,6 +158,18 @@ TPM 티켓 운영 규칙:
- TPM은 합의된 변경을 이슈로 등록하고 우선순위(`P0/P1/P2`)를 지정한다. - TPM은 합의된 변경을 이슈로 등록하고 우선순위(`P0/P1/P2`)를 지정한다.
- PR 본문에는 TPM이 지정한 우선순위와 범위가 그대로 반영되어야 한다. - PR 본문에는 TPM이 지정한 우선순위와 범위가 그대로 반영되어야 한다.
- 우선순위 변경은 TPM 제안 + Main Agent 승인으로만 가능하다. - 우선순위 변경은 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 경로를 지정한다. - TPM은 각 티켓에 대해 `ticket temp branch -> program feature branch` PR 경로를 지정한다.
@@ -167,6 +188,8 @@ TPM 티켓 운영 규칙:
- 시스템 실제 구동(스테이징/로컬 실운영 모드) 실행 - 시스템 실제 구동(스테이징/로컬 실운영 모드) 실행
- 모니터링 체크리스트(핵심 경보/주문 경로/예외 로그) 수행 - 모니터링 체크리스트(핵심 경보/주문 경로/예외 로그) 수행
- 결과를 티켓/PR 코멘트에 증적으로 첨부하지 않으면 완료로 간주하지 않음 - 결과를 티켓/PR 코멘트에 증적으로 첨부하지 않으면 완료로 간주하지 않음
- 세션별 필수 관측 포인트(`NXT`, `US_PRE`, `US_DAY`, `US_AFTER` 등) 중 미관측 항목은 `NOT_OBSERVED`로 기록
- `NOT_OBSERVED` 존재 시 승인 금지 + Runtime 이슈 발행
## Server Reflection Rule ## Server Reflection Rule

View File

@@ -3,7 +3,7 @@ Doc-ID: DOC-OPS-002
Version: 1.0.0 Version: 1.0.0
Status: active Status: active
Owner: tpm Owner: tpm
Updated: 2026-02-26 Updated: 2026-02-27
--> -->
# 저장소 강제 설정 체크리스트 # 저장소 강제 설정 체크리스트
@@ -48,14 +48,24 @@ Updated: 2026-02-26
병합 전 체크리스트: 병합 전 체크리스트:
- 이슈 연결(`Closes #N`) 존재 - 이슈 연결(`Closes #N`) 존재
- PR 본문에 `REQ-*`, `TASK-*`, `TEST-*` 매핑 표 존재 - PR 본문에 `REQ-*`, `TASK-*`, `TEST-*` 매핑 표 존재
- Main -> Verifier Directive Contract(범위/방법/합격/실패/미관측/증적 형식) 기재
- process-change-first 대상이면 process 티켓 PR이 선머지됨
- `src/core/risk_manager.py` 변경 없음 - `src/core/risk_manager.py` 변경 없음
- 주요 의사결정 체크포인트(DCP-01~04) 중 해당 단계 Main Agent 확인 기록 존재 - 주요 의사결정 체크포인트(DCP-01~04) 중 해당 단계 Main Agent 확인 기록 존재
- 주요 의사결정(리뷰 지적/수정 합의/검증 승인)에 대한 에이전트 PR 코멘트 존재
- 티켓 PR의 base가 `main`이 아닌 program feature branch인지 확인 - 티켓 PR의 base가 `main`이 아닌 program feature branch인지 확인
자동 점검: 자동 점검:
- 문서 검증 스크립트 통과 - 문서 검증 스크립트 통과
- 테스트 통과 - 테스트 통과
- `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) 감사 추적 ## 5) 감사 추적

View File

@@ -0,0 +1,355 @@
<!--
Doc-ID: DOC-AUDIT-001
Version: 1.0.0
Status: active
Owner: strategy
Updated: 2026-02-28
-->
# v2/v3 구현 감사 및 수익률 분석 보고서
작성일: 2026-02-28
대상 기간: 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 구현 상태: ~75% 완료
| REQ-ID | 요구사항 | 상태 | 갭 설명 |
|--------|----------|------|---------|
| REQ-V3-001 | 모든 신호/주문/로그에 session_id 포함 | ⚠️ 부분 | 아래 GAP-1, GAP-2 참조 |
| REQ-V3-002 | 세션 전환 훅 + 리스크 파라미터 재로딩 | ⚠️ 부분 | 아래 GAP-3 참조 |
| REQ-V3-003 | 블랙아웃 윈도우 정책 | ✅ 완료 | `src/core/blackout_manager.py` |
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ⚠️ 부분 | 아래 GAP-4 참조 (부분 해소) |
| 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:459-471` |
### 1.3 운영 거버넌스: ~20% 완료
| REQ-ID | 요구사항 | 상태 | 갭 설명 |
|--------|----------|------|---------|
| REQ-OPS-001 | 타임존 명시 (KST/UTC) | ⚠️ 부분 | DB 기록은 UTC, 세션은 KST. 일부 로그에서 타임존 미표기 |
| REQ-OPS-002 | 정책 변경 시 레지스트리 업데이트 강제 | ❌ 미구현 | CI 자동 검증 없음 |
| REQ-OPS-003 | TASK-REQ 매핑 강제 | ❌ 미구현 | PR 단위 자동 검증 없음 |
---
## 2. 구현 갭 상세
### GAP-1: DecisionLogger에 session_id 미포함 (CRITICAL)
- **위치**: `src/logging/decision_logger.py:40`
- **문제**: `log_decision()` 함수에 `session_id` 파라미터가 없음
- **영향**: 어떤 세션에서 전략적 의사결정이 내려졌는지 추적 불가
- **요구사항**: REQ-V3-001
### GAP-2: src/main.py 거래 로그에 session_id 미전달 (CRITICAL)
- **위치**: `src/main.py` line 1625, 1682, 2769
- **문제**: `log_trade()` 호출 시 `session_id` 파라미터를 전달하지 않음
- **현상**: 시장 코드 기반 자동 추론에 의존 → 실제 런타임 세션과 불일치 가능
- **요구사항**: REQ-V3-001
### GAP-3: 세션 전환 시 리스크 파라미터 재로딩 없음 (HIGH)
- **위치**: `src/main.py` 전체
- **문제**: 리스크 파라미터가 시작 시 한 번만 로딩되고, 세션 경계 변경 시 재로딩 메커니즘 없음
- **영향**: NXT_AFTER(저유동) → KRX_REG(정규장) 전환 시에도 동일 파라미터 사용
- **요구사항**: REQ-V3-002
### GAP-4: 블랙아웃 복구 시 재검증 부분 해소, DB 기록 미구현 (HIGH)
- **위치**: `src/core/blackout_manager.py:89-96`, `src/main.py:694-791`
- **상태**: `pop_recovery_batch()` 자체는 단순 dequeue이나, 실행 경로에서 부분 재검증 수행:
- stale BUY 드롭 (포지션 이미 존재 시) — `src/main.py:713-720`
- stale SELL 드롭 (포지션 부재 시) — `src/main.py:721-727`
- `validate_order_policy()` 호출 — `src/main.py:729-734`
- **잔여 갭**: 가격 유효성(시세 변동), 세션 변경에 따른 파라미터 재적용은 미구현
- **신규 발견**: 블랙아웃 복구 주문이 `log_trade()` 없이 실행되어 거래 DB에 기록되지 않음 → 성과 리포트 불일치 유발
- **요구사항**: REQ-V3-004
### GAP-5: 시간장벽이 봉 개수 고정 (MEDIUM)
- **위치**: `src/analysis/triple_barrier.py:19`
- **문제**: `max_holding_bars` (고정 봉 수) 사용, v3 계획의 `max_holding_minutes` (캘린더 시간) 미반영
- **요구사항**: REQ-V2-005 / v3 확장
---
## 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 청산 로직이 부분 통합되었으나 실효성 부족 (HIGH)
- **현재 상태**: `src/main.py:500-583`에서 `evaluate_exit()` 기반 staged exit override가 동작함
- 상태기계(HOLDING→BE_LOCK→ARMED→EXITED) 전이 구현
- 4중 청산(hard stop, BE lock threat, ATR trailing, model/liquidity exit) 평가
- **실효성 문제**:
- `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만 발동
- **결론**: 코드 통합은 되었으나, 피처 공급과 파라미터 설정이 미비하여 v2 설계 가치가 실현되지 않는 상태
### ROOT-6: SELL 손익 계산이 부분청산/수량 불일치에 취약 (CRITICAL)
- **위치**: `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)
- **위치**: `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 구조적 개선 (아키텍처 변경)
| 우선순위 | 방안 | 예상 효과 | 난이도 |
|----------|------|-----------|--------|
| **P0** | **SELL PnL 계산을 sell_qty 기준으로 수정 (ROOT-6)** | 손익 계상 정확도 확보, 이상치 제거 | 낮음 |
| **P0** | **v2 staged exit에 실제 피처 공급 (atr_value, pred_down_prob) + 독립 파라미터 설정 (ROOT-5)** | v2 설계 가치 실현, 수익 보호 | 중간 |
| P0 | BUY 매칭 키에 exchange_code 추가 (ROOT-7) | 오매칭 방지 | 낮음 |
| P0 | 블랙아웃 복구 주문에 `log_trade()` 추가 (GAP-4) | DB/성과 리포트 정합성 | 낮음 |
| P1 | 세션 전환 시 리스크 파라미터 동적 재로딩 (GAP-3 해소) | 세션별 최적 파라미터 적용 | 중간 |
| P1 | session_id를 거래 로그/의사결정 로그에 명시적 전달 (GAP-1,2 해소) | 세션별 성과 분석 가능 | 낮음 |
| P2 | 블랙아웃 복구 시 가격/세션 재검증 강화 (GAP-4 잔여) | 세션 변경 후 무효 주문 방지 | 중간 |
### 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`)
### 테스트 미존재
- ❌ 세션 전환 훅 콜백
- ❌ 세션 경계 리스크 파라미터 재로딩
- ❌ DecisionLogger session_id 캡처
- ❌ 실거래 경로 ↔ v2 상태기계 통합 테스트 (피처 공급 포함)
- ❌ 블랙아웃 복구 주문의 DB 기록 검증
- ❌ SELL PnL 계산 시 수량 불일치 케이스
---
## 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,96 @@
<!--
Doc-ID: DOC-PLAN-082
Version: 1.0.0
Status: draft
Owner: strategy
Updated: 2026-02-28
-->
# 문서 재구조화 계획: 감사 → 실행 파이프라인
## Context
80_implementation_audit.md는 v2/v3 구현 감사와 수익률 분석을 수행했으나, 여러 차례 리뷰를 거치면서 리뷰 이력/데이터 품질 논의/SQL 쿼리 등이 혼재되어 **실행 문서로 사용하기 어려운 상태**다.
목표: 이 감사 결과를 바탕으로 **티켓 생성 → 개발 설계 → 구현/리뷰 → 검증 → 실환경 테스트**까지 일관되게 진행할 수 있는 문서 체계를 만든다.
## 변경 사항
### 1. 80_implementation_audit.md 정리 (감사 기록 문서)
**역할**: 현재 상태의 팩트 기록. "무엇이 문제인가"에만 집중.
정리 내용:
- Section 3: P&L 분석을 핵심 수치만 남기고 간결화
- 3.1(종합), 3.3(시장별), 3.4(통화 분리), 3.5(전략 진입분 분리), 3.6(무결성 결론) 유지
- 3.2 일별 손익: 주의 문구 제거, 본문으로 통합
- 3.7 데이터 품질: 핵심 결론만 남기고 세부 항목 제거
- 3.8 SQL: 별도 파일(`scripts/audit_queries.sql`)로 분리, 본문에서 참조만
- Section 6.1, 6.2 리뷰 반영 이력: 전부 제거 (git history로 추적 가능)
- Section 6 테스트: "재점검으로 확인" 항목을 "테스트 존재" 항목에 통합
- 신규 Section 7: 후속 문서 링크 (85_ 참조)
### 2. 85_loss_recovery_action_plan.md 신규 작성 (실행 계획 문서)
**역할**: "어떻게 고칠 것인가". 티켓 생성부터 실환경 검증까지의 실행 청사진.
구조:
```
## 1. 요약
- 목표: 손실 구간 탈출을 위한 7개 ROOT/5개 GAP 해소
- 성공 기준 (정량)
## 2. Phase별 작업 분해
### Phase 1: 즉시 파라미터/로직 수정 (손실 출혈 차단)
각 항목마다:
- ROOT/GAP 참조
- Gitea 이슈 제목/설명 템플릿
- 변경 대상 파일 + 현재 동작 + 목표 동작
- 수용 기준 (acceptance criteria)
- 테스트 계획
- 의존성/차단 관계
### Phase 2: 데이터 정합성 + v2 실효화
(동일 형식)
### Phase 3: v3 세션 최적화
(동일 형식)
## 3. 검증 계획
- 단위 테스트 기준
- 통합 테스트 시나리오 (백테스트 파이프라인 활용)
- 실환경 검증: 소액 live 운용으로 직접 검증
(paper trading 제외 — 실환경과 괴리가 커 검증 신뢰도 부족)
- Phase별 실환경 투입 기준:
단위/통합 테스트 통과 → 소액 live → 모니터링 → 정상 확인 후 본운용
## 4. 의존성 그래프
- Phase 간 blocking 관계
- Phase 내 작업 순서
## 5. 롤백 계획
- 각 Phase 실패 시 롤백 절차
```
### 3. README.md 업데이트
- 85_ 문서 링크 추가
## 작업 순서
1. 80_ 정리 (노이즈 제거, SQL 분리, 리뷰 이력 삭제)
2. `scripts/audit_queries.sql` 작성 (80_에서 분리한 SQL)
3. 85_ 신규 작성 (실행 계획)
4. README.md 업데이트
## 작성하지 않는 것
- 30_code_level_work_orders.md, 40_acceptance_and_test_plan.md 업데이트: 85_를 기반으로 실제 구현 시점에 업데이트 (지금은 실행 계획 수립까지만)
- 01_requirements_registry.md: ROOT/GAP에서 파생되는 신규 REQ는 구현 착수 시 등록
- Gitea 이슈 생성: 85_ 문서 확정 후 별도 진행
## 검증
- 80_: 감사 팩트만 남았는지, 리뷰 이력이 제거되었는지 확인
- 85_: Phase별 작업이 Gitea 이슈로 바로 전환 가능한 수준인지 확인
- 85_ 각 항목에 수용 기준과 테스트 계획이 포함되었는지 확인

View File

@@ -0,0 +1,392 @@
<!--
Doc-ID: DOC-ACTION-085
Version: 1.0.0
Status: active
Owner: strategy
Updated: 2026-02-28
-->
# 손실 복구 실행 계획
작성일: 2026-02-28
기반 문서: [80_implementation_audit.md](./80_implementation_audit.md) (ROOT 7개 + GAP 5개)
---
## 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` (line 694-791, 블랙아웃 복구 실행 경로)
- **현재 동작**: 블랙아웃 복구 주문이 실행되나 `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-10: 세션 전환 시 리스크 파라미터 동적 재로딩
- **ROOT 참조**: GAP-3 (세션 전환 시 리스크 파라미터 재로딩 없음)
- **Gitea 이슈**: feat: 세션 전환 시 리스크 파라미터 동적 재로딩
- **Gitea 이슈 번호**: #327
- **변경 대상 파일**: `src/main.py`, `src/config.py`
- **현재 동작**: 리스크 파라미터가 시작 시 한 번만 로딩
- **목표 동작**: 세션 경계 변경 이벤트 시 해당 세션의 리스크 파라미터를 재로딩. 세션별 프로파일 지원
- **수용 기준**:
- NXT_AFTER → KRX_REG 전환 시 파라미터 재로딩 확인
- 재로딩 이벤트 로그 기록
- 재로딩 실패 시 기존 파라미터 유지 (안전 폴백)
- **테스트 계획**:
- 단위: 세션 전환 훅 콜백 테스트
- 단위: 재로딩 실패 시 폴백 테스트
- **의존성**: 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/`, `scripts/validate_governance_assets.py`
- **현재 동작**: CI 자동 검증 없음. 문서 검증은 수동 실행
- **목표 동작**:
- PR 시 정책 레지스트리(`01_requirements_registry.md`) 변경 여부 자동 검증
- TASK/이슈가 REQ-ID를 참조하는지 자동 검증
- **수용 기준**:
- 정책 파일 변경 시 레지스트리 미업데이트면 CI 실패
- 새 이슈/PR에 REQ-ID 미참조 시 경고
- **테스트 계획**:
- CI 파이프라인 자체 테스트 (정상/실패 케이스)
- **의존성**: 없음
---
## 3. 검증 계획
### 3.1 단위 테스트
- 모든 ACT 항목에 대해 개별 테스트 작성
- 커버리지 >= 80% 유지
- 기존 551개 테스트 전체 통과 확인
### 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 검증
---
*끝.*

View File

@@ -22,6 +22,8 @@ Updated: 2026-02-26
8. TPM 제어 프로토콜/수용 매트릭스: [50_tpm_control_protocol.md](./50_tpm_control_protocol.md) 8. TPM 제어 프로토콜/수용 매트릭스: [50_tpm_control_protocol.md](./50_tpm_control_protocol.md)
9. 저장소 강제 설정 체크리스트: [60_repo_enforcement_checklist.md](./60_repo_enforcement_checklist.md) 9. 저장소 강제 설정 체크리스트: [60_repo_enforcement_checklist.md](./60_repo_enforcement_checklist.md)
10. 메인 에이전트 아이디에이션 백로그: [70_main_agent_ideation.md](./70_main_agent_ideation.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)
## 운영 규칙 ## 운영 규칙

View File

@@ -355,3 +355,36 @@ Order result: 모의투자 매수주문이 완료 되었습니다. ✓
- `TestOverseasGhostPositionClose` 2개: ghost-close 로그 확인, 일반 오류 무시 - `TestOverseasGhostPositionClose` 2개: ghost-close 로그 확인, 일반 오류 무시
**이슈/PR:** #235, PR #236 **이슈/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

View File

@@ -181,6 +181,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. **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 ## Test Configuration
### `pyproject.toml` ### `pyproject.toml`

View File

@@ -16,12 +16,55 @@
**Never commit directly to `main`.** This policy applies to all changes, no exceptions. **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) ## Branch Strategy (Mandatory)
- Team operation default branch is the **program feature branch**, not `main`. - Team operation default branch is the **program feature branch**, not `main`.
- Ticket-level development happens only on **ticket temp branches** cut from the program feature branch. - Ticket-level development happens only on **ticket temp branches** cut from the program feature branch.
- Ticket PR merges into program feature branch are allowed after verifier approval. - Ticket PR merges into program feature branch are allowed after verifier approval.
- Until final user sign-off, `main` merge is prohibited. - 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 ## Gitea CLI Formatting Troubleshooting
@@ -136,6 +179,62 @@ task_tool(
Use `run_in_background=True` for independent tasks that don't block subsequent work. 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 ## Code Review Checklist
**CRITICAL: Every PR review MUST verify plan-implementation consistency.** **CRITICAL: Every PR review MUST verify plan-implementation consistency.**
@@ -169,3 +268,10 @@ Before approving any PR, the reviewer (human or agent) must check ALL of the fol
- [ ] PR references the Gitea issue number - [ ] PR references the Gitea issue number
- [ ] Feature branch follows naming convention (`feature/issue-N-description`) - [ ] Feature branch follows naming convention (`feature/issue-N-description`)
- [ ] Commit messages are clear and descriptive - [ ] 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,156 @@
#!/usr/bin/env python3
"""Validate persistent governance assets for agent workflow safety."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
REQUIREMENTS_REGISTRY = "docs/ouroboros/01_requirements_registry.md"
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 main() -> int:
errors: 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)
if errors:
print("[FAIL] governance asset validation failed")
for err in errors:
print(f"- {err}")
return 1
print("[OK] governance assets validated")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,52 @@
"""Backtest cost/slippage/failure validation guard."""
from __future__ import annotations
from dataclasses import dataclass
import math
@dataclass(frozen=True)
class BacktestCostModel:
commission_bps: float | None = None
slippage_bps_by_session: dict[str, float] | None = None
failure_rate_by_session: dict[str, float] | None = None
unfavorable_fill_required: bool = True
def validate_backtest_cost_model(
*,
model: BacktestCostModel,
required_sessions: list[str],
) -> None:
"""Raise ValueError when required cost assumptions are missing/invalid."""
if (
model.commission_bps is None
or not math.isfinite(model.commission_bps)
or model.commission_bps < 0
):
raise ValueError("commission_bps must be provided and >= 0")
if not model.unfavorable_fill_required:
raise ValueError("unfavorable_fill_required must be True")
slippage = model.slippage_bps_by_session or {}
failure = model.failure_rate_by_session or {}
missing_slippage = [s for s in required_sessions if s not in slippage]
if missing_slippage:
raise ValueError(
f"missing slippage_bps_by_session for sessions: {', '.join(missing_slippage)}"
)
missing_failure = [s for s in required_sessions if s not in failure]
if missing_failure:
raise ValueError(
f"missing failure_rate_by_session for sessions: {', '.join(missing_failure)}"
)
for sess, bps in slippage.items():
if not math.isfinite(bps) or bps < 0:
raise ValueError(f"slippage bps must be >= 0 for session={sess}")
for sess, rate in failure.items():
if not math.isfinite(rate) or rate < 0 or rate > 1:
raise ValueError(f"failure rate must be within [0,1] for session={sess}")

View File

@@ -0,0 +1,103 @@
"""Conservative backtest execution model."""
from __future__ import annotations
from dataclasses import dataclass
import math
from random import Random
from typing import Literal
OrderSide = Literal["BUY", "SELL"]
@dataclass(frozen=True)
class ExecutionRequest:
side: OrderSide
session_id: str
qty: int
reference_price: float
@dataclass(frozen=True)
class ExecutionAssumptions:
slippage_bps_by_session: dict[str, float]
failure_rate_by_session: dict[str, float]
partial_fill_rate_by_session: dict[str, float]
partial_fill_min_ratio: float = 0.3
partial_fill_max_ratio: float = 0.8
seed: int = 0
@dataclass(frozen=True)
class ExecutionResult:
status: Literal["FILLED", "PARTIAL", "REJECTED"]
filled_qty: int
avg_price: float
slippage_bps: float
reason: str
class BacktestExecutionModel:
"""Execution simulator with conservative unfavorable fill assumptions."""
def __init__(self, assumptions: ExecutionAssumptions) -> None:
self.assumptions = assumptions
self._rng = Random(assumptions.seed)
if assumptions.partial_fill_min_ratio <= 0 or assumptions.partial_fill_max_ratio > 1:
raise ValueError("partial fill ratios must be within (0,1]")
if assumptions.partial_fill_min_ratio > assumptions.partial_fill_max_ratio:
raise ValueError("partial_fill_min_ratio must be <= partial_fill_max_ratio")
for sess, bps in assumptions.slippage_bps_by_session.items():
if not math.isfinite(bps) or bps < 0:
raise ValueError(f"slippage_bps must be finite and >= 0 for session={sess}")
for sess, rate in assumptions.failure_rate_by_session.items():
if not math.isfinite(rate) or rate < 0 or rate > 1:
raise ValueError(f"failure_rate must be in [0,1] for session={sess}")
for sess, rate in assumptions.partial_fill_rate_by_session.items():
if not math.isfinite(rate) or rate < 0 or rate > 1:
raise ValueError(f"partial_fill_rate must be in [0,1] for session={sess}")
def simulate(self, request: ExecutionRequest) -> ExecutionResult:
if request.qty <= 0:
raise ValueError("qty must be positive")
if request.reference_price <= 0:
raise ValueError("reference_price must be positive")
slippage_bps = self.assumptions.slippage_bps_by_session.get(request.session_id, 0.0)
failure_rate = self.assumptions.failure_rate_by_session.get(request.session_id, 0.0)
partial_rate = self.assumptions.partial_fill_rate_by_session.get(request.session_id, 0.0)
if self._rng.random() < failure_rate:
return ExecutionResult(
status="REJECTED",
filled_qty=0,
avg_price=0.0,
slippage_bps=slippage_bps,
reason="execution_failure",
)
slip_mult = 1.0 + (slippage_bps / 10000.0 if request.side == "BUY" else -slippage_bps / 10000.0)
exec_price = request.reference_price * slip_mult
if self._rng.random() < partial_rate:
ratio = self._rng.uniform(
self.assumptions.partial_fill_min_ratio,
self.assumptions.partial_fill_max_ratio,
)
filled = max(1, min(request.qty - 1, int(request.qty * ratio)))
return ExecutionResult(
status="PARTIAL",
filled_qty=filled,
avg_price=exec_price,
slippage_bps=slippage_bps,
reason="partial_fill",
)
return ExecutionResult(
status="FILLED",
filled_qty=request.qty,
avg_price=exec_price,
slippage_bps=slippage_bps,
reason="filled",
)

View File

@@ -0,0 +1,187 @@
"""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 statistics import mean
from typing import Literal
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
@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]
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")
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,
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

@@ -0,0 +1,139 @@
"""Triple barrier labeler utilities.
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
TieBreakMode = Literal["stop_first", "take_first"]
@dataclass(frozen=True)
class TripleBarrierSpec:
take_profit_pct: float
stop_loss_pct: float
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:
label: int # +1 take-profit first, -1 stop-loss first, 0 timeout
touched: Literal["take_profit", "stop_loss", "time"]
touch_bar: int
entry_price: float
upper_barrier: float
lower_barrier: float
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,
) -> TripleBarrierLabel:
"""Label one entry using triple-barrier first-touch rule.
Args:
highs/lows/closes: OHLC components with identical length.
entry_index: Entry bar index in the sequences.
side: +1 for long, -1 for short.
spec: Barrier specification.
"""
if side not in {1, -1}:
raise ValueError("side must be +1 or -1")
if len(highs) != len(lows) or len(highs) != len(closes):
raise ValueError("highs, lows, closes lengths must match")
if entry_index < 0 or entry_index >= len(closes):
raise IndexError("entry_index out of range")
entry_price = float(closes[entry_index])
if entry_price <= 0:
raise ValueError("entry price must be positive")
if side == 1:
upper = entry_price * (1.0 + spec.take_profit_pct)
lower = entry_price * (1.0 - spec.stop_loss_pct)
else:
# For short side, favorable move is down.
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):
high_price = float(highs[idx])
low_price = float(lows[idx])
up_touch = high_price >= upper
down_touch = low_price <= lower
if not up_touch and not down_touch:
continue
if up_touch and down_touch:
if spec.tie_break == "stop_first":
touched = "stop_loss"
label = -1
else:
touched = "take_profit"
label = 1
elif up_touch:
touched = "take_profit" if side == 1 else "stop_loss"
label = 1 if side == 1 else -1
else:
touched = "stop_loss" if side == 1 else "take_profit"
label = -1 if side == 1 else 1
return TripleBarrierLabel(
label=label,
touched=touched,
touch_bar=idx,
entry_price=entry_price,
upper_barrier=upper,
lower_barrier=lower,
)
return TripleBarrierLabel(
label=0,
touched="time",
touch_bar=last_index,
entry_price=entry_price,
upper_barrier=upper,
lower_barrier=lower,
)

View File

@@ -0,0 +1,74 @@
"""Walk-forward splitter with purge/embargo controls."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class WalkForwardFold:
train_indices: list[int]
test_indices: list[int]
@property
def train_size(self) -> int:
return len(self.train_indices)
@property
def test_size(self) -> int:
return len(self.test_indices)
def generate_walk_forward_splits(
*,
n_samples: int,
train_size: int,
test_size: int,
step_size: int | None = None,
purge_size: int = 0,
embargo_size: int = 0,
min_train_size: int = 1,
) -> list[WalkForwardFold]:
"""Generate chronological folds with purge/embargo leakage controls."""
if n_samples <= 0:
raise ValueError("n_samples must be positive")
if train_size <= 0 or test_size <= 0:
raise ValueError("train_size and test_size must be positive")
if purge_size < 0 or embargo_size < 0:
raise ValueError("purge_size and embargo_size must be >= 0")
if min_train_size <= 0:
raise ValueError("min_train_size must be positive")
step = step_size if step_size is not None else test_size
if step <= 0:
raise ValueError("step_size must be positive")
folds: list[WalkForwardFold] = []
prev_test_end: int | None = None
test_start = train_size + purge_size
while test_start + test_size <= n_samples:
test_end = test_start + test_size - 1
train_end = test_start - purge_size - 1
if train_end < 0:
break
train_start = max(0, train_end - train_size + 1)
train_indices = list(range(train_start, train_end + 1))
if prev_test_end is not None and embargo_size > 0:
emb_from = prev_test_end + 1
emb_to = prev_test_end + embargo_size
train_indices = [i for i in train_indices if i < emb_from or i > emb_to]
if len(train_indices) >= min_train_size:
folds.append(
WalkForwardFold(
train_indices=train_indices,
test_indices=list(range(test_start, test_end + 1)),
)
)
prev_test_end = test_end
test_start += step
return folds

View File

@@ -59,11 +59,23 @@ class Settings(BaseSettings):
# KIS VTS overseas balance API returns errors for most accounts. # KIS VTS overseas balance API returns errors for most accounts.
# This value is used as a fallback when the balance API returns 0 in paper mode. # 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) 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
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls) # Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$") TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$")
DAILY_SESSIONS: int = Field(default=4, ge=1, le=10) DAILY_SESSIONS: int = Field(default=4, ge=1, le=10)
SESSION_INTERVAL_HOURS: int = Field(default=6, ge=1, le=24) SESSION_INTERVAL_HOURS: int = Field(default=6, ge=1, le=24)
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)
# Pre-Market Planner # Pre-Market Planner
PRE_MARKET_MINUTES: int = Field(default=30, ge=10, le=120) PRE_MARKET_MINUTES: int = Field(default=30, ge=10, le=120)

View File

@@ -0,0 +1,105 @@
"""Blackout policy and queued order-intent manager."""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime, time
from zoneinfo import ZoneInfo
@dataclass(frozen=True)
class BlackoutWindow:
start: time
end: time
def contains(self, kst_time: time) -> bool:
if self.start <= self.end:
return self.start <= kst_time < self.end
return kst_time >= self.start or kst_time < self.end
@dataclass
class QueuedOrderIntent:
market_code: str
exchange_code: str
stock_code: str
order_type: str
quantity: int
price: float
source: str
queued_at: datetime
attempts: int = 0
def parse_blackout_windows_kst(raw: str) -> list[BlackoutWindow]:
"""Parse comma-separated KST windows like '23:30-00:10,11:20-11:30'."""
windows: list[BlackoutWindow] = []
for token in raw.split(","):
span = token.strip()
if not span or "-" not in span:
continue
start_raw, end_raw = [part.strip() for part in span.split("-", 1)]
try:
start_h, start_m = [int(v) for v in start_raw.split(":", 1)]
end_h, end_m = [int(v) for v in end_raw.split(":", 1)]
except (ValueError, TypeError):
continue
if not (0 <= start_h <= 23 and 0 <= end_h <= 23):
continue
if not (0 <= start_m <= 59 and 0 <= end_m <= 59):
continue
windows.append(BlackoutWindow(start=time(start_h, start_m), end=time(end_h, end_m)))
return windows
class BlackoutOrderManager:
"""Tracks blackout mode and queues order intents until recovery."""
def __init__(
self,
*,
enabled: bool,
windows: list[BlackoutWindow],
max_queue_size: int = 500,
) -> None:
self.enabled = enabled
self._windows = windows
self._queue: deque[QueuedOrderIntent] = deque()
self._was_blackout = False
self._max_queue_size = max_queue_size
@property
def pending_count(self) -> int:
return len(self._queue)
def in_blackout(self, now: datetime | None = None) -> bool:
if not self.enabled or not self._windows:
return False
now = now or datetime.now(UTC)
kst_now = now.astimezone(ZoneInfo("Asia/Seoul")).timetz().replace(tzinfo=None)
return any(window.contains(kst_now) for window in self._windows)
def enqueue(self, intent: QueuedOrderIntent) -> bool:
if len(self._queue) >= self._max_queue_size:
return False
self._queue.append(intent)
return True
def pop_recovery_batch(self, now: datetime | None = None) -> list[QueuedOrderIntent]:
in_blackout_now = self.in_blackout(now)
batch: list[QueuedOrderIntent] = []
if not in_blackout_now and self._queue:
while self._queue:
batch.append(self._queue.popleft())
self._was_blackout = in_blackout_now
return batch
def requeue(self, intent: QueuedOrderIntent) -> None:
if len(self._queue) < self._max_queue_size:
self._queue.append(intent)
def clear(self) -> int:
count = len(self._queue)
self._queue.clear()
return count

121
src/db.py
View File

@@ -31,8 +31,12 @@ def init_db(db_path: str) -> sqlite3.Connection:
quantity INTEGER, quantity INTEGER,
price REAL, price REAL,
pnl REAL DEFAULT 0.0, pnl REAL DEFAULT 0.0,
strategy_pnl REAL DEFAULT 0.0,
fx_pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR', market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX', exchange_code TEXT DEFAULT 'KRX',
session_id TEXT DEFAULT 'UNKNOWN',
selection_context TEXT,
decision_id TEXT, decision_id TEXT,
mode TEXT DEFAULT 'paper' mode TEXT DEFAULT 'paper'
) )
@@ -53,6 +57,32 @@ def init_db(db_path: str) -> sqlite3.Connection:
conn.execute("ALTER TABLE trades ADD COLUMN decision_id TEXT") conn.execute("ALTER TABLE trades ADD COLUMN decision_id TEXT")
if "mode" not in columns: if "mode" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN mode TEXT DEFAULT 'paper'") conn.execute("ALTER TABLE trades ADD COLUMN mode TEXT DEFAULT 'paper'")
session_id_added = False
if "session_id" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN session_id TEXT DEFAULT 'UNKNOWN'")
session_id_added = True
if "strategy_pnl" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN strategy_pnl REAL DEFAULT 0.0")
if "fx_pnl" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN fx_pnl REAL DEFAULT 0.0")
# Backfill legacy rows where only pnl existed before split accounting columns.
conn.execute(
"""
UPDATE trades
SET strategy_pnl = pnl, fx_pnl = 0.0
WHERE pnl != 0.0
AND strategy_pnl = 0.0
AND fx_pnl = 0.0
"""
)
if session_id_added:
conn.execute(
"""
UPDATE trades
SET session_id = 'UNKNOWN'
WHERE session_id IS NULL OR session_id = ''
"""
)
# Context tree tables for multi-layered memory management # Context tree tables for multi-layered memory management
conn.execute( conn.execute(
@@ -79,6 +109,7 @@ def init_db(db_path: str) -> sqlite3.Connection:
stock_code TEXT NOT NULL, stock_code TEXT NOT NULL,
market TEXT NOT NULL, market TEXT NOT NULL,
exchange_code TEXT NOT NULL, exchange_code TEXT NOT NULL,
session_id TEXT DEFAULT 'UNKNOWN',
action TEXT NOT NULL, action TEXT NOT NULL,
confidence INTEGER NOT NULL, confidence INTEGER NOT NULL,
rationale TEXT NOT NULL, rationale TEXT NOT NULL,
@@ -91,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( conn.execute(
""" """
@@ -171,8 +223,11 @@ def log_trade(
quantity: int = 0, quantity: int = 0,
price: float = 0.0, price: float = 0.0,
pnl: float = 0.0, pnl: float = 0.0,
strategy_pnl: float | None = None,
fx_pnl: float | None = None,
market: str = "KR", market: str = "KR",
exchange_code: str = "KRX", exchange_code: str = "KRX",
session_id: str | None = None,
selection_context: dict[str, any] | None = None, selection_context: dict[str, any] | None = None,
decision_id: str | None = None, decision_id: str | None = None,
mode: str = "paper", mode: str = "paper",
@@ -187,24 +242,37 @@ def log_trade(
rationale: AI decision rationale rationale: AI decision rationale
quantity: Number of shares quantity: Number of shares
price: Trade price price: Trade price
pnl: Profit/loss pnl: Total profit/loss (backward compatibility)
strategy_pnl: Strategy PnL component
fx_pnl: FX PnL component
market: Market code market: Market code
exchange_code: Exchange code exchange_code: Exchange code
session_id: Session identifier (if omitted, auto-derived from market)
selection_context: Scanner selection data (RSI, volume_ratio, signal, score) selection_context: Scanner selection data (RSI, volume_ratio, signal, score)
decision_id: Unique decision identifier for audit linking decision_id: Unique decision identifier for audit linking
mode: Trading mode ('paper' or 'live') for data separation mode: Trading mode ('paper' or 'live') for data separation
""" """
# Serialize selection context to JSON # Serialize selection context to JSON
context_json = json.dumps(selection_context) if selection_context else None context_json = json.dumps(selection_context) if selection_context else None
resolved_session_id = _resolve_session_id(market=market, session_id=session_id)
if strategy_pnl is None and fx_pnl is None:
strategy_pnl = pnl
fx_pnl = 0.0
elif strategy_pnl is None:
strategy_pnl = pnl - float(fx_pnl or 0.0) if pnl != 0.0 else 0.0
elif fx_pnl is None:
fx_pnl = pnl - float(strategy_pnl) if pnl != 0.0 else 0.0
if pnl == 0.0 and (strategy_pnl or fx_pnl):
pnl = float(strategy_pnl) + float(fx_pnl)
conn.execute( conn.execute(
""" """
INSERT INTO trades ( INSERT INTO trades (
timestamp, stock_code, action, confidence, rationale, timestamp, stock_code, action, confidence, rationale,
quantity, price, pnl, market, exchange_code, selection_context, decision_id, quantity, price, pnl, strategy_pnl, fx_pnl,
mode market, exchange_code, session_id, selection_context, decision_id, mode
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
datetime.now(UTC).isoformat(), datetime.now(UTC).isoformat(),
@@ -215,8 +283,11 @@ def log_trade(
quantity, quantity,
price, price,
pnl, pnl,
strategy_pnl,
fx_pnl,
market, market,
exchange_code, exchange_code,
resolved_session_id,
context_json, context_json,
decision_id, decision_id,
mode, mode,
@@ -225,10 +296,50 @@ def log_trade(
conn.commit() conn.commit()
def _resolve_session_id(*, market: str, session_id: str | None) -> str:
if session_id:
return session_id
try:
from src.core.order_policy import classify_session_id
from src.markets.schedule import MARKETS
market_info = MARKETS.get(market)
if market_info is not None:
return classify_session_id(market_info)
except Exception:
pass
return "UNKNOWN"
def get_latest_buy_trade( 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: ) -> dict[str, Any] | None:
"""Fetch the most recent BUY trade for a stock and market.""" """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( cursor = conn.execute(
""" """
SELECT decision_id, price, quantity SELECT decision_id, price, quantity

View File

@@ -9,6 +9,7 @@ This module:
from __future__ import annotations from __future__ import annotations
import ast
import json import json
import logging import logging
import sqlite3 import sqlite3
@@ -28,7 +29,7 @@ from src.logging.decision_logger import DecisionLogger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
STRATEGIES_DIR = Path("src/strategies") STRATEGIES_DIR = Path("src/strategies")
STRATEGY_TEMPLATE = textwrap.dedent("""\ STRATEGY_TEMPLATE = """\
\"\"\"Auto-generated strategy: {name} \"\"\"Auto-generated strategy: {name}
Generated at: {timestamp} Generated at: {timestamp}
@@ -45,7 +46,7 @@ STRATEGY_TEMPLATE = textwrap.dedent("""\
def evaluate(self, market_data: dict[str, Any]) -> dict[str, Any]: def evaluate(self, market_data: dict[str, Any]) -> dict[str, Any]:
{body} {body}
""") """
class EvolutionOptimizer: class EvolutionOptimizer:
@@ -235,7 +236,8 @@ class EvolutionOptimizer:
file_path = STRATEGIES_DIR / file_name file_path = STRATEGIES_DIR / file_name
# Indent the body for the class method # 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 # Generate rationale from patterns
rationale = f"Auto-evolved from {len(failures)} failures. " rationale = f"Auto-evolved from {len(failures)} failures. "
@@ -247,9 +249,16 @@ class EvolutionOptimizer:
timestamp=datetime.now(UTC).isoformat(), timestamp=datetime.now(UTC).isoformat(),
rationale=rationale, rationale=rationale,
class_name=class_name, 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) file_path.write_text(content)
logger.info("Generated strategy file: %s", file_path) logger.info("Generated strategy file: %s", file_path)
return file_path return file_path

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
"""Market schedule management with timezone support.""" """Market schedule management with timezone support."""
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, time, timedelta from datetime import UTC, datetime, time, timedelta
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -181,7 +181,10 @@ def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool:
def get_open_markets( 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]: ) -> list[MarketInfo]:
""" """
Get list of currently open markets. Get list of currently open markets.
@@ -196,17 +199,31 @@ def get_open_markets(
if enabled_markets is None: if enabled_markets is None:
enabled_markets = list(MARKETS.keys()) 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 = [ open_markets = [
MARKETS[code] MARKETS[code]
for code in enabled_markets 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) return sorted(open_markets, key=lambda m: m.code)
def get_next_market_open( 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]: ) -> tuple[MarketInfo, datetime]:
""" """
Find the next market that will open and when. 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_open_time: datetime | None = None
next_market: MarketInfo | 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: for code in enabled_markets:
if code not in MARKETS: if code not in MARKETS:
continue continue
@@ -240,6 +272,13 @@ def get_next_market_open(
market = MARKETS[code] market = MARKETS[code]
market_now = now.astimezone(market.timezone) 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 # Calculate next open time for this market
for days_ahead in range(7): # Check next 7 days for days_ahead in range(7): # Check next 7 days
check_date = market_now.date() + timedelta(days=days_ahead) check_date = market_now.date() + timedelta(days=days_ahead)

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
import pytest
from src.analysis.backtest_cost_guard import BacktestCostModel, validate_backtest_cost_model
def test_valid_backtest_cost_model_passes() -> None:
model = BacktestCostModel(
commission_bps=5.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,
)
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG", "US_PRE"])
def test_missing_required_slippage_session_raises() -> None:
model = BacktestCostModel(
commission_bps=5.0,
slippage_bps_by_session={"KRX_REG": 10.0},
failure_rate_by_session={"KRX_REG": 0.01, "US_PRE": 0.08},
unfavorable_fill_required=True,
)
with pytest.raises(ValueError, match="missing slippage_bps_by_session.*US_PRE"):
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG", "US_PRE"])
def test_missing_required_failure_rate_session_raises() -> None:
model = BacktestCostModel(
commission_bps=5.0,
slippage_bps_by_session={"KRX_REG": 10.0, "US_PRE": 50.0},
failure_rate_by_session={"KRX_REG": 0.01},
unfavorable_fill_required=True,
)
with pytest.raises(ValueError, match="missing failure_rate_by_session.*US_PRE"):
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG", "US_PRE"])
def test_invalid_failure_rate_range_raises() -> None:
model = BacktestCostModel(
commission_bps=5.0,
slippage_bps_by_session={"KRX_REG": 10.0},
failure_rate_by_session={"KRX_REG": 1.2},
unfavorable_fill_required=True,
)
with pytest.raises(ValueError, match="failure rate must be within"):
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG"])
def test_unfavorable_fill_requirement_cannot_be_disabled() -> None:
model = BacktestCostModel(
commission_bps=5.0,
slippage_bps_by_session={"KRX_REG": 10.0},
failure_rate_by_session={"KRX_REG": 0.02},
unfavorable_fill_required=False,
)
with pytest.raises(ValueError, match="unfavorable_fill_required must be True"):
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG"])
@pytest.mark.parametrize("bad_commission", [float("nan"), float("inf"), float("-inf")])
def test_non_finite_commission_rejected(bad_commission: float) -> None:
model = BacktestCostModel(
commission_bps=bad_commission,
slippage_bps_by_session={"KRX_REG": 10.0},
failure_rate_by_session={"KRX_REG": 0.02},
unfavorable_fill_required=True,
)
with pytest.raises(ValueError, match="commission_bps"):
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG"])
@pytest.mark.parametrize("bad_slippage", [float("nan"), float("inf"), float("-inf")])
def test_non_finite_slippage_rejected(bad_slippage: float) -> None:
model = BacktestCostModel(
commission_bps=5.0,
slippage_bps_by_session={"KRX_REG": bad_slippage},
failure_rate_by_session={"KRX_REG": 0.02},
unfavorable_fill_required=True,
)
with pytest.raises(ValueError, match="slippage bps"):
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG"])

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
import pytest
from src.analysis.backtest_execution_model import (
BacktestExecutionModel,
ExecutionAssumptions,
ExecutionRequest,
)
def test_buy_uses_unfavorable_slippage_direction() -> None:
model = BacktestExecutionModel(
ExecutionAssumptions(
slippage_bps_by_session={"US_PRE": 50.0},
failure_rate_by_session={"US_PRE": 0.0},
partial_fill_rate_by_session={"US_PRE": 0.0},
seed=1,
)
)
out = model.simulate(
ExecutionRequest(side="BUY", session_id="US_PRE", qty=10, reference_price=100.0)
)
assert out.status == "FILLED"
assert out.avg_price == pytest.approx(100.5)
def test_sell_uses_unfavorable_slippage_direction() -> None:
model = BacktestExecutionModel(
ExecutionAssumptions(
slippage_bps_by_session={"US_PRE": 50.0},
failure_rate_by_session={"US_PRE": 0.0},
partial_fill_rate_by_session={"US_PRE": 0.0},
seed=1,
)
)
out = model.simulate(
ExecutionRequest(side="SELL", session_id="US_PRE", qty=10, reference_price=100.0)
)
assert out.status == "FILLED"
assert out.avg_price == pytest.approx(99.5)
def test_failure_rate_can_reject_order() -> None:
model = BacktestExecutionModel(
ExecutionAssumptions(
slippage_bps_by_session={"KRX_REG": 10.0},
failure_rate_by_session={"KRX_REG": 1.0},
partial_fill_rate_by_session={"KRX_REG": 0.0},
seed=42,
)
)
out = model.simulate(
ExecutionRequest(side="BUY", session_id="KRX_REG", qty=10, reference_price=100.0)
)
assert out.status == "REJECTED"
assert out.filled_qty == 0
def test_partial_fill_applies_when_rate_is_one() -> None:
model = BacktestExecutionModel(
ExecutionAssumptions(
slippage_bps_by_session={"KRX_REG": 0.0},
failure_rate_by_session={"KRX_REG": 0.0},
partial_fill_rate_by_session={"KRX_REG": 1.0},
partial_fill_min_ratio=0.4,
partial_fill_max_ratio=0.4,
seed=0,
)
)
out = model.simulate(
ExecutionRequest(side="BUY", session_id="KRX_REG", qty=10, reference_price=100.0)
)
assert out.status == "PARTIAL"
assert out.filled_qty == 4
assert out.avg_price == 100.0
@pytest.mark.parametrize("bad_slip", [-1.0, float("nan"), float("inf")])
def test_invalid_slippage_is_rejected(bad_slip: float) -> None:
with pytest.raises(ValueError, match="slippage_bps"):
BacktestExecutionModel(
ExecutionAssumptions(
slippage_bps_by_session={"US_PRE": bad_slip},
failure_rate_by_session={"US_PRE": 0.0},
partial_fill_rate_by_session={"US_PRE": 0.0},
)
)
@pytest.mark.parametrize("bad_rate", [-0.1, 1.1, float("nan")])
def test_invalid_failure_or_partial_rates_are_rejected(bad_rate: float) -> None:
with pytest.raises(ValueError, match="failure_rate"):
BacktestExecutionModel(
ExecutionAssumptions(
slippage_bps_by_session={"US_PRE": 10.0},
failure_rate_by_session={"US_PRE": bad_rate},
partial_fill_rate_by_session={"US_PRE": 0.0},
)
)
with pytest.raises(ValueError, match="partial_fill_rate"):
BacktestExecutionModel(
ExecutionAssumptions(
slippage_bps_by_session={"US_PRE": 10.0},
failure_rate_by_session={"US_PRE": 0.0},
partial_fill_rate_by_session={"US_PRE": bad_rate},
)
)

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
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]:
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",
)
)
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_bars=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_bars=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_bars=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

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from datetime import UTC, datetime
from src.core.blackout_manager import (
BlackoutOrderManager,
QueuedOrderIntent,
parse_blackout_windows_kst,
)
def test_parse_blackout_windows_kst() -> None:
windows = parse_blackout_windows_kst("23:30-00:10,11:20-11:30,invalid")
assert len(windows) == 2
def test_blackout_manager_handles_cross_midnight_window() -> None:
manager = BlackoutOrderManager(
enabled=True,
windows=parse_blackout_windows_kst("23:30-00:10"),
max_queue_size=10,
)
# 2026-01-01 23:40 KST = 2026-01-01 14:40 UTC
assert manager.in_blackout(datetime(2026, 1, 1, 14, 40, tzinfo=UTC))
# 2026-01-02 00:20 KST = 2026-01-01 15:20 UTC
assert not manager.in_blackout(datetime(2026, 1, 1, 15, 20, tzinfo=UTC))
def test_recovery_batch_only_after_blackout_exit() -> None:
manager = BlackoutOrderManager(
enabled=True,
windows=parse_blackout_windows_kst("23:30-00:10"),
max_queue_size=10,
)
intent = QueuedOrderIntent(
market_code="KR",
exchange_code="KRX",
stock_code="005930",
order_type="BUY",
quantity=1,
price=100.0,
source="test",
queued_at=datetime.now(UTC),
)
assert manager.enqueue(intent)
# Inside blackout: no pop yet
inside_blackout = datetime(2026, 1, 1, 14, 40, tzinfo=UTC)
assert manager.pop_recovery_batch(inside_blackout) == []
# Outside blackout: pop full batch once
outside_blackout = datetime(2026, 1, 1, 15, 20, tzinfo=UTC)
batch = manager.pop_recovery_batch(outside_blackout)
assert len(batch) == 1
assert manager.pending_count == 0
def test_requeued_intent_is_processed_next_non_blackout_cycle() -> None:
manager = BlackoutOrderManager(
enabled=True,
windows=parse_blackout_windows_kst("23:30-00:10"),
max_queue_size=10,
)
intent = QueuedOrderIntent(
market_code="KR",
exchange_code="KRX",
stock_code="005930",
order_type="BUY",
quantity=1,
price=100.0,
source="test",
queued_at=datetime.now(UTC),
)
manager.enqueue(intent)
outside_blackout = datetime(2026, 1, 1, 15, 20, tzinfo=UTC)
first_batch = manager.pop_recovery_batch(outside_blackout)
assert len(first_batch) == 1
manager.requeue(first_batch[0])
second_batch = manager.pop_recovery_batch(outside_blackout)
assert len(second_batch) == 1

View File

@@ -3,7 +3,7 @@
import tempfile import tempfile
import os 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: def test_get_open_position_returns_latest_buy() -> None:
@@ -155,6 +155,9 @@ def test_mode_column_exists_in_schema() -> None:
cursor = conn.execute("PRAGMA table_info(trades)") cursor = conn.execute("PRAGMA table_info(trades)")
columns = {row[1] for row in cursor.fetchall()} columns = {row[1] for row in cursor.fetchall()}
assert "mode" in columns assert "mode" in columns
assert "session_id" in columns
assert "strategy_pnl" in columns
assert "fx_pnl" in columns
def test_mode_migration_adds_column_to_existing_db() -> None: def test_mode_migration_adds_column_to_existing_db() -> None:
@@ -182,6 +185,13 @@ def test_mode_migration_adds_column_to_existing_db() -> None:
decision_id TEXT decision_id TEXT
)""" )"""
) )
old_conn.execute(
"""
INSERT INTO trades (
timestamp, stock_code, action, confidence, rationale, quantity, price, pnl
) VALUES ('2026-01-01T00:00:00+00:00', 'AAPL', 'SELL', 90, 'legacy', 1, 100.0, 123.45)
"""
)
old_conn.commit() old_conn.commit()
old_conn.close() old_conn.close()
@@ -190,6 +200,218 @@ def test_mode_migration_adds_column_to_existing_db() -> None:
cursor = conn.execute("PRAGMA table_info(trades)") cursor = conn.execute("PRAGMA table_info(trades)")
columns = {row[1] for row in cursor.fetchall()} columns = {row[1] for row in cursor.fetchall()}
assert "mode" in columns assert "mode" in columns
assert "session_id" in columns
assert "strategy_pnl" in columns
assert "fx_pnl" in columns
migrated = conn.execute(
"SELECT pnl, strategy_pnl, fx_pnl, session_id FROM trades WHERE stock_code='AAPL' LIMIT 1"
).fetchone()
assert migrated is not None
assert migrated[0] == 123.45
assert migrated[1] == 123.45
assert migrated[2] == 0.0
assert migrated[3] == "UNKNOWN"
conn.close()
finally:
os.unlink(db_path)
def test_log_trade_stores_strategy_and_fx_pnl_separately() -> None:
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="AAPL",
action="SELL",
confidence=90,
rationale="fx split",
pnl=120.0,
strategy_pnl=100.0,
fx_pnl=20.0,
market="US_NASDAQ",
exchange_code="NASD",
)
row = conn.execute(
"SELECT pnl, strategy_pnl, fx_pnl FROM trades ORDER BY id DESC LIMIT 1"
).fetchone()
assert row is not None
assert row[0] == 120.0
assert row[1] == 100.0
assert row[2] == 20.0
def test_log_trade_backward_compat_sets_strategy_pnl_from_pnl() -> None:
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="005930",
action="SELL",
confidence=80,
rationale="legacy",
pnl=50.0,
market="KR",
exchange_code="KRX",
)
row = conn.execute(
"SELECT pnl, strategy_pnl, fx_pnl FROM trades ORDER BY id DESC LIMIT 1"
).fetchone()
assert row is not None
assert row[0] == 50.0
assert row[1] == 50.0
assert row[2] == 0.0
def test_log_trade_partial_fx_input_does_not_infer_negative_strategy_pnl() -> None:
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="AAPL",
action="SELL",
confidence=70,
rationale="fx only",
pnl=0.0,
fx_pnl=10.0,
market="US_NASDAQ",
exchange_code="NASD",
)
row = conn.execute(
"SELECT pnl, strategy_pnl, fx_pnl FROM trades ORDER BY id DESC LIMIT 1"
).fetchone()
assert row is not None
assert row[0] == 10.0
assert row[1] == 0.0
assert row[2] == 10.0
def test_log_trade_persists_explicit_session_id() -> None:
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="AAPL",
action="BUY",
confidence=70,
rationale="session test",
market="US_NASDAQ",
exchange_code="NASD",
session_id="US_PRE",
)
row = conn.execute("SELECT session_id FROM trades ORDER BY id DESC LIMIT 1").fetchone()
assert row is not None
assert row[0] == "US_PRE"
def test_log_trade_auto_derives_session_id_when_not_provided() -> None:
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="005930",
action="BUY",
confidence=70,
rationale="auto session",
market="KR",
exchange_code="KRX",
)
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_log_trade_unknown_market_falls_back_to_unknown_session() -> None:
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="X",
action="BUY",
confidence=70,
rationale="unknown market",
market="MARS",
exchange_code="MARS",
)
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() conn.close()
finally: finally:
os.unlink(db_path) 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 # Verify record exists in database
cursor = db_conn.execute( 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,), (decision_id,),
) )
row = cursor.fetchone() 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[0] == decision_id
assert row[1] == "BUY" assert row[1] == "BUY"
assert row[2] == 85 assert row[2] == 85
assert row[3] == "UNKNOWN"
def test_log_decision_stores_context_snapshot(logger: DecisionLogger) -> None: 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 is not None
assert decision.context_snapshot == context_snapshot assert decision.context_snapshot == context_snapshot
assert decision.input_data == input_data 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: def test_get_unreviewed_decisions(logger: DecisionLogger) -> None:
@@ -278,6 +297,7 @@ def test_decision_log_dataclass() -> None:
stock_code="005930", stock_code="005930",
market="KR", market="KR",
exchange_code="KRX", exchange_code="KRX",
session_id="KRX_REG",
action="BUY", action="BUY",
confidence=85, confidence=85,
rationale="Test", rationale="Test",
@@ -286,6 +306,7 @@ def test_decision_log_dataclass() -> None:
) )
assert log.decision_id == "test-uuid" assert log.decision_id == "test-uuid"
assert log.session_id == "KRX_REG"
assert log.action == "BUY" assert log.action == "BUY"
assert log.confidence == 85 assert log.confidence == 85
assert log.reviewed is False 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() 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 @pytest.mark.asyncio
async def test_generate_strategy_handles_api_error(optimizer: EvolutionOptimizer) -> None: async def test_generate_strategy_handles_api_error(optimizer: EvolutionOptimizer) -> None:
"""Test that generate_strategy handles Gemini API errors gracefully.""" """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] codes = [m.code for m in open_markets]
assert codes == sorted(codes) 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: class TestGetNextMarketOpen:
"""Test get_next_market_open function.""" """Test get_next_market_open function."""
@@ -201,6 +219,20 @@ class TestGetNextMarketOpen:
) )
assert market.code == "KR" 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: class TestExpandMarketCodes:
"""Test shorthand market expansion.""" """Test shorthand market expansion."""

View File

@@ -0,0 +1,184 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from src.analysis.triple_barrier import TripleBarrierSpec, label_with_triple_barrier
def test_long_take_profit_first() -> 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)
out = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
entry_index=0,
side=1,
spec=spec,
)
assert out.label == 1
assert out.touched == "take_profit"
assert out.touch_bar == 2
def test_long_stop_loss_first() -> None:
highs = [100, 100.5, 101]
lows = [100, 98.8, 99]
closes = [100, 99.5, 100]
spec = TripleBarrierSpec(take_profit_pct=0.02, stop_loss_pct=0.01, max_holding_bars=3)
out = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
entry_index=0,
side=1,
spec=spec,
)
assert out.label == -1
assert out.touched == "stop_loss"
assert out.touch_bar == 1
def test_time_barrier_timeout() -> None:
highs = [100, 100.8, 100.7]
lows = [100, 99.3, 99.4]
closes = [100, 100, 100]
spec = TripleBarrierSpec(take_profit_pct=0.02, stop_loss_pct=0.02, max_holding_bars=2)
out = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
entry_index=0,
side=1,
spec=spec,
)
assert out.label == 0
assert out.touched == "time"
assert out.touch_bar == 2
def test_tie_break_stop_first_default() -> None:
highs = [100, 102.1]
lows = [100, 98.9]
closes = [100, 100]
spec = TripleBarrierSpec(take_profit_pct=0.02, stop_loss_pct=0.01, max_holding_bars=1)
out = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
entry_index=0,
side=1,
spec=spec,
)
assert out.label == -1
assert out.touched == "stop_loss"
def test_short_side_inverts_barrier_semantics() -> None:
highs = [100, 100.5, 101.2]
lows = [100, 97.8, 98.0]
closes = [100, 99, 99]
spec = TripleBarrierSpec(take_profit_pct=0.02, stop_loss_pct=0.01, max_holding_bars=3)
out = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
entry_index=0,
side=-1,
spec=spec,
)
assert out.label == 1
assert out.touched == "take_profit"
def test_short_tie_break_modes() -> None:
highs = [100, 101.1]
lows = [100, 97.9]
closes = [100, 100]
stop_first = TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.01,
max_holding_bars=1,
tie_break="stop_first",
)
out_stop = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
entry_index=0,
side=-1,
spec=stop_first,
)
assert out_stop.label == -1
assert out_stop.touched == "stop_loss"
take_first = TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.01,
max_holding_bars=1,
tie_break="take_first",
)
out_take = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
entry_index=0,
side=-1,
spec=take_first,
)
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,81 @@
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",
]

View File

@@ -0,0 +1,92 @@
from __future__ import annotations
import pytest
from src.analysis.walk_forward_split import generate_walk_forward_splits
def test_generates_sequential_folds() -> None:
folds = generate_walk_forward_splits(
n_samples=30,
train_size=10,
test_size=5,
)
assert len(folds) == 4
assert folds[0].train_indices == list(range(0, 10))
assert folds[0].test_indices == list(range(10, 15))
assert folds[1].train_indices == list(range(5, 15))
assert folds[1].test_indices == list(range(15, 20))
def test_purge_removes_boundary_samples_before_test() -> None:
folds = generate_walk_forward_splits(
n_samples=25,
train_size=8,
test_size=4,
purge_size=2,
)
first = folds[0]
# test starts at 10, purge=2 => train end must be 7
assert first.train_indices == list(range(0, 8))
assert first.test_indices == list(range(10, 14))
def test_embargo_excludes_post_test_samples_from_next_train() -> None:
folds = generate_walk_forward_splits(
n_samples=45,
train_size=15,
test_size=5,
step_size=10,
embargo_size=3,
)
assert len(folds) >= 2
# Fold1 test: 15..19, next fold train window: 10..24.
# embargo_size=3 should remove 20,21,22 from fold2 train.
second_train = folds[1].train_indices
assert 20 not in second_train
assert 21 not in second_train
assert 22 not in second_train
assert 23 in second_train
def test_respects_min_train_size_and_returns_empty_when_impossible() -> None:
folds = generate_walk_forward_splits(
n_samples=15,
train_size=5,
test_size=5,
min_train_size=6,
)
assert folds == []
def test_embargo_uses_last_accepted_fold_when_intermediate_fold_skips() -> None:
folds = generate_walk_forward_splits(
n_samples=30,
train_size=5,
test_size=3,
step_size=5,
embargo_size=1,
min_train_size=5,
)
# 1st fold accepted, 2nd skipped by min_train_size, subsequent folds still generated.
assert len(folds) == 3
assert folds[0].test_indices == [5, 6, 7]
assert folds[1].test_indices == [15, 16, 17]
assert folds[2].test_indices == [25, 26, 27]
@pytest.mark.parametrize(
("n_samples", "train_size", "test_size"),
[
(0, 10, 2),
(10, 0, 2),
(10, 5, 0),
],
)
def test_invalid_args_raise(n_samples: int, train_size: int, test_size: int) -> None:
with pytest.raises(ValueError):
generate_walk_forward_splits(
n_samples=n_samples,
train_size=train_size,
test_size=test_size,
)

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) 중심으로 전환