Compare commits
69 Commits
1381b140ab
...
feature/v3
| Author | SHA1 | Date | |
|---|---|---|---|
| fc6083bd2a | |||
|
|
5f53b02da8 | ||
|
|
82808a8493 | ||
| 9456d66de4 | |||
| 33b97f21ac | |||
| 3b135c3080 | |||
| 1b0d5568d3 | |||
|
|
2406a80782 | ||
| b8569d9de1 | |||
|
|
92261da414 | ||
|
|
9267f1fb77 | ||
|
|
fd0246769a | ||
| ea7260d574 | |||
| a2855e286e | |||
| 28ded34441 | |||
|
|
08607eaa56 | ||
|
|
5c107d2435 | ||
|
|
6d7e6557d2 | ||
|
|
11b9ad126f | ||
|
|
2e394cd17c | ||
|
|
c641097fe7 | ||
|
|
2f3b2149d5 | ||
| 13a6d6612a | |||
|
|
ca5fa73769 | ||
|
|
ab9ea56efa | ||
| 8dc9f95032 | |||
|
|
dd51ffb6ac | ||
| 0542e78f90 | |||
|
|
8396dc1606 | ||
| 343631a935 | |||
|
|
c00525eb4d | ||
| 1ae12f92f6 | |||
|
|
98dab2e06e | ||
| a63d23fab9 | |||
|
|
85a59542f8 | ||
| 5830791355 | |||
|
|
b1610f14c5 | ||
| 1984065499 | |||
|
|
d912471d0e | ||
| 5f337e2ebc | |||
|
|
4a404875a9 | ||
| cdd3814781 | |||
|
|
dbf57b5068 | ||
| 7efc254ab5 | |||
|
|
2742628b78 | ||
| d60fd8947b | |||
|
|
694d73b212 | ||
|
|
b2b02b6f57 | ||
| 2dbe98615d | |||
|
|
34cf081c96 | ||
|
|
7bc4e88335 | ||
| 386e039ff6 | |||
|
|
13ba9e8081 | ||
|
|
5b52f593a8 | ||
| 2798558bf3 | |||
|
|
2331d80915 | ||
|
|
7d72669cb8 | ||
| 74a4784b7a | |||
|
|
dc70311aed | ||
|
|
e56819e9e2 | ||
| cfd5351b58 | |||
|
|
b206c23fc9 | ||
|
|
4d9f3e2cfc | ||
| a93a5c616b | |||
|
|
9f64c9944a | ||
|
|
bb391d502c | ||
| b0100fde10 | |||
|
|
0a4e69d40c | ||
|
|
25401ac132 |
41
.gitea/ISSUE_TEMPLATE/runtime_verification.md
Normal file
41
.gitea/ISSUE_TEMPLATE/runtime_verification.md
Normal 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`
|
||||||
|
|
||||||
53
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
53
.gitea/PULL_REQUEST_TEMPLATE.md
Normal 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
51
.gitea/workflows/ci.yml
Normal 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
66
.github/workflows/backtest-gate.yml
vendored
Normal 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
|
||||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) 감사 추적
|
||||||
|
|
||||||
|
|||||||
355
docs/ouroboros/80_implementation_audit.md
Normal file
355
docs/ouroboros/80_implementation_audit.md
Normal 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*끝.*
|
||||||
96
docs/ouroboros/82_doc_restructure_plan.md
Normal file
96
docs/ouroboros/82_doc_restructure_plan.md
Normal 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_ 각 항목에 수용 기준과 테스트 계획이 포함되었는지 확인
|
||||||
392
docs/ouroboros/85_loss_recovery_action_plan.md
Normal file
392
docs/ouroboros/85_loss_recovery_action_plan.md
Normal 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 검증
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*끝.*
|
||||||
@@ -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)
|
||||||
|
|
||||||
## 운영 규칙
|
## 운영 규칙
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
106
docs/workflow.md
106
docs/workflow.md
@@ -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
184
scripts/audit_queries.sql
Normal 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
106
scripts/backtest_gate.sh
Executable 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
114
scripts/runtime_verify_monitor.sh
Executable 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
146
scripts/session_handover_check.py
Executable 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())
|
||||||
156
scripts/validate_governance_assets.py
Normal file
156
scripts/validate_governance_assets.py
Normal 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())
|
||||||
52
src/analysis/backtest_cost_guard.py
Normal file
52
src/analysis/backtest_cost_guard.py
Normal 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}")
|
||||||
103
src/analysis/backtest_execution_model.py
Normal file
103
src/analysis/backtest_execution_model.py
Normal 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",
|
||||||
|
)
|
||||||
187
src/analysis/backtest_pipeline.py
Normal file
187
src/analysis/backtest_pipeline.py
Normal 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))
|
||||||
139
src/analysis/triple_barrier.py
Normal file
139
src/analysis/triple_barrier.py
Normal 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,
|
||||||
|
)
|
||||||
74
src/analysis/walk_forward_split.py
Normal file
74
src/analysis/walk_forward_split.py
Normal 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
|
||||||
@@ -59,6 +59,10 @@ 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)
|
||||||
|
STOPLOSS_REENTRY_COOLDOWN_MINUTES: int = Field(default=120, ge=1, le=1440)
|
||||||
|
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)$")
|
||||||
|
|||||||
@@ -98,3 +98,8 @@ class BlackoutOrderManager:
|
|||||||
def requeue(self, intent: QueuedOrderIntent) -> None:
|
def requeue(self, intent: QueuedOrderIntent) -> None:
|
||||||
if len(self._queue) < self._max_queue_size:
|
if len(self._queue) < self._max_queue_size:
|
||||||
self._queue.append(intent)
|
self._queue.append(intent)
|
||||||
|
|
||||||
|
def clear(self) -> int:
|
||||||
|
count = len(self._queue)
|
||||||
|
self._queue.clear()
|
||||||
|
return count
|
||||||
|
|||||||
147
src/db.py
147
src/db.py
@@ -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,23 +296,63 @@ 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."""
|
||||||
cursor = conn.execute(
|
if exchange_code:
|
||||||
"""
|
cursor = conn.execute(
|
||||||
SELECT decision_id, price, quantity
|
"""
|
||||||
FROM trades
|
SELECT decision_id, price, quantity
|
||||||
WHERE stock_code = ?
|
FROM trades
|
||||||
AND market = ?
|
WHERE stock_code = ?
|
||||||
AND action = 'BUY'
|
AND market = ?
|
||||||
AND decision_id IS NOT NULL
|
AND action = 'BUY'
|
||||||
ORDER BY timestamp DESC
|
AND decision_id IS NOT NULL
|
||||||
LIMIT 1
|
AND (
|
||||||
""",
|
exchange_code = ?
|
||||||
(stock_code, market),
|
OR exchange_code IS NULL
|
||||||
)
|
OR exchange_code = ''
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN exchange_code = ? THEN 0 ELSE 1 END,
|
||||||
|
timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(stock_code, market, exchange_code, exchange_code),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT decision_id, price, quantity
|
||||||
|
FROM trades
|
||||||
|
WHERE stock_code = ?
|
||||||
|
AND market = ?
|
||||||
|
AND action = 'BUY'
|
||||||
|
AND decision_id IS NOT NULL
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(stock_code, market),
|
||||||
|
)
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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,24 +29,24 @@ 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}
|
||||||
Rationale: {rationale}
|
Rationale: {rationale}
|
||||||
\"\"\"
|
\"\"\"
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from src.strategies.base import BaseStrategy
|
from src.strategies.base import BaseStrategy
|
||||||
|
|
||||||
|
|
||||||
class {class_name}(BaseStrategy):
|
class {class_name}(BaseStrategy):
|
||||||
\"\"\"Strategy: {name}\"\"\"
|
\"\"\"Strategy: {name}\"\"\"
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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],
|
||||||
)
|
)
|
||||||
|
|||||||
733
src/main.py
733
src/main.py
@@ -33,7 +33,11 @@ from src.core.blackout_manager import (
|
|||||||
parse_blackout_windows_kst,
|
parse_blackout_windows_kst,
|
||||||
)
|
)
|
||||||
from src.core.kill_switch import KillSwitchOrchestrator
|
from src.core.kill_switch import KillSwitchOrchestrator
|
||||||
from src.core.order_policy import OrderPolicyRejected, validate_order_policy
|
from src.core.order_policy import (
|
||||||
|
OrderPolicyRejected,
|
||||||
|
get_session_info,
|
||||||
|
validate_order_policy,
|
||||||
|
)
|
||||||
from src.core.priority_queue import PriorityTaskQueue
|
from src.core.priority_queue import PriorityTaskQueue
|
||||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager
|
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager
|
||||||
from src.db import (
|
from src.db import (
|
||||||
@@ -63,6 +67,10 @@ BLACKOUT_ORDER_MANAGER = BlackoutOrderManager(
|
|||||||
windows=[],
|
windows=[],
|
||||||
max_queue_size=500,
|
max_queue_size=500,
|
||||||
)
|
)
|
||||||
|
_SESSION_CLOSE_WINDOWS = {"NXT_AFTER", "US_AFTER"}
|
||||||
|
_RUNTIME_EXIT_STATES: dict[str, PositionState] = {}
|
||||||
|
_RUNTIME_EXIT_PEAKS: dict[str, float] = {}
|
||||||
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
def safe_float(value: str | float | None, default: float = 0.0) -> float:
|
def safe_float(value: str | float | None, default: float = 0.0) -> float:
|
||||||
@@ -103,6 +111,24 @@ DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
|
|||||||
TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions
|
TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_sell_qty_for_pnl(*, sell_qty: int | None, buy_qty: int | None) -> int:
|
||||||
|
"""Choose quantity basis for SELL outcome PnL with safe fallback."""
|
||||||
|
resolved_sell = int(sell_qty or 0)
|
||||||
|
if resolved_sell > 0:
|
||||||
|
return resolved_sell
|
||||||
|
return max(0, int(buy_qty or 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _stoploss_cooldown_key(*, market: MarketInfo, stock_code: str) -> str:
|
||||||
|
return f"{market.code}:{stock_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def _stoploss_cooldown_minutes(settings: Settings | None) -> int:
|
||||||
|
if settings is None:
|
||||||
|
return 120
|
||||||
|
return max(1, int(getattr(settings, "STOPLOSS_REENTRY_COOLDOWN_MINUTES", 120)))
|
||||||
|
|
||||||
|
|
||||||
async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kwargs: Any) -> Any:
|
async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kwargs: Any) -> Any:
|
||||||
"""Call an async function retrying on ConnectionError with exponential backoff.
|
"""Call an async function retrying on ConnectionError with exponential backoff.
|
||||||
|
|
||||||
@@ -210,6 +236,7 @@ async def sync_positions_from_broker(
|
|||||||
price=avg_price,
|
price=avg_price,
|
||||||
market=log_market,
|
market=log_market,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
|
session_id=get_session_info(market).session_id,
|
||||||
mode=settings.MODE,
|
mode=settings.MODE,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -429,6 +456,153 @@ def _determine_order_quantity(
|
|||||||
return quantity
|
return quantity
|
||||||
|
|
||||||
|
|
||||||
|
def _should_block_overseas_buy_for_fx_buffer(
|
||||||
|
*,
|
||||||
|
market: MarketInfo,
|
||||||
|
action: str,
|
||||||
|
total_cash: float,
|
||||||
|
order_amount: float,
|
||||||
|
settings: Settings | None,
|
||||||
|
) -> tuple[bool, float, float]:
|
||||||
|
if (
|
||||||
|
market.is_domestic
|
||||||
|
or not market.code.startswith("US")
|
||||||
|
or action != "BUY"
|
||||||
|
or settings is None
|
||||||
|
):
|
||||||
|
return False, total_cash - order_amount, 0.0
|
||||||
|
remaining = total_cash - order_amount
|
||||||
|
required = settings.USD_BUFFER_MIN
|
||||||
|
return remaining < required, remaining, required
|
||||||
|
|
||||||
|
|
||||||
|
def _should_force_exit_for_overnight(
|
||||||
|
*,
|
||||||
|
market: MarketInfo,
|
||||||
|
settings: Settings | None,
|
||||||
|
) -> bool:
|
||||||
|
session_id = get_session_info(market).session_id
|
||||||
|
if session_id not in _SESSION_CLOSE_WINDOWS:
|
||||||
|
return False
|
||||||
|
if KILL_SWITCH.new_orders_blocked:
|
||||||
|
return True
|
||||||
|
if settings is None:
|
||||||
|
return False
|
||||||
|
return not settings.OVERNIGHT_EXCEPTION_ENABLED
|
||||||
|
|
||||||
|
|
||||||
|
def _build_runtime_position_key(
|
||||||
|
*,
|
||||||
|
market_code: str,
|
||||||
|
stock_code: str,
|
||||||
|
open_position: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
decision_id = str(open_position.get("decision_id") or "")
|
||||||
|
timestamp = str(open_position.get("timestamp") or "")
|
||||||
|
return f"{market_code}:{stock_code}:{decision_id}:{timestamp}"
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_runtime_exit_cache_for_symbol(*, market_code: str, stock_code: str) -> None:
|
||||||
|
prefix = f"{market_code}:{stock_code}:"
|
||||||
|
stale_keys = [key for key in _RUNTIME_EXIT_STATES if key.startswith(prefix)]
|
||||||
|
for key in stale_keys:
|
||||||
|
_RUNTIME_EXIT_STATES.pop(key, None)
|
||||||
|
_RUNTIME_EXIT_PEAKS.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_staged_exit_override_for_hold(
|
||||||
|
*,
|
||||||
|
decision: TradeDecision,
|
||||||
|
market: MarketInfo,
|
||||||
|
stock_code: str,
|
||||||
|
open_position: dict[str, Any] | None,
|
||||||
|
market_data: dict[str, Any],
|
||||||
|
stock_playbook: Any | None,
|
||||||
|
) -> TradeDecision:
|
||||||
|
"""Apply v2 staged exit semantics for HOLD positions using runtime state."""
|
||||||
|
if decision.action != "HOLD" or not open_position:
|
||||||
|
return decision
|
||||||
|
|
||||||
|
entry_price = safe_float(open_position.get("price"), 0.0)
|
||||||
|
current_price = safe_float(market_data.get("current_price"), 0.0)
|
||||||
|
if entry_price <= 0 or current_price <= 0:
|
||||||
|
return decision
|
||||||
|
|
||||||
|
stop_loss_threshold = -2.0
|
||||||
|
take_profit_threshold = 3.0
|
||||||
|
if stock_playbook and stock_playbook.scenarios:
|
||||||
|
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
|
||||||
|
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
|
||||||
|
|
||||||
|
runtime_key = _build_runtime_position_key(
|
||||||
|
market_code=market.code,
|
||||||
|
stock_code=stock_code,
|
||||||
|
open_position=open_position,
|
||||||
|
)
|
||||||
|
current_state = _RUNTIME_EXIT_STATES.get(runtime_key, PositionState.HOLDING)
|
||||||
|
prev_peak = _RUNTIME_EXIT_PEAKS.get(runtime_key, 0.0)
|
||||||
|
peak_hint = max(
|
||||||
|
safe_float(market_data.get("peak_price"), 0.0),
|
||||||
|
safe_float(market_data.get("session_high_price"), 0.0),
|
||||||
|
)
|
||||||
|
peak_price = max(entry_price, current_price, prev_peak, peak_hint)
|
||||||
|
|
||||||
|
exit_eval = evaluate_exit(
|
||||||
|
current_state=current_state,
|
||||||
|
config=ExitRuleConfig(
|
||||||
|
hard_stop_pct=stop_loss_threshold,
|
||||||
|
be_arm_pct=max(0.5, take_profit_threshold * 0.4),
|
||||||
|
arm_pct=take_profit_threshold,
|
||||||
|
),
|
||||||
|
inp=ExitRuleInput(
|
||||||
|
current_price=current_price,
|
||||||
|
entry_price=entry_price,
|
||||||
|
peak_price=peak_price,
|
||||||
|
atr_value=safe_float(market_data.get("atr_value"), 0.0),
|
||||||
|
pred_down_prob=safe_float(market_data.get("pred_down_prob"), 0.0),
|
||||||
|
liquidity_weak=safe_float(market_data.get("volume_ratio"), 1.0) < 1.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_RUNTIME_EXIT_STATES[runtime_key] = exit_eval.state
|
||||||
|
_RUNTIME_EXIT_PEAKS[runtime_key] = peak_price
|
||||||
|
|
||||||
|
if not exit_eval.should_exit:
|
||||||
|
return decision
|
||||||
|
|
||||||
|
pnl_pct = (current_price - entry_price) / entry_price * 100.0
|
||||||
|
if exit_eval.reason == "hard_stop":
|
||||||
|
rationale = (
|
||||||
|
f"Stop-loss triggered ({pnl_pct:.2f}% <= "
|
||||||
|
f"{stop_loss_threshold:.2f}%)"
|
||||||
|
)
|
||||||
|
elif exit_eval.reason == "arm_take_profit":
|
||||||
|
rationale = (
|
||||||
|
f"Take-profit triggered ({pnl_pct:.2f}% >= "
|
||||||
|
f"{take_profit_threshold:.2f}%)"
|
||||||
|
)
|
||||||
|
elif exit_eval.reason == "atr_trailing_stop":
|
||||||
|
rationale = "ATR trailing-stop triggered"
|
||||||
|
elif exit_eval.reason == "be_lock_threat":
|
||||||
|
rationale = "Break-even lock threat detected"
|
||||||
|
elif exit_eval.reason == "model_liquidity_exit":
|
||||||
|
rationale = "Model/liquidity exit triggered"
|
||||||
|
else:
|
||||||
|
rationale = f"Exit rule triggered ({exit_eval.reason})"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Staged exit override for %s (%s): HOLD -> SELL (reason=%s, state=%s)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
exit_eval.reason,
|
||||||
|
exit_eval.state.value,
|
||||||
|
)
|
||||||
|
return TradeDecision(
|
||||||
|
action="SELL",
|
||||||
|
confidence=max(decision.confidence, 90),
|
||||||
|
rationale=rationale,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def build_overseas_symbol_universe(
|
async def build_overseas_symbol_universe(
|
||||||
db_conn: Any,
|
db_conn: Any,
|
||||||
overseas_broker: OverseasBroker,
|
overseas_broker: OverseasBroker,
|
||||||
@@ -596,6 +770,20 @@ async def process_blackout_recovery_orders(
|
|||||||
|
|
||||||
accepted = result.get("rt_cd", "0") == "0"
|
accepted = result.get("rt_cd", "0") == "0"
|
||||||
if accepted:
|
if accepted:
|
||||||
|
runtime_session_id = get_session_info(market).session_id
|
||||||
|
log_trade(
|
||||||
|
conn=db_conn,
|
||||||
|
stock_code=intent.stock_code,
|
||||||
|
action=intent.order_type,
|
||||||
|
confidence=0,
|
||||||
|
rationale=f"[blackout-recovery] {intent.source}",
|
||||||
|
quantity=intent.quantity,
|
||||||
|
price=float(intent.price),
|
||||||
|
pnl=0.0,
|
||||||
|
market=market.code,
|
||||||
|
exchange_code=market.exchange_code,
|
||||||
|
session_id=runtime_session_id,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Recovered queued order executed: %s %s (%s) qty=%d price=%.4f source=%s",
|
"Recovered queued order executed: %s %s (%s) qty=%d price=%.4f source=%s",
|
||||||
intent.order_type,
|
intent.order_type,
|
||||||
@@ -636,6 +824,187 @@ async def process_blackout_recovery_orders(
|
|||||||
BLACKOUT_ORDER_MANAGER.requeue(intent)
|
BLACKOUT_ORDER_MANAGER.requeue(intent)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_kill_switch_markets(
|
||||||
|
*,
|
||||||
|
settings: Settings | None,
|
||||||
|
current_market: MarketInfo | None,
|
||||||
|
) -> list[MarketInfo]:
|
||||||
|
if settings is not None:
|
||||||
|
markets: list[MarketInfo] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for market_code in settings.enabled_market_list:
|
||||||
|
market = MARKETS.get(market_code)
|
||||||
|
if market is None or market.code in seen:
|
||||||
|
continue
|
||||||
|
markets.append(market)
|
||||||
|
seen.add(market.code)
|
||||||
|
if markets:
|
||||||
|
return markets
|
||||||
|
if current_market is not None:
|
||||||
|
return [current_market]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _cancel_pending_orders_for_kill_switch(
|
||||||
|
*,
|
||||||
|
broker: KISBroker,
|
||||||
|
overseas_broker: OverseasBroker,
|
||||||
|
markets: list[MarketInfo],
|
||||||
|
) -> None:
|
||||||
|
failures: list[str] = []
|
||||||
|
domestic = [m for m in markets if m.is_domestic]
|
||||||
|
overseas = [m for m in markets if not m.is_domestic]
|
||||||
|
|
||||||
|
if domestic:
|
||||||
|
try:
|
||||||
|
orders = await broker.get_domestic_pending_orders()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("KillSwitch: failed to fetch domestic pending orders: %s", exc)
|
||||||
|
orders = []
|
||||||
|
for order in orders:
|
||||||
|
stock_code = str(order.get("pdno", ""))
|
||||||
|
try:
|
||||||
|
orgn_odno = order.get("orgn_odno", "")
|
||||||
|
krx_fwdg_ord_orgno = order.get("ord_gno_brno", "")
|
||||||
|
psbl_qty = int(order.get("psbl_qty", "0") or "0")
|
||||||
|
if not stock_code or not orgn_odno or psbl_qty <= 0:
|
||||||
|
continue
|
||||||
|
cancel_result = await broker.cancel_domestic_order(
|
||||||
|
stock_code=stock_code,
|
||||||
|
orgn_odno=orgn_odno,
|
||||||
|
krx_fwdg_ord_orgno=krx_fwdg_ord_orgno,
|
||||||
|
qty=psbl_qty,
|
||||||
|
)
|
||||||
|
if cancel_result.get("rt_cd") != "0":
|
||||||
|
failures.append(
|
||||||
|
"domestic cancel failed for"
|
||||||
|
f" {stock_code}: rt_cd={cancel_result.get('rt_cd')}"
|
||||||
|
f" msg={cancel_result.get('msg1')}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("KillSwitch: domestic cancel failed: %s", exc)
|
||||||
|
failures.append(f"domestic cancel exception for {stock_code}: {exc}")
|
||||||
|
|
||||||
|
us_exchanges = frozenset({"NASD", "NYSE", "AMEX"})
|
||||||
|
exchange_codes: list[str] = []
|
||||||
|
seen_us = False
|
||||||
|
for market in overseas:
|
||||||
|
exc_code = market.exchange_code
|
||||||
|
if exc_code in us_exchanges:
|
||||||
|
if not seen_us:
|
||||||
|
exchange_codes.append("NASD")
|
||||||
|
seen_us = True
|
||||||
|
elif exc_code not in exchange_codes:
|
||||||
|
exchange_codes.append(exc_code)
|
||||||
|
|
||||||
|
for exchange_code in exchange_codes:
|
||||||
|
try:
|
||||||
|
orders = await overseas_broker.get_overseas_pending_orders(exchange_code)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"KillSwitch: failed to fetch overseas pending orders for %s: %s",
|
||||||
|
exchange_code,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for order in orders:
|
||||||
|
stock_code = str(order.get("pdno", ""))
|
||||||
|
order_exchange = str(order.get("ovrs_excg_cd") or exchange_code)
|
||||||
|
try:
|
||||||
|
odno = order.get("odno", "")
|
||||||
|
nccs_qty = int(order.get("nccs_qty", "0") or "0")
|
||||||
|
if not stock_code or not odno or nccs_qty <= 0:
|
||||||
|
continue
|
||||||
|
cancel_result = await overseas_broker.cancel_overseas_order(
|
||||||
|
exchange_code=order_exchange,
|
||||||
|
stock_code=stock_code,
|
||||||
|
odno=odno,
|
||||||
|
qty=nccs_qty,
|
||||||
|
)
|
||||||
|
if cancel_result.get("rt_cd") != "0":
|
||||||
|
failures.append(
|
||||||
|
"overseas cancel failed for"
|
||||||
|
f" {order_exchange}/{stock_code}: rt_cd={cancel_result.get('rt_cd')}"
|
||||||
|
f" msg={cancel_result.get('msg1')}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("KillSwitch: overseas cancel failed: %s", exc)
|
||||||
|
failures.append(
|
||||||
|
f"overseas cancel exception for {order_exchange}/{stock_code}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
raise RuntimeError("; ".join(failures[:3]))
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_order_state_for_kill_switch(
|
||||||
|
*,
|
||||||
|
broker: KISBroker,
|
||||||
|
overseas_broker: OverseasBroker,
|
||||||
|
markets: list[MarketInfo],
|
||||||
|
) -> None:
|
||||||
|
seen_overseas: set[str] = set()
|
||||||
|
for market in markets:
|
||||||
|
try:
|
||||||
|
if market.is_domestic:
|
||||||
|
await broker.get_balance()
|
||||||
|
elif market.exchange_code not in seen_overseas:
|
||||||
|
seen_overseas.add(market.exchange_code)
|
||||||
|
await overseas_broker.get_overseas_balance(market.exchange_code)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"KillSwitch: refresh state failed for %s/%s: %s",
|
||||||
|
market.code,
|
||||||
|
market.exchange_code,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reduce_risk_for_kill_switch() -> None:
|
||||||
|
dropped = BLACKOUT_ORDER_MANAGER.clear()
|
||||||
|
logger.critical("KillSwitch: reduced queued order risk by clearing %d queued intents", dropped)
|
||||||
|
|
||||||
|
|
||||||
|
async def _trigger_emergency_kill_switch(
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
broker: KISBroker,
|
||||||
|
overseas_broker: OverseasBroker,
|
||||||
|
telegram: TelegramClient,
|
||||||
|
settings: Settings | None,
|
||||||
|
current_market: MarketInfo | None,
|
||||||
|
stock_code: str,
|
||||||
|
pnl_pct: float,
|
||||||
|
threshold: float,
|
||||||
|
) -> Any:
|
||||||
|
markets = _resolve_kill_switch_markets(settings=settings, current_market=current_market)
|
||||||
|
return await KILL_SWITCH.trigger(
|
||||||
|
reason=reason,
|
||||||
|
cancel_pending_orders=lambda: _cancel_pending_orders_for_kill_switch(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=overseas_broker,
|
||||||
|
markets=markets,
|
||||||
|
),
|
||||||
|
refresh_order_state=lambda: _refresh_order_state_for_kill_switch(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=overseas_broker,
|
||||||
|
markets=markets,
|
||||||
|
),
|
||||||
|
reduce_risk=_reduce_risk_for_kill_switch,
|
||||||
|
snapshot_state=lambda: logger.critical(
|
||||||
|
"KillSwitch snapshot %s/%s pnl=%.2f threshold=%.2f",
|
||||||
|
current_market.code if current_market else "UNKNOWN",
|
||||||
|
stock_code,
|
||||||
|
pnl_pct,
|
||||||
|
threshold,
|
||||||
|
),
|
||||||
|
notify=lambda: telegram.notify_circuit_breaker(
|
||||||
|
pnl_pct=pnl_pct,
|
||||||
|
threshold=threshold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def trading_cycle(
|
async def trading_cycle(
|
||||||
broker: KISBroker,
|
broker: KISBroker,
|
||||||
overseas_broker: OverseasBroker,
|
overseas_broker: OverseasBroker,
|
||||||
@@ -756,6 +1125,11 @@ async def trading_cycle(
|
|||||||
"foreigner_net": foreigner_net,
|
"foreigner_net": foreigner_net,
|
||||||
"price_change_pct": price_change_pct,
|
"price_change_pct": price_change_pct,
|
||||||
}
|
}
|
||||||
|
session_high_price = safe_float(
|
||||||
|
price_output.get("high") or price_output.get("ovrs_hgpr") or price_output.get("stck_hgpr")
|
||||||
|
)
|
||||||
|
if session_high_price > 0:
|
||||||
|
market_data["session_high_price"] = session_high_price
|
||||||
|
|
||||||
# Enrich market_data with scanner metrics for scenario engine
|
# Enrich market_data with scanner metrics for scenario engine
|
||||||
market_candidates = scan_candidates.get(market.code, {})
|
market_candidates = scan_candidates.get(market.code, {})
|
||||||
@@ -951,68 +1325,74 @@ async def trading_cycle(
|
|||||||
stock_code,
|
stock_code,
|
||||||
market.name,
|
market.name,
|
||||||
)
|
)
|
||||||
|
elif market.code.startswith("US"):
|
||||||
|
min_price = float(getattr(settings, "US_MIN_PRICE", 5.0) if settings else 5.0)
|
||||||
|
if current_price <= min_price:
|
||||||
|
decision = TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=decision.confidence,
|
||||||
|
rationale=(
|
||||||
|
f"US minimum price filter blocked BUY "
|
||||||
|
f"(price={current_price:.4f} <= {min_price:.4f})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"BUY suppressed for %s (%s): US min price filter %.4f <= %.4f",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
current_price,
|
||||||
|
min_price,
|
||||||
|
)
|
||||||
|
if decision.action == "BUY":
|
||||||
|
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
|
||||||
|
now_epoch = datetime.now(UTC).timestamp()
|
||||||
|
cooldown_until = _STOPLOSS_REENTRY_COOLDOWN_UNTIL.get(cooldown_key, 0.0)
|
||||||
|
if now_epoch < cooldown_until:
|
||||||
|
remaining = int(cooldown_until - now_epoch)
|
||||||
|
decision = TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=decision.confidence,
|
||||||
|
rationale=f"Stop-loss reentry cooldown active ({remaining}s remaining)",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"BUY suppressed for %s (%s): stop-loss cooldown active (%ds remaining)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
|
|
||||||
if decision.action == "HOLD":
|
if decision.action == "HOLD":
|
||||||
open_position = get_open_position(db_conn, stock_code, market.code)
|
open_position = get_open_position(db_conn, stock_code, market.code)
|
||||||
if open_position:
|
if not open_position:
|
||||||
entry_price = safe_float(open_position.get("price"), 0.0)
|
_clear_runtime_exit_cache_for_symbol(
|
||||||
if entry_price > 0 and current_price > 0:
|
market_code=market.code,
|
||||||
loss_pct = (current_price - entry_price) / entry_price * 100
|
stock_code=stock_code,
|
||||||
stop_loss_threshold = -2.0
|
)
|
||||||
take_profit_threshold = 3.0
|
decision = _apply_staged_exit_override_for_hold(
|
||||||
if stock_playbook and stock_playbook.scenarios:
|
decision=decision,
|
||||||
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
|
market=market,
|
||||||
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
|
stock_code=stock_code,
|
||||||
|
open_position=open_position,
|
||||||
exit_eval = evaluate_exit(
|
market_data=market_data,
|
||||||
current_state=PositionState.HOLDING,
|
stock_playbook=stock_playbook,
|
||||||
config=ExitRuleConfig(
|
)
|
||||||
hard_stop_pct=stop_loss_threshold,
|
if open_position and decision.action == "HOLD" and _should_force_exit_for_overnight(
|
||||||
be_arm_pct=max(0.5, take_profit_threshold * 0.4),
|
market=market,
|
||||||
arm_pct=take_profit_threshold,
|
settings=settings,
|
||||||
),
|
):
|
||||||
inp=ExitRuleInput(
|
decision = TradeDecision(
|
||||||
current_price=current_price,
|
action="SELL",
|
||||||
entry_price=entry_price,
|
confidence=max(decision.confidence, 85),
|
||||||
peak_price=max(entry_price, current_price),
|
rationale=(
|
||||||
atr_value=0.0,
|
"Forced exit by overnight policy"
|
||||||
pred_down_prob=0.0,
|
" (session close window / kill switch priority)"
|
||||||
liquidity_weak=market_data.get("volume_ratio", 1.0) < 1.0,
|
),
|
||||||
),
|
)
|
||||||
)
|
logger.info(
|
||||||
|
"Overnight policy override for %s (%s): HOLD -> SELL",
|
||||||
if exit_eval.reason == "hard_stop":
|
stock_code,
|
||||||
decision = TradeDecision(
|
market.name,
|
||||||
action="SELL",
|
)
|
||||||
confidence=95,
|
|
||||||
rationale=(
|
|
||||||
f"Stop-loss triggered ({loss_pct:.2f}% <= "
|
|
||||||
f"{stop_loss_threshold:.2f}%)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Stop-loss override for %s (%s): %.2f%% <= %.2f%%",
|
|
||||||
stock_code,
|
|
||||||
market.name,
|
|
||||||
loss_pct,
|
|
||||||
stop_loss_threshold,
|
|
||||||
)
|
|
||||||
elif exit_eval.reason == "arm_take_profit":
|
|
||||||
decision = TradeDecision(
|
|
||||||
action="SELL",
|
|
||||||
confidence=90,
|
|
||||||
rationale=(
|
|
||||||
f"Take-profit triggered ({loss_pct:.2f}% >= "
|
|
||||||
f"{take_profit_threshold:.2f}%)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Take-profit override for %s (%s): %.2f%% >= %.2f%%",
|
|
||||||
stock_code,
|
|
||||||
market.name,
|
|
||||||
loss_pct,
|
|
||||||
take_profit_threshold,
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Decision for %s (%s): %s (confidence=%d)",
|
"Decision for %s (%s): %s (confidence=%d)",
|
||||||
stock_code,
|
stock_code,
|
||||||
@@ -1057,10 +1437,12 @@ async def trading_cycle(
|
|||||||
"pnl_pct": pnl_pct,
|
"pnl_pct": pnl_pct,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime_session_id = get_session_info(market).session_id
|
||||||
decision_id = decision_logger.log_decision(
|
decision_id = decision_logger.log_decision(
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
market=market.code,
|
market=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
|
session_id=runtime_session_id,
|
||||||
action=decision.action,
|
action=decision.action,
|
||||||
confidence=decision.confidence,
|
confidence=decision.confidence,
|
||||||
rationale=decision.rationale,
|
rationale=decision.rationale,
|
||||||
@@ -1073,7 +1455,7 @@ async def trading_cycle(
|
|||||||
trade_price = current_price
|
trade_price = current_price
|
||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
if decision.action in ("BUY", "SELL"):
|
if decision.action in ("BUY", "SELL"):
|
||||||
if KILL_SWITCH.new_orders_blocked:
|
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
|
||||||
logger.critical(
|
logger.critical(
|
||||||
"KillSwitch block active: skip %s order for %s (%s)",
|
"KillSwitch block active: skip %s order for %s (%s)",
|
||||||
decision.action,
|
decision.action,
|
||||||
@@ -1111,6 +1493,24 @@ async def trading_cycle(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
order_amount = current_price * quantity
|
order_amount = current_price * quantity
|
||||||
|
fx_blocked, remaining_cash, required_buffer = _should_block_overseas_buy_for_fx_buffer(
|
||||||
|
market=market,
|
||||||
|
action=decision.action,
|
||||||
|
total_cash=total_cash,
|
||||||
|
order_amount=order_amount,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
if fx_blocked:
|
||||||
|
logger.warning(
|
||||||
|
"Skip BUY %s (%s): FX buffer guard (remaining=%.2f, required=%.2f, cash=%.2f, order=%.2f)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
remaining_cash,
|
||||||
|
required_buffer,
|
||||||
|
total_cash,
|
||||||
|
order_amount,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# 4. Check BUY cooldown (set when a prior BUY failed due to insufficient balance)
|
# 4. Check BUY cooldown (set when a prior BUY failed due to insufficient balance)
|
||||||
if decision.action == "BUY" and buy_cooldown is not None:
|
if decision.action == "BUY" and buy_cooldown is not None:
|
||||||
@@ -1151,15 +1551,16 @@ async def trading_cycle(
|
|||||||
logger.warning("Fat finger notification failed: %s", notify_exc)
|
logger.warning("Fat finger notification failed: %s", notify_exc)
|
||||||
raise # Re-raise to prevent trade
|
raise # Re-raise to prevent trade
|
||||||
except CircuitBreakerTripped as exc:
|
except CircuitBreakerTripped as exc:
|
||||||
ks_report = await KILL_SWITCH.trigger(
|
ks_report = await _trigger_emergency_kill_switch(
|
||||||
reason=f"circuit_breaker:{market.code}:{stock_code}:{exc.pnl_pct:.2f}",
|
reason=f"circuit_breaker:{market.code}:{stock_code}:{exc.pnl_pct:.2f}",
|
||||||
snapshot_state=lambda: logger.critical(
|
broker=broker,
|
||||||
"KillSwitch snapshot %s/%s pnl=%.2f threshold=%.2f",
|
overseas_broker=overseas_broker,
|
||||||
market.code,
|
telegram=telegram,
|
||||||
stock_code,
|
settings=settings,
|
||||||
exc.pnl_pct,
|
current_market=market,
|
||||||
exc.threshold,
|
stock_code=stock_code,
|
||||||
),
|
pnl_pct=exc.pnl_pct,
|
||||||
|
threshold=exc.threshold,
|
||||||
)
|
)
|
||||||
if ks_report.errors:
|
if ks_report.errors:
|
||||||
logger.critical(
|
logger.critical(
|
||||||
@@ -1306,6 +1707,7 @@ async def trading_cycle(
|
|||||||
pnl=0.0,
|
pnl=0.0,
|
||||||
market=market.code,
|
market=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
|
session_id=runtime_session_id,
|
||||||
mode=settings.MODE if settings else "paper",
|
mode=settings.MODE if settings else "paper",
|
||||||
)
|
)
|
||||||
logger.info("Order result: %s", result.get("msg1", "OK"))
|
logger.info("Order result: %s", result.get("msg1", "OK"))
|
||||||
@@ -1325,16 +1727,34 @@ async def trading_cycle(
|
|||||||
logger.warning("Telegram notification failed: %s", exc)
|
logger.warning("Telegram notification failed: %s", exc)
|
||||||
|
|
||||||
if decision.action == "SELL" and order_succeeded:
|
if decision.action == "SELL" and order_succeeded:
|
||||||
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
|
buy_trade = get_latest_buy_trade(
|
||||||
|
db_conn,
|
||||||
|
stock_code,
|
||||||
|
market.code,
|
||||||
|
exchange_code=market.exchange_code,
|
||||||
|
)
|
||||||
if buy_trade and buy_trade.get("price") is not None:
|
if buy_trade and buy_trade.get("price") is not None:
|
||||||
buy_price = float(buy_trade["price"])
|
buy_price = float(buy_trade["price"])
|
||||||
buy_qty = int(buy_trade.get("quantity") or 1)
|
buy_qty = int(buy_trade.get("quantity") or 0)
|
||||||
trade_pnl = (trade_price - buy_price) * buy_qty
|
sell_qty = _resolve_sell_qty_for_pnl(sell_qty=quantity, buy_qty=buy_qty)
|
||||||
|
trade_pnl = (trade_price - buy_price) * sell_qty
|
||||||
decision_logger.update_outcome(
|
decision_logger.update_outcome(
|
||||||
decision_id=buy_trade["decision_id"],
|
decision_id=buy_trade["decision_id"],
|
||||||
pnl=trade_pnl,
|
pnl=trade_pnl,
|
||||||
accuracy=1 if trade_pnl > 0 else 0,
|
accuracy=1 if trade_pnl > 0 else 0,
|
||||||
)
|
)
|
||||||
|
if trade_pnl < 0:
|
||||||
|
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
|
||||||
|
cooldown_minutes = _stoploss_cooldown_minutes(settings)
|
||||||
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL[cooldown_key] = (
|
||||||
|
datetime.now(UTC).timestamp() + cooldown_minutes * 60
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Stop-loss cooldown set for %s (%s): %d minutes",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
cooldown_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
# 6. Log trade with selection context (skip if order was rejected)
|
# 6. Log trade with selection context (skip if order was rejected)
|
||||||
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
||||||
@@ -1360,6 +1780,7 @@ async def trading_cycle(
|
|||||||
pnl=trade_pnl,
|
pnl=trade_pnl,
|
||||||
market=market.code,
|
market=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
|
session_id=runtime_session_id,
|
||||||
selection_context=selection_context,
|
selection_context=selection_context,
|
||||||
decision_id=decision_id,
|
decision_id=decision_id,
|
||||||
mode=settings.MODE if settings else "paper",
|
mode=settings.MODE if settings else "paper",
|
||||||
@@ -1933,6 +2354,14 @@ async def run_daily_session(
|
|||||||
"foreigner_net": foreigner_net,
|
"foreigner_net": foreigner_net,
|
||||||
"price_change_pct": price_change_pct,
|
"price_change_pct": price_change_pct,
|
||||||
}
|
}
|
||||||
|
if not market.is_domestic:
|
||||||
|
session_high_price = safe_float(
|
||||||
|
price_data.get("output", {}).get("high")
|
||||||
|
or price_data.get("output", {}).get("ovrs_hgpr")
|
||||||
|
or price_data.get("output", {}).get("stck_hgpr")
|
||||||
|
)
|
||||||
|
if session_high_price > 0:
|
||||||
|
stock_data["session_high_price"] = session_high_price
|
||||||
# Enrich with scanner metrics
|
# Enrich with scanner metrics
|
||||||
cand = candidate_map.get(stock_code)
|
cand = candidate_map.get(stock_code)
|
||||||
if cand:
|
if cand:
|
||||||
@@ -2060,6 +2489,7 @@ async def run_daily_session(
|
|||||||
)
|
)
|
||||||
for stock_data in stocks_data:
|
for stock_data in stocks_data:
|
||||||
stock_code = stock_data["stock_code"]
|
stock_code = stock_data["stock_code"]
|
||||||
|
stock_playbook = playbook.get_stock_playbook(stock_code)
|
||||||
match = scenario_engine.evaluate(
|
match = scenario_engine.evaluate(
|
||||||
playbook, stock_code, stock_data, portfolio_data,
|
playbook, stock_code, stock_data, portfolio_data,
|
||||||
)
|
)
|
||||||
@@ -2103,6 +2533,73 @@ async def run_daily_session(
|
|||||||
stock_code,
|
stock_code,
|
||||||
market.name,
|
market.name,
|
||||||
)
|
)
|
||||||
|
elif market.code.startswith("US"):
|
||||||
|
min_price = float(getattr(settings, "US_MIN_PRICE", 5.0))
|
||||||
|
if stock_data["current_price"] <= min_price:
|
||||||
|
decision = TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=decision.confidence,
|
||||||
|
rationale=(
|
||||||
|
f"US minimum price filter blocked BUY "
|
||||||
|
f"(price={stock_data['current_price']:.4f} <= {min_price:.4f})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"BUY suppressed for %s (%s): US min price filter %.4f <= %.4f",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
stock_data["current_price"],
|
||||||
|
min_price,
|
||||||
|
)
|
||||||
|
if decision.action == "BUY":
|
||||||
|
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
|
||||||
|
now_epoch = datetime.now(UTC).timestamp()
|
||||||
|
cooldown_until = _STOPLOSS_REENTRY_COOLDOWN_UNTIL.get(cooldown_key, 0.0)
|
||||||
|
if now_epoch < cooldown_until:
|
||||||
|
remaining = int(cooldown_until - now_epoch)
|
||||||
|
decision = TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=decision.confidence,
|
||||||
|
rationale=f"Stop-loss reentry cooldown active ({remaining}s remaining)",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"BUY suppressed for %s (%s): stop-loss cooldown active (%ds remaining)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
|
if decision.action == "HOLD":
|
||||||
|
daily_open = get_open_position(db_conn, stock_code, market.code)
|
||||||
|
if not daily_open:
|
||||||
|
_clear_runtime_exit_cache_for_symbol(
|
||||||
|
market_code=market.code,
|
||||||
|
stock_code=stock_code,
|
||||||
|
)
|
||||||
|
decision = _apply_staged_exit_override_for_hold(
|
||||||
|
decision=decision,
|
||||||
|
market=market,
|
||||||
|
stock_code=stock_code,
|
||||||
|
open_position=daily_open,
|
||||||
|
market_data=stock_data,
|
||||||
|
stock_playbook=stock_playbook,
|
||||||
|
)
|
||||||
|
if daily_open and decision.action == "HOLD" and _should_force_exit_for_overnight(
|
||||||
|
market=market,
|
||||||
|
settings=settings,
|
||||||
|
):
|
||||||
|
decision = TradeDecision(
|
||||||
|
action="SELL",
|
||||||
|
confidence=max(decision.confidence, 85),
|
||||||
|
rationale=(
|
||||||
|
"Forced exit by overnight policy"
|
||||||
|
" (session close window / kill switch priority)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Daily overnight policy override for %s (%s): HOLD -> SELL",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
)
|
||||||
|
|
||||||
# Log decision
|
# Log decision
|
||||||
context_snapshot = {
|
context_snapshot = {
|
||||||
@@ -2126,10 +2623,12 @@ async def run_daily_session(
|
|||||||
"pnl_pct": pnl_pct,
|
"pnl_pct": pnl_pct,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime_session_id = get_session_info(market).session_id
|
||||||
decision_id = decision_logger.log_decision(
|
decision_id = decision_logger.log_decision(
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
market=market.code,
|
market=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
|
session_id=runtime_session_id,
|
||||||
action=decision.action,
|
action=decision.action,
|
||||||
confidence=decision.confidence,
|
confidence=decision.confidence,
|
||||||
rationale=decision.rationale,
|
rationale=decision.rationale,
|
||||||
@@ -2143,7 +2642,7 @@ async def run_daily_session(
|
|||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
order_succeeded = True
|
order_succeeded = True
|
||||||
if decision.action in ("BUY", "SELL"):
|
if decision.action in ("BUY", "SELL"):
|
||||||
if KILL_SWITCH.new_orders_blocked:
|
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
|
||||||
logger.critical(
|
logger.critical(
|
||||||
"KillSwitch block active: skip %s order for %s (%s)",
|
"KillSwitch block active: skip %s order for %s (%s)",
|
||||||
decision.action,
|
decision.action,
|
||||||
@@ -2178,6 +2677,24 @@ async def run_daily_session(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
order_amount = stock_data["current_price"] * quantity
|
order_amount = stock_data["current_price"] * quantity
|
||||||
|
fx_blocked, remaining_cash, required_buffer = _should_block_overseas_buy_for_fx_buffer(
|
||||||
|
market=market,
|
||||||
|
action=decision.action,
|
||||||
|
total_cash=total_cash,
|
||||||
|
order_amount=order_amount,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
if fx_blocked:
|
||||||
|
logger.warning(
|
||||||
|
"Skip BUY %s (%s): FX buffer guard (remaining=%.2f, required=%.2f, cash=%.2f, order=%.2f)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
remaining_cash,
|
||||||
|
required_buffer,
|
||||||
|
total_cash,
|
||||||
|
order_amount,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Check BUY cooldown (insufficient balance)
|
# Check BUY cooldown (insufficient balance)
|
||||||
if decision.action == "BUY":
|
if decision.action == "BUY":
|
||||||
@@ -2218,26 +2735,18 @@ async def run_daily_session(
|
|||||||
logger.warning("Fat finger notification failed: %s", notify_exc)
|
logger.warning("Fat finger notification failed: %s", notify_exc)
|
||||||
continue # Skip this order
|
continue # Skip this order
|
||||||
except CircuitBreakerTripped as exc:
|
except CircuitBreakerTripped as exc:
|
||||||
ks_report = await KILL_SWITCH.trigger(
|
ks_report = await _trigger_emergency_kill_switch(
|
||||||
reason=f"daily_circuit_breaker:{market.code}:{stock_code}:{exc.pnl_pct:.2f}",
|
reason=f"daily_circuit_breaker:{market.code}:{stock_code}:{exc.pnl_pct:.2f}",
|
||||||
snapshot_state=lambda: logger.critical(
|
broker=broker,
|
||||||
"Daily KillSwitch snapshot %s/%s pnl=%.2f threshold=%.2f",
|
overseas_broker=overseas_broker,
|
||||||
market.code,
|
telegram=telegram,
|
||||||
stock_code,
|
settings=settings,
|
||||||
exc.pnl_pct,
|
current_market=market,
|
||||||
exc.threshold,
|
stock_code=stock_code,
|
||||||
),
|
pnl_pct=exc.pnl_pct,
|
||||||
|
threshold=exc.threshold,
|
||||||
)
|
)
|
||||||
logger.critical("Circuit breaker tripped — stopping session")
|
logger.critical("Circuit breaker tripped — stopping session")
|
||||||
try:
|
|
||||||
await telegram.notify_circuit_breaker(
|
|
||||||
pnl_pct=exc.pnl_pct,
|
|
||||||
threshold=exc.threshold,
|
|
||||||
)
|
|
||||||
except Exception as notify_exc:
|
|
||||||
logger.warning(
|
|
||||||
"Circuit breaker notification failed: %s", notify_exc
|
|
||||||
)
|
|
||||||
if ks_report.errors:
|
if ks_report.errors:
|
||||||
logger.critical(
|
logger.critical(
|
||||||
"Daily KillSwitch step errors for %s/%s: %s",
|
"Daily KillSwitch step errors for %s/%s: %s",
|
||||||
@@ -2371,16 +2880,37 @@ async def run_daily_session(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if decision.action == "SELL" and order_succeeded:
|
if decision.action == "SELL" and order_succeeded:
|
||||||
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
|
buy_trade = get_latest_buy_trade(
|
||||||
|
db_conn,
|
||||||
|
stock_code,
|
||||||
|
market.code,
|
||||||
|
exchange_code=market.exchange_code,
|
||||||
|
)
|
||||||
if buy_trade and buy_trade.get("price") is not None:
|
if buy_trade and buy_trade.get("price") is not None:
|
||||||
buy_price = float(buy_trade["price"])
|
buy_price = float(buy_trade["price"])
|
||||||
buy_qty = int(buy_trade.get("quantity") or 1)
|
buy_qty = int(buy_trade.get("quantity") or 0)
|
||||||
trade_pnl = (trade_price - buy_price) * buy_qty
|
sell_qty = _resolve_sell_qty_for_pnl(
|
||||||
|
sell_qty=quantity,
|
||||||
|
buy_qty=buy_qty,
|
||||||
|
)
|
||||||
|
trade_pnl = (trade_price - buy_price) * sell_qty
|
||||||
decision_logger.update_outcome(
|
decision_logger.update_outcome(
|
||||||
decision_id=buy_trade["decision_id"],
|
decision_id=buy_trade["decision_id"],
|
||||||
pnl=trade_pnl,
|
pnl=trade_pnl,
|
||||||
accuracy=1 if trade_pnl > 0 else 0,
|
accuracy=1 if trade_pnl > 0 else 0,
|
||||||
)
|
)
|
||||||
|
if trade_pnl < 0:
|
||||||
|
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
|
||||||
|
cooldown_minutes = _stoploss_cooldown_minutes(settings)
|
||||||
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL[cooldown_key] = (
|
||||||
|
datetime.now(UTC).timestamp() + cooldown_minutes * 60
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Stop-loss cooldown set for %s (%s): %d minutes",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
cooldown_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
# Log trade (skip if order was rejected by API)
|
# Log trade (skip if order was rejected by API)
|
||||||
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
||||||
@@ -2396,6 +2926,7 @@ async def run_daily_session(
|
|||||||
pnl=trade_pnl,
|
pnl=trade_pnl,
|
||||||
market=market.code,
|
market=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
|
session_id=runtime_session_id,
|
||||||
decision_id=decision_id,
|
decision_id=decision_id,
|
||||||
mode=settings.MODE,
|
mode=settings.MODE,
|
||||||
)
|
)
|
||||||
@@ -3122,7 +3653,10 @@ async def run(settings: Settings) -> None:
|
|||||||
_run_context_scheduler(context_scheduler, now=datetime.now(UTC))
|
_run_context_scheduler(context_scheduler, now=datetime.now(UTC))
|
||||||
|
|
||||||
# Get currently open markets
|
# Get currently open markets
|
||||||
open_markets = get_open_markets(settings.enabled_market_list)
|
open_markets = get_open_markets(
|
||||||
|
settings.enabled_market_list,
|
||||||
|
include_extended_sessions=True,
|
||||||
|
)
|
||||||
|
|
||||||
if not open_markets:
|
if not open_markets:
|
||||||
# Notify market close for any markets that were open
|
# Notify market close for any markets that were open
|
||||||
@@ -3151,7 +3685,8 @@ async def run(settings: Settings) -> None:
|
|||||||
# No markets open — wait until next market opens
|
# No markets open — wait until next market opens
|
||||||
try:
|
try:
|
||||||
next_market, next_open_time = get_next_market_open(
|
next_market, next_open_time = get_next_market_open(
|
||||||
settings.enabled_market_list
|
settings.enabled_market_list,
|
||||||
|
include_extended_sessions=True,
|
||||||
)
|
)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
wait_seconds = (next_open_time - now).total_seconds()
|
wait_seconds = (next_open_time - now).total_seconds()
|
||||||
@@ -3173,6 +3708,14 @@ async def run(settings: Settings) -> None:
|
|||||||
if shutdown.is_set():
|
if shutdown.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
|
session_info = get_session_info(market)
|
||||||
|
logger.info(
|
||||||
|
"Market session active: %s (%s) session=%s",
|
||||||
|
market.code,
|
||||||
|
market.name,
|
||||||
|
session_info.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
await process_blackout_recovery_orders(
|
await process_blackout_recovery_orders(
|
||||||
broker=broker,
|
broker=broker,
|
||||||
overseas_broker=overseas_broker,
|
overseas_broker=overseas_broker,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
83
tests/test_backtest_cost_guard.py
Normal file
83
tests/test_backtest_cost_guard.py
Normal 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"])
|
||||||
108
tests/test_backtest_execution_model.py
Normal file
108
tests/test_backtest_execution_model.py
Normal 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},
|
||||||
|
)
|
||||||
|
)
|
||||||
136
tests/test_backtest_pipeline_integration.py
Normal file
136
tests/test_backtest_pipeline_integration.py
Normal 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
|
||||||
224
tests/test_db.py
224
tests/test_db.py
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
184
tests/test_triple_barrier.py
Normal file
184
tests/test_triple_barrier.py
Normal 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,
|
||||||
|
)
|
||||||
81
tests/test_validate_governance_assets.py
Normal file
81
tests/test_validate_governance_assets.py
Normal 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",
|
||||||
|
]
|
||||||
92
tests/test_walk_forward_split.py
Normal file
92
tests/test_walk_forward_split.py
Normal 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,
|
||||||
|
)
|
||||||
91
workflow/session-handover.md
Normal file
91
workflow/session-handover.md
Normal 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) 중심으로 전환
|
||||||
Reference in New Issue
Block a user