Compare commits
7 Commits
fix/400
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fd9c552f3 | ||
|
|
c80f3daad7 | ||
|
|
100586e237 | ||
|
|
86733ef830 | ||
|
|
296b89d95f | ||
|
|
fa89499ccb | ||
|
|
b227554e9e |
281
docs/plans/2026-03-03-398-400-401-implementation.md
Normal file
281
docs/plans/2026-03-03-398-400-401-implementation.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# 398/400/401 Integration Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Implement #398, #400, #401 as three isolated PRs targeting `feature/398-400-401`, merge only when CI passes and self-review has zero minor issues, then run and monitor overnight script without stopping the process.
|
||||||
|
|
||||||
|
**Architecture:** Create one integration base branch from `origin/main`, branch per issue, and ship in strict sequence (`398 -> 400 -> 401`) to keep diffs isolated. Use TDD per issue (fail-first tests, minimal fix, regression checks), then perform PR self-review and CI gate before merge. After all merges, run overnight in background and monitor logs/process health while leaving runtime active.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3, pytest, asyncio runtime loop, Git/Gitea (`tea`), shell scripts (`scripts/run_overnight.sh`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Prepare Integration Branch Topology
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.git` refs only (branch operations)
|
||||||
|
|
||||||
|
**Step 1: Sync base branch**
|
||||||
|
|
||||||
|
Run: `git fetch origin && git checkout main && git pull --ff-only origin main`
|
||||||
|
Expected: local `main` equals `origin/main`
|
||||||
|
|
||||||
|
**Step 2: Create integration branch**
|
||||||
|
|
||||||
|
Run: `git checkout -b feature/398-400-401`
|
||||||
|
Expected: current branch is `feature/398-400-401`
|
||||||
|
|
||||||
|
**Step 3: Create issue branches from integration branch**
|
||||||
|
|
||||||
|
Run: `git checkout -b fix/398 && git checkout feature/398-400-401 && git checkout -b fix/400 && git checkout feature/398-400-401 && git checkout -b fix/401 && git checkout feature/398-400-401`
|
||||||
|
Expected: three issue branches exist and point to same base commit
|
||||||
|
|
||||||
|
**Step 4: Push all branches**
|
||||||
|
|
||||||
|
Run: `git push -u origin feature/398-400-401 fix/398 fix/400 fix/401`
|
||||||
|
Expected: remote tracking set for all four branches
|
||||||
|
|
||||||
|
**Step 5: Commit checkpoint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
Expected: clean workspace before issue implementation
|
||||||
|
|
||||||
|
### Task 2: Implement #398 with TDD (KR rt_cd failure handling)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main.py`
|
||||||
|
- Test: `tests/test_main.py`
|
||||||
|
|
||||||
|
**Step 1: Write failing test**
|
||||||
|
|
||||||
|
Add test in `tests/test_main.py` verifying KR order returns `rt_cd != '0'` does not trigger success side effects (no BUY notify, no trade log success path).
|
||||||
|
|
||||||
|
**Step 2: Run test to verify failure**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_main.py -k "kr and rt_cd" -v`
|
||||||
|
Expected: FAIL showing current code incorrectly treats KR order as success
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
In KR order branch of `src/main.py`, immediately after `send_order`, add `rt_cd` acceptance check identical to overseas branch behavior; set `order_succeeded = False` and warning log when rejected.
|
||||||
|
|
||||||
|
**Step 4: Run targeted tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_main.py -k "kr and rt_cd" -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Run safety regression**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_main.py tests/test_order_policy.py -q`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git add tests/test_main.py src/main.py
|
||||||
|
git commit -m "fix: handle KR order rejection via rt_cd check (#398)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Open PR for #398, Self-review, CI gate, Merge
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: remote PR metadata/comments only
|
||||||
|
|
||||||
|
**Step 1: Push branch**
|
||||||
|
|
||||||
|
Run: `git checkout fix/398 && git push -u origin fix/398`
|
||||||
|
|
||||||
|
**Step 2: Create PR targeting integration branch**
|
||||||
|
|
||||||
|
Run: `tea pr create --base feature/398-400-401 --head fix/398 --title "fix: #398 KR rt_cd rejection handling" --description "Implements issue #398 with tests."`
|
||||||
|
Expected: PR URL returned
|
||||||
|
|
||||||
|
**Step 3: Add self-review comment (severity rubric)**
|
||||||
|
|
||||||
|
Run: `tea pr comment <PR_398> --message "Self-review: Critical 0 / Major 0 / Minor 0. Merge allowed when CI passes."`
|
||||||
|
|
||||||
|
**Step 4: Wait for CI success**
|
||||||
|
|
||||||
|
Run: `tea pr checks <PR_398>` (poll until all success)
|
||||||
|
Expected: all checks success
|
||||||
|
|
||||||
|
**Step 5: Merge only when gate passes**
|
||||||
|
|
||||||
|
Run: `tea pr merge <PR_398> --delete-branch=false`
|
||||||
|
Expected: merged into `feature/398-400-401`
|
||||||
|
|
||||||
|
### Task 4: Implement #400 with TDD (US session transition correctness)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main.py`, `src/core/order_policy.py`, `src/markets/schedule.py`
|
||||||
|
- Test: `tests/test_main.py`, `tests/test_market_schedule.py`, `tests/test_order_policy.py`
|
||||||
|
|
||||||
|
**Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Add tests for:
|
||||||
|
- session transition event handling (`US_DAY -> US_REG`) emits open event and forces rescan
|
||||||
|
- `US_DAY` treated non-tradable for playbook/trading actions
|
||||||
|
|
||||||
|
**Step 2: Run failing tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_main.py tests/test_market_schedule.py tests/test_order_policy.py -k "US_DAY or US_REG or session" -v`
|
||||||
|
Expected: FAIL at current behavior
|
||||||
|
|
||||||
|
**Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
- Track market state by session identifier (not bool only)
|
||||||
|
- Force rescan/playbook refresh on US_REG entry
|
||||||
|
- Exclude/suppress US_DAY for trading/playbook generation path
|
||||||
|
|
||||||
|
**Step 4: Re-run targeted tests**
|
||||||
|
|
||||||
|
Run: same command as Step 2
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Regression pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_main.py tests/test_market_schedule.py tests/test_order_policy.py tests/test_pre_market_planner.py -q`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git add src/main.py src/core/order_policy.py src/markets/schedule.py tests/test_main.py tests/test_market_schedule.py tests/test_order_policy.py
|
||||||
|
git commit -m "fix: handle US session transitions and suppress US_DAY trading (#400)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Open PR for #400, Self-review, CI gate, Merge
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: remote PR metadata/comments only
|
||||||
|
|
||||||
|
**Step 1: Push branch**
|
||||||
|
|
||||||
|
Run: `git checkout fix/400 && git push -u origin fix/400`
|
||||||
|
|
||||||
|
**Step 2: Create PR**
|
||||||
|
|
||||||
|
Run: `tea pr create --base feature/398-400-401 --head fix/400 --title "fix: #400 US session transition handling" --description "Implements issue #400 with tests."`
|
||||||
|
|
||||||
|
**Step 3: Add self-review comment**
|
||||||
|
|
||||||
|
Run: `tea pr comment <PR_400> --message "Self-review: Critical 0 / Major 0 / Minor 0. Merge allowed when CI passes."`
|
||||||
|
|
||||||
|
**Step 4: Wait for CI success**
|
||||||
|
|
||||||
|
Run: `tea pr checks <PR_400>`
|
||||||
|
Expected: all checks success
|
||||||
|
|
||||||
|
**Step 5: Merge**
|
||||||
|
|
||||||
|
Run: `tea pr merge <PR_400> --delete-branch=false`
|
||||||
|
|
||||||
|
### Task 6: Implement #401 with TDD (multi-market parallel processing)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main.py`
|
||||||
|
- Test: `tests/test_main.py`
|
||||||
|
|
||||||
|
**Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Add tests verifying:
|
||||||
|
- open markets are processed via parallel task dispatch
|
||||||
|
- circuit breaker behavior still triggers global shutdown semantics
|
||||||
|
- shared state updates remain deterministic under parallel market execution
|
||||||
|
|
||||||
|
**Step 2: Run failing tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_main.py -k "parallel or market" -v`
|
||||||
|
Expected: FAIL before implementation
|
||||||
|
|
||||||
|
**Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
Refactor sequential market loop into market-level async tasks (`asyncio.gather`/task group) while preserving stock-level processing order per market and existing failure semantics.
|
||||||
|
|
||||||
|
**Step 4: Re-run targeted tests**
|
||||||
|
|
||||||
|
Run: same command as Step 2
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Regression pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_main.py tests/test_runtime_overnight_scripts.py -q`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git add src/main.py tests/test_main.py
|
||||||
|
git commit -m "feat: process active markets in parallel with preserved shutdown semantics (#401)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: Open PR for #401, Self-review, CI gate, Merge
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: remote PR metadata/comments only
|
||||||
|
|
||||||
|
**Step 1: Push branch**
|
||||||
|
|
||||||
|
Run: `git checkout fix/401 && git push -u origin fix/401`
|
||||||
|
|
||||||
|
**Step 2: Create PR**
|
||||||
|
|
||||||
|
Run: `tea pr create --base feature/398-400-401 --head fix/401 --title "feat: #401 parallel multi-market processing" --description "Implements issue #401 with tests."`
|
||||||
|
|
||||||
|
**Step 3: Add self-review comment**
|
||||||
|
|
||||||
|
Run: `tea pr comment <PR_401> --message "Self-review: Critical 0 / Major 0 / Minor 0. Merge allowed when CI passes."`
|
||||||
|
|
||||||
|
**Step 4: Wait for CI success**
|
||||||
|
|
||||||
|
Run: `tea pr checks <PR_401>`
|
||||||
|
Expected: all checks success
|
||||||
|
|
||||||
|
**Step 5: Merge**
|
||||||
|
|
||||||
|
Run: `tea pr merge <PR_401> --delete-branch=false`
|
||||||
|
|
||||||
|
### Task 8: Final Branch Validation + Overnight Runtime Monitoring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Execute: `scripts/run_overnight.sh`
|
||||||
|
- Observe: runtime log file (e.g., `logs/overnight.log`)
|
||||||
|
|
||||||
|
**Step 1: Checkout integrated branch and sync**
|
||||||
|
|
||||||
|
Run: `git checkout feature/398-400-401 && git pull --ff-only origin feature/398-400-401`
|
||||||
|
Expected: branch contains merged PRs
|
||||||
|
|
||||||
|
**Step 2: Start overnight in background (non-blocking)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
nohup ./scripts/run_overnight.sh > /tmp/ouroboros_overnight.log 2>&1 &
|
||||||
|
echo $! > /tmp/ouroboros_overnight.pid
|
||||||
|
```
|
||||||
|
Expected: PID written and process running
|
||||||
|
|
||||||
|
**Step 3: Verify process alive**
|
||||||
|
|
||||||
|
Run: `ps -p $(cat /tmp/ouroboros_overnight.pid) -o pid,ppid,stat,etime,cmd`
|
||||||
|
Expected: process present
|
||||||
|
|
||||||
|
**Step 4: Monitor startup logs**
|
||||||
|
|
||||||
|
Run: `tail -n 120 /tmp/ouroboros_overnight.log`
|
||||||
|
Expected: startup complete and runtime loop active without fatal errors
|
||||||
|
|
||||||
|
**Step 5: Ongoing monitor without shutdown**
|
||||||
|
|
||||||
|
Run: `tail -f /tmp/ouroboros_overnight.log` (sample monitoring window, then detach)
|
||||||
|
Expected: continued activity; do not kill process
|
||||||
|
|
||||||
|
**Step 6: Final status note**
|
||||||
|
|
||||||
|
Record PID, log path, and “process left running” status.
|
||||||
62
docs/plans/2026-03-03-398-400-401-integration-design.md
Normal file
62
docs/plans/2026-03-03-398-400-401-integration-design.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# 398/400/401 통합 처리 설계
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
이 문서는 이슈 #398, #400, #401을 `origin/main` 기반 통합 브랜치에서 순차적으로 처리하고,
|
||||||
|
각 PR을 셀프 리뷰 및 CI 게이트로 검증한 뒤 머지하는 운영 설계를 정의한다.
|
||||||
|
최종 머지된 통합 브랜치에서 overnight 스크립트를 실행하고, 모니터링 이후에도 프로그램은 계속 실행 상태를 유지한다.
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
- 통합 브랜치: `feature/398-400-401`
|
||||||
|
- 작업 브랜치: `fix/398`, `fix/400`, `fix/401`
|
||||||
|
- PR base: 모두 `feature/398-400-401`
|
||||||
|
- 머지 조건: `CI 전체 통과` + `셀프 리뷰에서 minor 포함 이슈 0건`
|
||||||
|
- 최종 확인: 통합 브랜치에서 overnight 실행 및 모니터링, 프로세스 지속 실행
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
- `origin/main`에서 `feature/398-400-401` 생성
|
||||||
|
- 각 이슈는 독립 브랜치(`fix/398`, `fix/400`, `fix/401`)에서 구현
|
||||||
|
- PR은 순차적으로 생성/검증/머지 (`398 -> 400 -> 401`)
|
||||||
|
- 각 PR은 셀프 리뷰 코멘트를 남기고, minor 이상 발견 시 수정 후 재검증
|
||||||
|
- 3개 PR 머지 완료 후 통합 브랜치에서 overnight 백그라운드 실행 및 로그 모니터링
|
||||||
|
- 모니터링 완료 후에도 프로세스는 종료하지 않음
|
||||||
|
|
||||||
|
## 컴포넌트
|
||||||
|
- Git/브랜치 컴포넌트: 브랜치 생성, 리베이스, 충돌 해결
|
||||||
|
- 이슈 구현 컴포넌트:
|
||||||
|
- #398: KR 주문 `rt_cd` 실패 처리, 오알림/오기록 차단
|
||||||
|
- #400: US 세션 전환 감지, US_DAY 억제, US_REG 진입 이벤트/강제 재스캔
|
||||||
|
- #401: 시장 단위 병렬 처리 및 공유 상태 동시성 보호
|
||||||
|
- PR 운영 컴포넌트: PR 생성, 셀프 리뷰 코멘트 작성, 승인 기준 확인
|
||||||
|
- CI 게이트 컴포넌트: 체크 상태 폴링 및 pass 확인
|
||||||
|
- 머지 컴포넌트: 게이트 통과 PR만 머지
|
||||||
|
- 런타임 검증 컴포넌트: overnight 실행, 로그 추적, 프로세스 생존 확인
|
||||||
|
|
||||||
|
## 데이터/제어 흐름
|
||||||
|
1. `feature/398-400-401` 생성
|
||||||
|
2. `fix/398` 구현 -> 테스트 -> 커밋 -> PR 생성
|
||||||
|
3. 셀프 리뷰 코멘트 작성(결함 레벨 포함)
|
||||||
|
4. CI 완료 대기 후 `CI pass && minor 0`이면 머지
|
||||||
|
5. `fix/400`, `fix/401`에 대해 동일 절차 반복
|
||||||
|
6. 통합 브랜치에서 overnight 백그라운드 실행
|
||||||
|
7. 로그/상태 모니터링으로 실제 동작 확인
|
||||||
|
8. 결과 보고 후에도 프로세스는 계속 실행
|
||||||
|
|
||||||
|
## 에러 처리/복구
|
||||||
|
- PR 생성/충돌 실패: 해당 브랜치만 중단 후 해결, 다른 브랜치와 격리 유지
|
||||||
|
- 셀프 리뷰 실패(minor 포함): 머지 금지, 수정 커밋 후 리뷰 갱신
|
||||||
|
- CI 실패: 실패 원인 수정 후 재푸시, 재검증
|
||||||
|
- 머지 실패: base 최신화 및 재시도
|
||||||
|
- overnight 시작 실패: 로그 분석 후 재기동
|
||||||
|
- 모니터링 중 오류: 오류 보고는 하되 자동 종료하지 않고 실행 유지
|
||||||
|
|
||||||
|
## 테스트/검증
|
||||||
|
- PR별 관련 단위/통합 테스트 실행
|
||||||
|
- 필요 시 `tests/test_main.py`, `tests/test_runtime_overnight_scripts.py` 포함 회귀 실행
|
||||||
|
- 셀프 리뷰는 `Critical/Major/Minor` 기준으로 작성
|
||||||
|
- minor 0건 명시된 경우에만 머지 진행
|
||||||
|
- 최종 통합 브랜치에서 overnight 기동/루프 진입/에러 로그 확인
|
||||||
|
- PID/프로세스 생존 확인 후 실행 지속 상태 보고
|
||||||
|
|
||||||
|
## 비목표
|
||||||
|
- 본 문서는 구현 상세 코드 변경 자체를 다루지 않는다.
|
||||||
|
- 본 문서는 외부 리뷰어 승인 프로세스를 다루지 않는다(셀프 리뷰만 대상).
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Issue #409 Design - KR Session-Aware Exchange Routing
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Issue: #409 (bug: KR 세션별 거래소 미분리 - 스크리닝/주문/이중상장 우선순위 미처리)
|
||||||
|
- Related runtime observation targets: #318, #325
|
||||||
|
- Date: 2026-03-04
|
||||||
|
- Confirmed approach: Option 2 (routing module introduction)
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
1. Ensure domestic screening uses session-specific exchange market code.
|
||||||
|
2. Ensure domestic order submission explicitly sets exchange routing code.
|
||||||
|
3. Add dual-listing routing priority logic (spread/liquidity aware) with safe fallback.
|
||||||
|
4. Keep existing behavior stable for non-KR flows and existing risk/order policy guards.
|
||||||
|
5. Enable runtime observability for #409 while monitoring #318/#325 in parallel.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
- Replacing current session classification model.
|
||||||
|
- Introducing new market sessions or changing session boundaries.
|
||||||
|
- Refactoring overseas order flow.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
### New Component
|
||||||
|
- Add `KRExchangeRouter` (new module, e.g. `src/broker/kr_exchange_router.py`).
|
||||||
|
- Responsibility split:
|
||||||
|
- `classify_session_id`: session classification only.
|
||||||
|
- `KRExchangeRouter`: final domestic exchange selection (`KRX`/`NXT`) for ranking and order.
|
||||||
|
- `KISBroker`: inject resolved routing values into request params/body.
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `KISBroker.fetch_market_rankings`
|
||||||
|
- Session-aware market division code:
|
||||||
|
- `KRX_REG` -> `J`
|
||||||
|
- `NXT_PRE`, `NXT_AFTER` -> `NX`
|
||||||
|
- `KISBroker.send_order`
|
||||||
|
- Explicit `EXCG_ID_DVSN_CD` is always set.
|
||||||
|
- `SmartVolatilityScanner._scan_domestic`
|
||||||
|
- Ensure domestic ranking API path resolves exchange consistently with current session.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
1. Scanner path:
|
||||||
|
- Determine `session_id`.
|
||||||
|
- `resolve_for_ranking(session_id)`.
|
||||||
|
- Inject `J` or `NX` into ranking API params.
|
||||||
|
2. Order path:
|
||||||
|
- Pass `session_id` into order path.
|
||||||
|
- `resolve_for_order(stock_code, session_id)`.
|
||||||
|
- Single listing: session default exchange.
|
||||||
|
- Dual listing: select by spread/liquidity heuristic when data is available.
|
||||||
|
- Data unavailable/error: fallback to session default.
|
||||||
|
- Send order with explicit `EXCG_ID_DVSN_CD`.
|
||||||
|
3. Observability:
|
||||||
|
- Log `session_id`, `resolved_exchange`, `routing_reason`.
|
||||||
|
|
||||||
|
## Dual-Listing Routing Priority
|
||||||
|
- Preferred decision source: spread/liquidity comparison.
|
||||||
|
- Deterministic fallback: session-default exchange.
|
||||||
|
- Proposed reasons in logs:
|
||||||
|
- `session_default`
|
||||||
|
- `dual_listing_spread`
|
||||||
|
- `dual_listing_liquidity`
|
||||||
|
- `fallback_data_unavailable`
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- Router does not block order path when auxiliary data is unavailable.
|
||||||
|
- Fail-open strategy for routing selection (fallback to session default) while preserving existing API/network error semantics.
|
||||||
|
- `send_order` exchange field omission is forbidden by design after this change.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
### Unit
|
||||||
|
- Router mapping by session (`KRX_REG`, `NXT_PRE`, `NXT_AFTER`).
|
||||||
|
- Dual-listing routing priority and fallback.
|
||||||
|
- Broker order body includes `EXCG_ID_DVSN_CD`.
|
||||||
|
- Ranking params use session-aware market code.
|
||||||
|
|
||||||
|
### Integration/Regression
|
||||||
|
- `smart_scanner` domestic calls align with session exchange.
|
||||||
|
- Existing order policy tests remain green.
|
||||||
|
- Re-run regression sets covering #318/#325 related paths.
|
||||||
|
|
||||||
|
### Runtime Observation (24h)
|
||||||
|
- Restart program from working branch build.
|
||||||
|
- Run runtime monitor for up to 24h.
|
||||||
|
- Verify and track:
|
||||||
|
- #409: session-aware routing evidence in logs.
|
||||||
|
- #318: ATR dynamic stop evidence.
|
||||||
|
- #325: ATR/pred_down_prob injection evidence.
|
||||||
|
- If anomalies are detected during monitoring, create separate issue tickets with evidence and links.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. No domestic ranking call uses hardcoded KRX-only behavior across NXT sessions.
|
||||||
|
2. No domestic order is sent without `EXCG_ID_DVSN_CD`.
|
||||||
|
3. Dual-listing path has explicit priority logic and deterministic fallback.
|
||||||
|
4. Tests pass for new and affected paths.
|
||||||
|
5. Runtime monitor evidence is collected for #409, #318, #325; anomalies are ticketed.
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
- Risk: Increased routing complexity introduces regressions.
|
||||||
|
- Mitigation: isolate router, high-coverage unit tests, preserve existing interfaces where possible.
|
||||||
|
- Risk: Runtime events for #318/#325 may not naturally occur in 24h.
|
||||||
|
- Mitigation: mark as `NOT_OBSERVED` and keep issue state based on evidence policy; do not force-close without proof.
|
||||||
|
|
||||||
|
## Planned Next Step
|
||||||
|
- Invoke `writing-plans` workflow and produce implementation plan before code changes.
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
# Issue #409 KR Session Exchange Routing Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Fix #409 by making KR screening/order routing session-aware and adding dual-listing exchange priority with deterministic fallback, then run 24h runtime observation for #409/#318/#325.
|
||||||
|
|
||||||
|
**Architecture:** Introduce a dedicated `KRExchangeRouter` module that resolves exchange by session and dual-listing metadata. Keep session classification in `order_policy`, and inject router outputs into `KISBroker` ranking/order requests. Add explicit routing logs for runtime evidence and keep non-KR behavior unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, aiohttp client layer, pytest/pytest-asyncio, Gitea CLI (`tea`), bash runtime monitor scripts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Preflight and Branch Runtime Gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `workflow/session-handover.md`
|
||||||
|
|
||||||
|
**Step 1: Add handover entry for this ticket branch**
|
||||||
|
|
||||||
|
```md
|
||||||
|
### 2026-03-04 | session=codex-issue409-start
|
||||||
|
- branch: feature/issue-409-kr-session-exchange-routing
|
||||||
|
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
|
||||||
|
- open_issues_reviewed: #409, #318, #325
|
||||||
|
- next_ticket: #409
|
||||||
|
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
||||||
|
- risks_or_notes: #409 code fix + 24h monitor, runtime anomaly creates separate issue ticket
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run strict handover check**
|
||||||
|
|
||||||
|
Run: `python3 scripts/session_handover_check.py --strict`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add workflow/session-handover.md
|
||||||
|
git commit -m "chore: add handover entry for issue #409"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Add Router Unit Tests First (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/test_kr_exchange_router.py`
|
||||||
|
|
||||||
|
**Step 1: Write failing tests for session mapping**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.broker.kr_exchange_router import KRExchangeRouter
|
||||||
|
|
||||||
|
|
||||||
|
def test_ranking_market_code_by_session() -> None:
|
||||||
|
router = KRExchangeRouter()
|
||||||
|
assert router.resolve_for_ranking("KRX_REG") == "J"
|
||||||
|
assert router.resolve_for_ranking("NXT_PRE") == "NX"
|
||||||
|
assert router.resolve_for_ranking("NXT_AFTER") == "NX"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Write failing tests for dual-listing fallback behavior**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_order_exchange_falls_back_to_session_default_on_missing_data() -> None:
|
||||||
|
router = KRExchangeRouter()
|
||||||
|
resolved = router.resolve_for_order(
|
||||||
|
stock_code="0001A0",
|
||||||
|
session_id="NXT_PRE",
|
||||||
|
is_dual_listed=True,
|
||||||
|
spread_krx=None,
|
||||||
|
spread_nxt=None,
|
||||||
|
liquidity_krx=None,
|
||||||
|
liquidity_nxt=None,
|
||||||
|
)
|
||||||
|
assert resolved.exchange_code == "NXT"
|
||||||
|
assert resolved.reason == "fallback_data_unavailable"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run tests to verify fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_kr_exchange_router.py -v`
|
||||||
|
Expected: FAIL (`ModuleNotFoundError` or missing class)
|
||||||
|
|
||||||
|
**Step 4: Commit tests-only checkpoint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_kr_exchange_router.py
|
||||||
|
git commit -m "test: add failing tests for KR exchange router"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Implement Router Minimal Code
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/broker/kr_exchange_router.py`
|
||||||
|
- Modify: `src/broker/__init__.py`
|
||||||
|
|
||||||
|
**Step 1: Add routing dataclass + session default mapping**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExchangeResolution:
|
||||||
|
exchange_code: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class KRExchangeRouter:
|
||||||
|
def resolve_for_ranking(self, session_id: str) -> str:
|
||||||
|
return "NX" if session_id in {"NXT_PRE", "NXT_AFTER"} else "J"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add dual-listing decision path + fallback**
|
||||||
|
|
||||||
|
```python
|
||||||
|
if is_dual_listed and spread_krx is not None and spread_nxt is not None:
|
||||||
|
if spread_nxt < spread_krx:
|
||||||
|
return ExchangeResolution("NXT", "dual_listing_spread")
|
||||||
|
return ExchangeResolution("KRX", "dual_listing_spread")
|
||||||
|
|
||||||
|
return ExchangeResolution(default_exchange, "fallback_data_unavailable")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run router tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_kr_exchange_router.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/broker/kr_exchange_router.py src/broker/__init__.py
|
||||||
|
git commit -m "feat: add KR session-aware exchange router"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Broker Request Wiring (Ranking + Order)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/broker/kis_api.py`
|
||||||
|
- Modify: `tests/test_broker.py`
|
||||||
|
|
||||||
|
**Step 1: Add failing tests for ranking param and order body exchange field**
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert called_params["FID_COND_MRKT_DIV_CODE"] == "NX"
|
||||||
|
assert called_json["EXCG_ID_DVSN_CD"] == "NXT"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run targeted test subset (fail first)**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_broker.py -k "market_rankings or EXCG_ID_DVSN_CD" -v`
|
||||||
|
Expected: FAIL on missing field/value
|
||||||
|
|
||||||
|
**Step 3: Implement minimal wiring**
|
||||||
|
|
||||||
|
```python
|
||||||
|
session_id = runtime_session_id or classify_session_id(MARKETS["KR"])
|
||||||
|
market_div_code = self._kr_router.resolve_for_ranking(session_id)
|
||||||
|
params["FID_COND_MRKT_DIV_CODE"] = market_div_code
|
||||||
|
|
||||||
|
resolution = self._kr_router.resolve_for_order(...)
|
||||||
|
body["EXCG_ID_DVSN_CD"] = resolution.exchange_code
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Add routing evidence logs**
|
||||||
|
|
||||||
|
```python
|
||||||
|
logger.info(
|
||||||
|
"KR routing resolved",
|
||||||
|
extra={"session_id": session_id, "exchange": resolution.exchange_code, "reason": resolution.reason},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Re-run broker tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_broker.py -k "market_rankings or EXCG_ID_DVSN_CD" -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/broker/kis_api.py tests/test_broker.py
|
||||||
|
git commit -m "fix: apply KR exchange routing to rankings and orders"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Scanner Session Alignment
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/analysis/smart_scanner.py`
|
||||||
|
- Modify: `tests/test_smart_scanner.py`
|
||||||
|
|
||||||
|
**Step 1: Add failing test for domestic session-aware ranking path**
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert mock_broker.fetch_market_rankings.call_args_list[0].kwargs["session_id"] == "NXT_PRE"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run scanner tests (fail first)**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_smart_scanner.py -k "session" -v`
|
||||||
|
Expected: FAIL on missing session argument
|
||||||
|
|
||||||
|
**Step 3: Implement scanner call wiring**
|
||||||
|
|
||||||
|
```python
|
||||||
|
fluct_rows = await self.broker.fetch_market_rankings(
|
||||||
|
ranking_type="fluctuation",
|
||||||
|
limit=50,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Re-run scanner tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_smart_scanner.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/analysis/smart_scanner.py tests/test_smart_scanner.py
|
||||||
|
git commit -m "fix: align domestic scanner rankings with KR session routing"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Full Verification and Regression
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No new files
|
||||||
|
|
||||||
|
**Step 1: Run focused regressions for #409**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `pytest tests/test_kr_exchange_router.py tests/test_broker.py tests/test_smart_scanner.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 2: Run related runtime-path regressions for #318/#325**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `pytest tests/test_main.py -k "atr or staged_exit or pred_down_prob" -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Run lint/type checks for touched modules**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `ruff check src/broker/kis_api.py src/broker/kr_exchange_router.py src/analysis/smart_scanner.py tests/test_kr_exchange_router.py tests/test_broker.py tests/test_smart_scanner.py`
|
||||||
|
- `mypy src/broker/kis_api.py src/broker/kr_exchange_router.py src/analysis/smart_scanner.py --strict`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit final fixup if needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: finalize #409 verification adjustments"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: PR Creation, Self-Review, and Merge
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: PR metadata only
|
||||||
|
|
||||||
|
**Step 1: Push branch**
|
||||||
|
|
||||||
|
Run: `git push -u origin feature/issue-409-kr-session-exchange-routing`
|
||||||
|
Expected: remote branch created
|
||||||
|
|
||||||
|
**Step 2: Create PR to `main` with issue links**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PR_BODY=$(cat <<'MD'
|
||||||
|
## Summary
|
||||||
|
- fix KR session-aware exchange routing for rankings and orders (#409)
|
||||||
|
- add dual-listing exchange priority with deterministic fallback
|
||||||
|
- add logs and tests for routing evidence
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- pytest tests/test_kr_exchange_router.py tests/test_broker.py tests/test_smart_scanner.py -v
|
||||||
|
- pytest tests/test_main.py -k "atr or staged_exit or pred_down_prob" -v
|
||||||
|
- ruff check ...
|
||||||
|
- mypy ...
|
||||||
|
MD
|
||||||
|
)
|
||||||
|
|
||||||
|
tea pr create --base main --head feature/issue-409-kr-session-exchange-routing --title "fix: KR session-aware exchange routing (#409)" --description "$PR_BODY"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Validate PR body integrity**
|
||||||
|
|
||||||
|
Run: `python3 scripts/validate_pr_body.py --pr <PR_NUMBER>`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Self-review checklist (blocking)**
|
||||||
|
- Re-check diff for missing `EXCG_ID_DVSN_CD`
|
||||||
|
- Confirm session mapping (`KRX_REG=J`, `NXT_PRE/NXT_AFTER=NX`)
|
||||||
|
- Confirm fallback reason logging exists
|
||||||
|
- Confirm tests cover dual-listing fallback
|
||||||
|
|
||||||
|
**Step 5: Merge only if no minor issues remain**
|
||||||
|
|
||||||
|
Run: `tea pr merge <PR_NUMBER> --merge`
|
||||||
|
Expected: merged
|
||||||
|
|
||||||
|
### Task 8: Restart Program and 24h Runtime Monitoring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Runtime artifacts: `data/overnight/*.log`
|
||||||
|
|
||||||
|
**Step 1: Restart runtime from merged state**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `bash scripts/stop_overnight.sh`
|
||||||
|
- `bash scripts/run_overnight.sh`
|
||||||
|
Expected: live process and watchdog healthy
|
||||||
|
|
||||||
|
**Step 2: Start 24h monitor**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `INTERVAL_SEC=60 MAX_HOURS=24 POLICY_TZ=Asia/Seoul bash scripts/runtime_verify_monitor.sh`
|
||||||
|
Expected: monitor loop runs and writes `data/overnight/runtime_verify_*.log`
|
||||||
|
|
||||||
|
**Step 3: Track #409/#318/#325 evidence in loop**
|
||||||
|
|
||||||
|
Run examples:
|
||||||
|
- `rg -n "KR routing resolved|EXCG_ID_DVSN_CD|session=NXT_|session=KRX_REG" data/overnight/run_*.log`
|
||||||
|
- `rg -n "atr_value|dynamic hard stop|staged exit|pred_down_prob" data/overnight/run_*.log`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- #409 routing evidence present when KR flows trigger
|
||||||
|
- #318/#325 evidence captured if runtime conditions occur
|
||||||
|
|
||||||
|
**Step 4: If anomaly found, create separate issue ticket immediately**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ISSUE_BODY=$(cat <<'MD'
|
||||||
|
## Summary
|
||||||
|
- runtime anomaly detected during #409 monitor
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
- log: data/overnight/run_xxx.log
|
||||||
|
- timestamp: <UTC/KST>
|
||||||
|
- observed: <symptom>
|
||||||
|
|
||||||
|
## Suspected Scope
|
||||||
|
- related to #409/#318/#325 monitoring path
|
||||||
|
|
||||||
|
## Next Action
|
||||||
|
- triage + reproducible test
|
||||||
|
MD
|
||||||
|
)
|
||||||
|
|
||||||
|
tea issues create -t "bug: runtime anomaly during #409 monitor" -d "$ISSUE_BODY"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Post monitoring summary to #409/#318/#325**
|
||||||
|
- Include PASS/FAIL/NOT_OBSERVED matrix and exact timestamps.
|
||||||
|
- Do not close #318/#325 without concrete acceptance evidence.
|
||||||
@@ -68,6 +68,7 @@ class SmartVolatilityScanner:
|
|||||||
self,
|
self,
|
||||||
market: MarketInfo | None = None,
|
market: MarketInfo | None = None,
|
||||||
fallback_stocks: list[str] | None = None,
|
fallback_stocks: list[str] | None = None,
|
||||||
|
domestic_session_id: str | None = None,
|
||||||
) -> list[ScanCandidate]:
|
) -> list[ScanCandidate]:
|
||||||
"""Execute smart scan and return qualified candidates.
|
"""Execute smart scan and return qualified candidates.
|
||||||
|
|
||||||
@@ -81,11 +82,12 @@ class SmartVolatilityScanner:
|
|||||||
if market and not market.is_domestic:
|
if market and not market.is_domestic:
|
||||||
return await self._scan_overseas(market, fallback_stocks)
|
return await self._scan_overseas(market, fallback_stocks)
|
||||||
|
|
||||||
return await self._scan_domestic(fallback_stocks)
|
return await self._scan_domestic(fallback_stocks, session_id=domestic_session_id)
|
||||||
|
|
||||||
async def _scan_domestic(
|
async def _scan_domestic(
|
||||||
self,
|
self,
|
||||||
fallback_stocks: list[str] | None = None,
|
fallback_stocks: list[str] | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
) -> list[ScanCandidate]:
|
) -> list[ScanCandidate]:
|
||||||
"""Scan domestic market using volatility-first ranking + liquidity bonus."""
|
"""Scan domestic market using volatility-first ranking + liquidity bonus."""
|
||||||
# 1) Primary universe from fluctuation ranking.
|
# 1) Primary universe from fluctuation ranking.
|
||||||
@@ -93,6 +95,7 @@ class SmartVolatilityScanner:
|
|||||||
fluct_rows = await self.broker.fetch_market_rankings(
|
fluct_rows = await self.broker.fetch_market_rankings(
|
||||||
ranking_type="fluctuation",
|
ranking_type="fluctuation",
|
||||||
limit=50,
|
limit=50,
|
||||||
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
except ConnectionError as exc:
|
except ConnectionError as exc:
|
||||||
logger.warning("Domestic fluctuation ranking failed: %s", exc)
|
logger.warning("Domestic fluctuation ranking failed: %s", exc)
|
||||||
@@ -103,6 +106,7 @@ class SmartVolatilityScanner:
|
|||||||
volume_rows = await self.broker.fetch_market_rankings(
|
volume_rows = await self.broker.fetch_market_rankings(
|
||||||
ranking_type="volume",
|
ranking_type="volume",
|
||||||
limit=50,
|
limit=50,
|
||||||
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
except ConnectionError as exc:
|
except ConnectionError as exc:
|
||||||
logger.warning("Domestic volume ranking failed: %s", exc)
|
logger.warning("Domestic volume ranking failed: %s", exc)
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ from typing import Any, cast
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from src.broker.kr_exchange_router import KRExchangeRouter
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
from src.core.order_policy import classify_session_id
|
||||||
|
from src.markets.schedule import MARKETS
|
||||||
|
|
||||||
# KIS virtual trading server has a known SSL certificate hostname mismatch.
|
# KIS virtual trading server has a known SSL certificate hostname mismatch.
|
||||||
_KIS_VTS_HOST = "openapivts.koreainvestment.com"
|
_KIS_VTS_HOST = "openapivts.koreainvestment.com"
|
||||||
@@ -92,6 +95,7 @@ class KISBroker:
|
|||||||
self._last_refresh_attempt: float = 0.0
|
self._last_refresh_attempt: float = 0.0
|
||||||
self._refresh_cooldown: float = 60.0 # Seconds (matches KIS 1/minute limit)
|
self._refresh_cooldown: float = 60.0 # Seconds (matches KIS 1/minute limit)
|
||||||
self._rate_limiter = LeakyBucket(settings.RATE_LIMIT_RPS)
|
self._rate_limiter = LeakyBucket(settings.RATE_LIMIT_RPS)
|
||||||
|
self._kr_router = KRExchangeRouter()
|
||||||
|
|
||||||
def _get_session(self) -> aiohttp.ClientSession:
|
def _get_session(self) -> aiohttp.ClientSession:
|
||||||
if self._session is None or self._session.closed:
|
if self._session is None or self._session.closed:
|
||||||
@@ -187,9 +191,12 @@ class KISBroker:
|
|||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
raise ConnectionError(f"Hash key request failed ({resp.status}): {text}")
|
raise ConnectionError(f"Hash key request failed ({resp.status}): {text}")
|
||||||
data = await resp.json()
|
data = cast(dict[str, Any], await resp.json())
|
||||||
|
|
||||||
return data["HASH"]
|
hash_value = data.get("HASH")
|
||||||
|
if not isinstance(hash_value, str):
|
||||||
|
raise ConnectionError("Hash key response missing HASH")
|
||||||
|
return hash_value
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Common Headers
|
# Common Headers
|
||||||
@@ -211,12 +218,21 @@ class KISBroker:
|
|||||||
|
|
||||||
async def get_orderbook(self, stock_code: str) -> dict[str, Any]:
|
async def get_orderbook(self, stock_code: str) -> dict[str, Any]:
|
||||||
"""Fetch the current orderbook for a given stock code."""
|
"""Fetch the current orderbook for a given stock code."""
|
||||||
|
return await self.get_orderbook_by_market(stock_code, market_div_code="J")
|
||||||
|
|
||||||
|
async def get_orderbook_by_market(
|
||||||
|
self,
|
||||||
|
stock_code: str,
|
||||||
|
*,
|
||||||
|
market_div_code: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fetch orderbook for a specific domestic market division code."""
|
||||||
await self._rate_limiter.acquire()
|
await self._rate_limiter.acquire()
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
|
|
||||||
headers = await self._auth_headers("FHKST01010200")
|
headers = await self._auth_headers("FHKST01010200")
|
||||||
params = {
|
params = {
|
||||||
"FID_COND_MRKT_DIV_CODE": "J",
|
"FID_COND_MRKT_DIV_CODE": market_div_code,
|
||||||
"FID_INPUT_ISCD": stock_code,
|
"FID_INPUT_ISCD": stock_code,
|
||||||
}
|
}
|
||||||
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"
|
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"
|
||||||
@@ -226,10 +242,80 @@ class KISBroker:
|
|||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
raise ConnectionError(f"get_orderbook failed ({resp.status}): {text}")
|
raise ConnectionError(f"get_orderbook failed ({resp.status}): {text}")
|
||||||
return await resp.json()
|
return cast(dict[str, Any], await resp.json())
|
||||||
except (TimeoutError, aiohttp.ClientError) as exc:
|
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||||
raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc
|
raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_orderbook_metrics(payload: dict[str, Any]) -> tuple[float | None, float | None]:
|
||||||
|
output = payload.get("output1") or payload.get("output") or {}
|
||||||
|
if not isinstance(output, dict):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _float(*keys: str) -> float | None:
|
||||||
|
for key in keys:
|
||||||
|
raw = output.get(key)
|
||||||
|
if raw in (None, ""):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return float(cast(str | int | float, raw))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
ask = _float("askp1", "stck_askp1")
|
||||||
|
bid = _float("bidp1", "stck_bidp1")
|
||||||
|
if ask is not None and bid is not None and ask > 0 and bid > 0 and ask >= bid:
|
||||||
|
mid = (ask + bid) / 2
|
||||||
|
if mid > 0:
|
||||||
|
spread = (ask - bid) / mid
|
||||||
|
else:
|
||||||
|
spread = None
|
||||||
|
else:
|
||||||
|
spread = None
|
||||||
|
|
||||||
|
ask_qty = _float("askp_rsqn1", "ask_qty1")
|
||||||
|
bid_qty = _float("bidp_rsqn1", "bid_qty1")
|
||||||
|
if ask_qty is not None and bid_qty is not None and ask_qty >= 0 and bid_qty >= 0:
|
||||||
|
liquidity = ask_qty + bid_qty
|
||||||
|
else:
|
||||||
|
liquidity = None
|
||||||
|
|
||||||
|
return spread, liquidity
|
||||||
|
|
||||||
|
async def _load_dual_listing_metrics(
|
||||||
|
self,
|
||||||
|
stock_code: str,
|
||||||
|
) -> tuple[bool, float | None, float | None, float | None, float | None]:
|
||||||
|
"""Try KRX/NXT orderbooks and derive spread/liquidity metrics."""
|
||||||
|
spread_krx: float | None = None
|
||||||
|
spread_nxt: float | None = None
|
||||||
|
liquidity_krx: float | None = None
|
||||||
|
liquidity_nxt: float | None = None
|
||||||
|
|
||||||
|
for market_div_code, exchange in (("J", "KRX"), ("NX", "NXT")):
|
||||||
|
try:
|
||||||
|
payload = await self.get_orderbook_by_market(
|
||||||
|
stock_code,
|
||||||
|
market_div_code=market_div_code,
|
||||||
|
)
|
||||||
|
except ConnectionError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
spread, liquidity = self._extract_orderbook_metrics(payload)
|
||||||
|
if exchange == "KRX":
|
||||||
|
spread_krx = spread
|
||||||
|
liquidity_krx = liquidity
|
||||||
|
else:
|
||||||
|
spread_nxt = spread
|
||||||
|
liquidity_nxt = liquidity
|
||||||
|
|
||||||
|
is_dual_listed = (
|
||||||
|
(spread_krx is not None and spread_nxt is not None)
|
||||||
|
or (liquidity_krx is not None and liquidity_nxt is not None)
|
||||||
|
)
|
||||||
|
return is_dual_listed, spread_krx, spread_nxt, liquidity_krx, liquidity_nxt
|
||||||
|
|
||||||
async def get_current_price(self, stock_code: str) -> tuple[float, float, float]:
|
async def get_current_price(self, stock_code: str) -> tuple[float, float, float]:
|
||||||
"""Fetch current price data for a domestic stock.
|
"""Fetch current price data for a domestic stock.
|
||||||
|
|
||||||
@@ -302,7 +388,7 @@ class KISBroker:
|
|||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
raise ConnectionError(f"get_balance failed ({resp.status}): {text}")
|
raise ConnectionError(f"get_balance failed ({resp.status}): {text}")
|
||||||
return await resp.json()
|
return cast(dict[str, Any], await resp.json())
|
||||||
except (TimeoutError, aiohttp.ClientError) as exc:
|
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||||
raise ConnectionError(f"Network error fetching balance: {exc}") from exc
|
raise ConnectionError(f"Network error fetching balance: {exc}") from exc
|
||||||
|
|
||||||
@@ -311,7 +397,8 @@ class KISBroker:
|
|||||||
stock_code: str,
|
stock_code: str,
|
||||||
order_type: str, # "BUY" or "SELL"
|
order_type: str, # "BUY" or "SELL"
|
||||||
quantity: int,
|
quantity: int,
|
||||||
price: int = 0,
|
price: float = 0,
|
||||||
|
session_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Submit a buy or sell order.
|
"""Submit a buy or sell order.
|
||||||
|
|
||||||
@@ -341,10 +428,32 @@ class KISBroker:
|
|||||||
ord_dvsn = "01" # 시장가
|
ord_dvsn = "01" # 시장가
|
||||||
ord_price = 0
|
ord_price = 0
|
||||||
|
|
||||||
|
resolved_session = session_id or classify_session_id(MARKETS["KR"])
|
||||||
|
if session_id is not None:
|
||||||
|
is_dual_listed, spread_krx, spread_nxt, liquidity_krx, liquidity_nxt = (
|
||||||
|
await self._load_dual_listing_metrics(stock_code)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
is_dual_listed = False
|
||||||
|
spread_krx = None
|
||||||
|
spread_nxt = None
|
||||||
|
liquidity_krx = None
|
||||||
|
liquidity_nxt = None
|
||||||
|
resolution = self._kr_router.resolve_for_order(
|
||||||
|
stock_code=stock_code,
|
||||||
|
session_id=resolved_session,
|
||||||
|
is_dual_listed=is_dual_listed,
|
||||||
|
spread_krx=spread_krx,
|
||||||
|
spread_nxt=spread_nxt,
|
||||||
|
liquidity_krx=liquidity_krx,
|
||||||
|
liquidity_nxt=liquidity_nxt,
|
||||||
|
)
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"CANO": self._account_no,
|
"CANO": self._account_no,
|
||||||
"ACNT_PRDT_CD": self._product_cd,
|
"ACNT_PRDT_CD": self._product_cd,
|
||||||
"PDNO": stock_code,
|
"PDNO": stock_code,
|
||||||
|
"EXCG_ID_DVSN_CD": resolution.exchange_code,
|
||||||
"ORD_DVSN": ord_dvsn,
|
"ORD_DVSN": ord_dvsn,
|
||||||
"ORD_QTY": str(quantity),
|
"ORD_QTY": str(quantity),
|
||||||
"ORD_UNPR": str(ord_price),
|
"ORD_UNPR": str(ord_price),
|
||||||
@@ -361,12 +470,15 @@ class KISBroker:
|
|||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
raise ConnectionError(f"send_order failed ({resp.status}): {text}")
|
raise ConnectionError(f"send_order failed ({resp.status}): {text}")
|
||||||
data = await resp.json()
|
data = cast(dict[str, Any], await resp.json())
|
||||||
logger.info(
|
logger.info(
|
||||||
"Order submitted",
|
"Order submitted",
|
||||||
extra={
|
extra={
|
||||||
"stock_code": stock_code,
|
"stock_code": stock_code,
|
||||||
"action": order_type,
|
"action": order_type,
|
||||||
|
"session_id": resolved_session,
|
||||||
|
"exchange": resolution.exchange_code,
|
||||||
|
"routing_reason": resolution.reason,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
@@ -377,6 +489,7 @@ class KISBroker:
|
|||||||
self,
|
self,
|
||||||
ranking_type: str = "volume",
|
ranking_type: str = "volume",
|
||||||
limit: int = 30,
|
limit: int = 30,
|
||||||
|
session_id: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Fetch market rankings from KIS API.
|
"""Fetch market rankings from KIS API.
|
||||||
|
|
||||||
@@ -394,12 +507,15 @@ class KISBroker:
|
|||||||
await self._rate_limiter.acquire()
|
await self._rate_limiter.acquire()
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
|
|
||||||
|
resolved_session = session_id or classify_session_id(MARKETS["KR"])
|
||||||
|
ranking_market_code = self._kr_router.resolve_for_ranking(resolved_session)
|
||||||
|
|
||||||
if ranking_type == "volume":
|
if ranking_type == "volume":
|
||||||
# 거래량순위: FHPST01710000 / /quotations/volume-rank
|
# 거래량순위: FHPST01710000 / /quotations/volume-rank
|
||||||
tr_id = "FHPST01710000"
|
tr_id = "FHPST01710000"
|
||||||
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
||||||
params: dict[str, str] = {
|
params: dict[str, str] = {
|
||||||
"FID_COND_MRKT_DIV_CODE": "J",
|
"FID_COND_MRKT_DIV_CODE": ranking_market_code,
|
||||||
"FID_COND_SCR_DIV_CODE": "20171",
|
"FID_COND_SCR_DIV_CODE": "20171",
|
||||||
"FID_INPUT_ISCD": "0000",
|
"FID_INPUT_ISCD": "0000",
|
||||||
"FID_DIV_CLS_CODE": "0",
|
"FID_DIV_CLS_CODE": "0",
|
||||||
@@ -416,7 +532,7 @@ class KISBroker:
|
|||||||
tr_id = "FHPST01700000"
|
tr_id = "FHPST01700000"
|
||||||
url = f"{self._base_url}/uapi/domestic-stock/v1/ranking/fluctuation"
|
url = f"{self._base_url}/uapi/domestic-stock/v1/ranking/fluctuation"
|
||||||
params = {
|
params = {
|
||||||
"fid_cond_mrkt_div_code": "J",
|
"fid_cond_mrkt_div_code": ranking_market_code,
|
||||||
"fid_cond_scr_div_code": "20170",
|
"fid_cond_scr_div_code": "20170",
|
||||||
"fid_input_iscd": "0000",
|
"fid_input_iscd": "0000",
|
||||||
"fid_rank_sort_cls_code": "0",
|
"fid_rank_sort_cls_code": "0",
|
||||||
|
|||||||
48
src/broker/kr_exchange_router.py
Normal file
48
src/broker/kr_exchange_router.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExchangeResolution:
|
||||||
|
exchange_code: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class KRExchangeRouter:
|
||||||
|
"""Resolve domestic exchange routing for KR sessions."""
|
||||||
|
|
||||||
|
def resolve_for_ranking(self, session_id: str) -> str:
|
||||||
|
if session_id in {"NXT_PRE", "NXT_AFTER"}:
|
||||||
|
return "NX"
|
||||||
|
return "J"
|
||||||
|
|
||||||
|
def resolve_for_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
stock_code: str,
|
||||||
|
session_id: str,
|
||||||
|
is_dual_listed: bool = False,
|
||||||
|
spread_krx: float | None = None,
|
||||||
|
spread_nxt: float | None = None,
|
||||||
|
liquidity_krx: float | None = None,
|
||||||
|
liquidity_nxt: float | None = None,
|
||||||
|
) -> ExchangeResolution:
|
||||||
|
del stock_code
|
||||||
|
default_exchange = "NXT" if session_id in {"NXT_PRE", "NXT_AFTER"} else "KRX"
|
||||||
|
default_reason = "session_default"
|
||||||
|
|
||||||
|
if not is_dual_listed:
|
||||||
|
return ExchangeResolution(default_exchange, default_reason)
|
||||||
|
|
||||||
|
if spread_krx is not None and spread_nxt is not None:
|
||||||
|
if spread_nxt < spread_krx:
|
||||||
|
return ExchangeResolution("NXT", "dual_listing_spread")
|
||||||
|
return ExchangeResolution("KRX", "dual_listing_spread")
|
||||||
|
|
||||||
|
if liquidity_krx is not None and liquidity_nxt is not None:
|
||||||
|
if liquidity_nxt > liquidity_krx:
|
||||||
|
return ExchangeResolution("NXT", "dual_listing_liquidity")
|
||||||
|
return ExchangeResolution("KRX", "dual_listing_liquidity")
|
||||||
|
|
||||||
|
return ExchangeResolution(default_exchange, "fallback_data_unavailable")
|
||||||
15
src/main.py
15
src/main.py
@@ -35,6 +35,7 @@ from src.core.criticality import CriticalityAssessor
|
|||||||
from src.core.kill_switch import KillSwitchOrchestrator
|
from src.core.kill_switch import KillSwitchOrchestrator
|
||||||
from src.core.order_policy import (
|
from src.core.order_policy import (
|
||||||
OrderPolicyRejected,
|
OrderPolicyRejected,
|
||||||
|
classify_session_id,
|
||||||
get_session_info,
|
get_session_info,
|
||||||
validate_order_policy,
|
validate_order_policy,
|
||||||
)
|
)
|
||||||
@@ -224,23 +225,27 @@ def _compute_kr_dynamic_stop_loss_pct(
|
|||||||
key="KR_ATR_STOP_MULTIPLIER_K",
|
key="KR_ATR_STOP_MULTIPLIER_K",
|
||||||
default=2.0,
|
default=2.0,
|
||||||
)
|
)
|
||||||
min_pct = _resolve_market_setting(
|
min_pct = float(
|
||||||
|
_resolve_market_setting(
|
||||||
market=market,
|
market=market,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
key="KR_ATR_STOP_MIN_PCT",
|
key="KR_ATR_STOP_MIN_PCT",
|
||||||
default=-2.0,
|
default=-2.0,
|
||||||
)
|
)
|
||||||
max_pct = _resolve_market_setting(
|
)
|
||||||
|
max_pct = float(
|
||||||
|
_resolve_market_setting(
|
||||||
market=market,
|
market=market,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
key="KR_ATR_STOP_MAX_PCT",
|
key="KR_ATR_STOP_MAX_PCT",
|
||||||
default=-7.0,
|
default=-7.0,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
if max_pct > min_pct:
|
if max_pct > min_pct:
|
||||||
min_pct, max_pct = max_pct, min_pct
|
min_pct, max_pct = max_pct, min_pct
|
||||||
|
|
||||||
dynamic_stop_pct = -((k * atr_value) / entry_price) * 100.0
|
dynamic_stop_pct = -((k * atr_value) / entry_price) * 100.0
|
||||||
return max(max_pct, min(min_pct, dynamic_stop_pct))
|
return float(max(max_pct, min(min_pct, dynamic_stop_pct)))
|
||||||
|
|
||||||
|
|
||||||
def _stoploss_cooldown_key(*, market: MarketInfo, stock_code: str) -> str:
|
def _stoploss_cooldown_key(*, market: MarketInfo, stock_code: str) -> str:
|
||||||
@@ -1200,6 +1205,7 @@ async def process_blackout_recovery_orders(
|
|||||||
order_type=intent.order_type,
|
order_type=intent.order_type,
|
||||||
quantity=intent.quantity,
|
quantity=intent.quantity,
|
||||||
price=intent.price,
|
price=intent.price,
|
||||||
|
session_id=intent.session_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = await overseas_broker.send_overseas_order(
|
result = await overseas_broker.send_overseas_order(
|
||||||
@@ -2083,6 +2089,7 @@ async def trading_cycle(
|
|||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=order_price,
|
price=order_price,
|
||||||
|
session_id=runtime_session_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# For overseas orders, always use limit orders (지정가):
|
# For overseas orders, always use limit orders (지정가):
|
||||||
@@ -2417,6 +2424,7 @@ async def handle_domestic_pending_orders(
|
|||||||
order_type="SELL",
|
order_type="SELL",
|
||||||
quantity=psbl_qty,
|
quantity=psbl_qty,
|
||||||
price=new_price,
|
price=new_price,
|
||||||
|
session_id=classify_session_id(MARKETS["KR"]),
|
||||||
)
|
)
|
||||||
sell_resubmit_counts[key] = sell_resubmit_counts.get(key, 0) + 1
|
sell_resubmit_counts[key] = sell_resubmit_counts.get(key, 0) + 1
|
||||||
try:
|
try:
|
||||||
@@ -3292,6 +3300,7 @@ async def run_daily_session(
|
|||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=order_price,
|
price=order_price,
|
||||||
|
session_id=runtime_session_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# KIS VTS only accepts limit orders; use 0.5% premium for BUY
|
# KIS VTS only accepts limit orders; use 0.5% premium for BUY
|
||||||
|
|||||||
@@ -400,6 +400,15 @@ class TestFetchMarketRankings:
|
|||||||
assert result[0]["stock_code"] == "015260"
|
assert result[0]["stock_code"] == "015260"
|
||||||
assert result[0]["change_rate"] == 29.74
|
assert result[0]["change_rate"] == 29.74
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_volume_uses_nx_market_code_in_nxt_session(self, broker: KISBroker) -> None:
|
||||||
|
mock_resp = _make_ranking_mock([])
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
|
||||||
|
await broker.fetch_market_rankings(ranking_type="volume", session_id="NXT_PRE")
|
||||||
|
|
||||||
|
params = mock_get.call_args[1].get("params", {})
|
||||||
|
assert params.get("FID_COND_MRKT_DIV_CODE") == "NX"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# KRX tick unit / round-down helpers (issue #157)
|
# KRX tick unit / round-down helpers (issue #157)
|
||||||
@@ -591,6 +600,60 @@ class TestSendOrderTickRounding:
|
|||||||
body = order_call[1].get("json", {})
|
body = order_call[1].get("json", {})
|
||||||
assert body["ORD_DVSN"] == "01"
|
assert body["ORD_DVSN"] == "01"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_order_sets_exchange_field_from_session(self, broker: KISBroker) -> None:
|
||||||
|
mock_hash = AsyncMock()
|
||||||
|
mock_hash.status = 200
|
||||||
|
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
|
||||||
|
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
|
||||||
|
mock_hash.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_order = AsyncMock()
|
||||||
|
mock_order.status = 200
|
||||||
|
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
|
||||||
|
mock_order.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
|
||||||
|
with patch.object(
|
||||||
|
broker,
|
||||||
|
"_load_dual_listing_metrics",
|
||||||
|
new=AsyncMock(return_value=(False, None, None, None, None)),
|
||||||
|
):
|
||||||
|
await broker.send_order("005930", "BUY", 1, price=50000, session_id="NXT_PRE")
|
||||||
|
|
||||||
|
order_call = mock_post.call_args_list[1]
|
||||||
|
body = order_call[1].get("json", {})
|
||||||
|
assert body["EXCG_ID_DVSN_CD"] == "NXT"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_order_prefers_nxt_when_dual_listing_spread_is_tighter(
|
||||||
|
self, broker: KISBroker
|
||||||
|
) -> None:
|
||||||
|
mock_hash = AsyncMock()
|
||||||
|
mock_hash.status = 200
|
||||||
|
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
|
||||||
|
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
|
||||||
|
mock_hash.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_order = AsyncMock()
|
||||||
|
mock_order.status = 200
|
||||||
|
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
|
||||||
|
mock_order.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
|
||||||
|
with patch.object(
|
||||||
|
broker,
|
||||||
|
"_load_dual_listing_metrics",
|
||||||
|
new=AsyncMock(return_value=(True, 0.004, 0.002, 100000.0, 90000.0)),
|
||||||
|
):
|
||||||
|
await broker.send_order("005930", "BUY", 1, price=50000, session_id="KRX_REG")
|
||||||
|
|
||||||
|
order_call = mock_post.call_args_list[1]
|
||||||
|
body = order_call[1].get("json", {})
|
||||||
|
assert body["EXCG_ID_DVSN_CD"] == "NXT"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# TR_ID live/paper branching (issues #201, #202, #203)
|
# TR_ID live/paper branching (issues #201, #202, #203)
|
||||||
|
|||||||
40
tests/test_kr_exchange_router.py
Normal file
40
tests/test_kr_exchange_router.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.broker.kr_exchange_router import KRExchangeRouter
|
||||||
|
|
||||||
|
|
||||||
|
def test_ranking_market_code_by_session() -> None:
|
||||||
|
router = KRExchangeRouter()
|
||||||
|
assert router.resolve_for_ranking("KRX_REG") == "J"
|
||||||
|
assert router.resolve_for_ranking("NXT_PRE") == "NX"
|
||||||
|
assert router.resolve_for_ranking("NXT_AFTER") == "NX"
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_exchange_falls_back_to_session_default_on_missing_data() -> None:
|
||||||
|
router = KRExchangeRouter()
|
||||||
|
resolved = router.resolve_for_order(
|
||||||
|
stock_code="0001A0",
|
||||||
|
session_id="NXT_PRE",
|
||||||
|
is_dual_listed=True,
|
||||||
|
spread_krx=None,
|
||||||
|
spread_nxt=None,
|
||||||
|
liquidity_krx=None,
|
||||||
|
liquidity_nxt=None,
|
||||||
|
)
|
||||||
|
assert resolved.exchange_code == "NXT"
|
||||||
|
assert resolved.reason == "fallback_data_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_exchange_uses_spread_preference_for_dual_listing() -> None:
|
||||||
|
router = KRExchangeRouter()
|
||||||
|
resolved = router.resolve_for_order(
|
||||||
|
stock_code="0001A0",
|
||||||
|
session_id="KRX_REG",
|
||||||
|
is_dual_listed=True,
|
||||||
|
spread_krx=0.005,
|
||||||
|
spread_nxt=0.003,
|
||||||
|
liquidity_krx=100000.0,
|
||||||
|
liquidity_nxt=90000.0,
|
||||||
|
)
|
||||||
|
assert resolved.exchange_code == "NXT"
|
||||||
|
assert resolved.reason == "dual_listing_spread"
|
||||||
@@ -103,6 +103,33 @@ class TestSmartVolatilityScanner:
|
|||||||
assert candidates[0].stock_code == "005930"
|
assert candidates[0].stock_code == "005930"
|
||||||
assert candidates[0].signal == "oversold"
|
assert candidates[0].signal == "oversold"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_domestic_passes_session_id_to_rankings(
|
||||||
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
|
) -> None:
|
||||||
|
fluctuation_rows = [
|
||||||
|
{
|
||||||
|
"stock_code": "005930",
|
||||||
|
"name": "Samsung",
|
||||||
|
"price": 70000,
|
||||||
|
"volume": 5000000,
|
||||||
|
"change_rate": 1.0,
|
||||||
|
"volume_increase_rate": 120,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mock_broker.fetch_market_rankings.side_effect = [fluctuation_rows, fluctuation_rows]
|
||||||
|
mock_broker.get_daily_prices.return_value = [
|
||||||
|
{"open": 1, "high": 71000, "low": 69000, "close": 70000, "volume": 1000000},
|
||||||
|
{"open": 1, "high": 70000, "low": 68000, "close": 69000, "volume": 900000},
|
||||||
|
]
|
||||||
|
|
||||||
|
await scanner.scan(domestic_session_id="NXT_PRE")
|
||||||
|
|
||||||
|
first_call = mock_broker.fetch_market_rankings.call_args_list[0]
|
||||||
|
second_call = mock_broker.fetch_market_rankings.call_args_list[1]
|
||||||
|
assert first_call.kwargs["session_id"] == "NXT_PRE"
|
||||||
|
assert second_call.kwargs["session_id"] == "NXT_PRE"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_domestic_finds_momentum_candidate(
|
async def test_scan_domestic_finds_momentum_candidate(
|
||||||
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
|
|||||||
@@ -137,3 +137,11 @@
|
|||||||
- next_ticket: #377
|
- next_ticket: #377
|
||||||
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
||||||
- risks_or_notes: refresh 단계를 최대 3회(초기+재시도2), 실패 시 지수 백오프로 재시도하고 성공 시 즉시 중단, 소진 시 오류를 기록한 뒤 다음 단계를 계속 수행한다.
|
- risks_or_notes: refresh 단계를 최대 3회(초기+재시도2), 실패 시 지수 백오프로 재시도하고 성공 시 즉시 중단, 소진 시 오류를 기록한 뒤 다음 단계를 계속 수행한다.
|
||||||
|
|
||||||
|
### 2026-03-04 | session=codex-issue409-start
|
||||||
|
- branch: feature/issue-409-kr-session-exchange-routing
|
||||||
|
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
|
||||||
|
- open_issues_reviewed: #409, #318, #325
|
||||||
|
- next_ticket: #409
|
||||||
|
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
||||||
|
- risks_or_notes: #409 코드수정/검증 후 프로그램 재시작 및 24h 런타임 모니터링 수행, 모니터 이상 징후는 별도 이슈 발행
|
||||||
|
|||||||
Reference in New Issue
Block a user