Compare commits

..

19 Commits

Author SHA1 Message Date
agentson
16b9b6832d fix: BULLISH confidence 임계값 75로 복원 (#205)
Some checks failed
CI / test (pull_request) Has been cancelled
CLAUDE.md 규칙 개정에 따라 BULLISH 시장은 75로 유지.
시장 전망별 임계값: BEARISH=90, NEUTRAL=80, BULLISH=75.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:30:51 +09:00
agentson
d6a389e0b7 feat: 실전 투자 전환 — TR_ID 분기, URL, 신뢰도 임계값, 텔레그램 알림 수정 (#201~#205, #208, #214)
Some checks failed
CI / test (pull_request) Has been cancelled
- #201: 국내/해외 TR_ID 실전/모의 자동 분기
  - get_balance: TTTC8434R(실전) / VTTC8434R(모의)
  - send_order: TTTC0012U/0011U(실전) / VTTC0012U/0011U(모의) [현금주문]
  - get_overseas_balance: TTTS3012R(실전) / VTTS3012R(모의)
  - send_overseas_order: TTTT1002U/1006U(실전) / VTTT1002U/1001U(모의)
- #202: KIS_BASE_URL 기본값 VTS 포트 9443→29443 수정
- #203: PAPER_OVERSEAS_CASH fallback 실전(MODE=live)에서 비활성화, 중복 코드 제거
- #205: BULLISH 시장 BUY confidence 임계값 75→80(기본값) 수정 (CLAUDE.md 비협상 규칙)
- #208: Daily 모드 CircuitBreakerTripped 시 텔레그램 알림 추가
- #214: 시스템 종료 시 notify_system_shutdown() 호출 추가

테스트 22개 추가 (TR_ID 분기 12개, confidence 임계값 1개 수정)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 10:28:24 +09:00
cd36d53a47 Merge pull request 'feat: 해외주식 미체결 SELL 시 이중 매수 방지 (#195)' (#200) from feature/issue-195-overseas-double-buy-prevention into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #200
2026-02-23 05:53:24 +09:00
agentson
1242794fc4 feat: 해외주식 미체결 SELL 시 이중 매수 방지 (#195)
Some checks failed
CI / test (pull_request) Has been cancelled
KIS VTS는 SELL 지정가 주문을 접수 즉시 rt_cd=0으로 반환하지만
실제 체결은 시장가 도달 시까지 지연된다. 이 기간 동안 DB는 포지션을
"종료"로 기록해 다음 사이클에서 이중 매수가 발생할 수 있었다.

- trading_cycle(): BUY 게이팅에 브로커 잔고 추가 확인 로직 삽입
- run_daily_session(): 동일 패턴의 BUY 중복 방지 로직 추가
- 두 함수 모두 이미 fetch된 balance_data 재사용 (추가 API 호출 없음)
- TestOverseasBrokerIntegration 클래스에 테스트 2개 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 05:52:35 +09:00
b45d136894 Merge pull request 'feat: 미구현 API 4개 대시보드 프론트 연결 (#198)' (#199) from feature/issue-198-dashboard-api-frontend into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #199
2026-02-23 05:37:33 +09:00
agentson
ce82121f04 feat: 미구현 API 4개 대시보드 프론트 연결 (#198)
Some checks failed
CI / test (pull_request) Has been cancelled
- Playbook(/api/playbook/{date}): 프리마켓 플레이북 아코디언 패널 추가
- Scorecard(/api/scorecard/{date}): 일간 스코어카드 KPI 카드 그리드 추가
- Scenarios(/api/scenarios/active): 활성 시나리오 매칭 테이블 추가
- Context(/api/context/{layer}): L1-L7 컨텍스트 트리 테이블 추가

모든 패널 decisions-panel 아래에 섹션 추가 방식으로 배치.
refreshAll()에 4개 함수 포함하여 30초 자동 갱신 지원.

보안:
- esc() 헬퍼로 innerHTML 삽입 값 XSS 방지
- ctx limit 값 parseInt + 범위 클램핑(1-200) 적용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 13:47:20 +09:00
0e2987e66d Merge pull request 'feat: 대시보드 Circuit Breaker 게이지 추가 (#196)' (#197) from feature/issue-196-cb-gauge into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #197
2026-02-22 11:49:57 +09:00
agentson
cdd5a218a7 refactor: CB 게이지 저장소를 context tree → system_metrics 별도 테이블로 분리
Some checks failed
CI / test (pull_request) Has been cancelled
대시보드 표시 전용 데이터를 AI 의사결정용 context tree에 저장하는 것은
관심사 분리 위반. system_metrics 경량 테이블을 신설하여 완전히 분리. (PR #197 코드리뷰 반영)

- db.py: system_metrics 테이블 추가 (key/value/updated_at)
- main.py: context_store.set_context(L6_DAILY) → db_conn.execute(system_metrics)
- app.py: contexts 쿼리 → system_metrics 쿼리
- tests: _seed_cb_context를 system_metrics 삽입으로 변경

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 11:49:03 +09:00
agentson
f3491e94e4 refactor: CB 게이지 pnl_pct 저장 레이어를 L7 → L6_DAILY로 변경
Some checks failed
CI / test (pull_request) Has been cancelled
portfolio_pnl_pct는 일별 성과 지표이므로 실시간 종목 데이터(L7)보다
일별 P&L 레이어(L6_DAILY)가 더 적합함. (PR #197 코드리뷰 반영)

- main.py: L7_REALTIME + ISO timestamp → L6_DAILY + date(YYYY-MM-DD)
- app.py: contexts 쿼리 layer/timeframe 조건 동기화
- tests: _seed_cb_context L6_DAILY + today 날짜로 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 00:33:21 +09:00
agentson
342511a6ed feat: 대시보드 Circuit Breaker 게이지 추가 (#196)
Some checks failed
CI / test (pull_request) Has been cancelled
- trading_cycle()의 L7 context에 portfolio_pnl_pct_{market} 저장 추가
  → 대시보드가 최신 pnl_pct를 DB에서 직접 조회 가능해짐
- /api/status 응답에 circuit_breaker 섹션 추가
  (threshold_pct, current_pnl_pct, status: ok/warning/tripped/unknown)
  - warning: CB 임계값까지 1% 이내 (-2.0% 이하)
  - tripped: 임계값(-3.0%) 이하
- 대시보드 헤더에 CB 게이지 추가 (점멸 도트 + 진행 바 + 수치)
  - ok: 녹색, warning: 오렌지 점멸, tripped: 빨간 점멸
- CB 상태 테스트 4개 추가 (ok/warning/tripped/unknown)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 21:13:53 +09:00
2d5912dc08 Merge pull request 'feat: 대시보드 오픈 포지션 패널 추가 (#193)' (#194) from feature/issue-193-dashboard-positions into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #194
2026-02-21 21:07:53 +09:00
agentson
40ea41cf3c feat: 대시보드 오픈 포지션 패널 추가 (#193)
Some checks failed
CI / test (pull_request) Has been cancelled
- /api/positions 엔드포인트 신설: 마지막 거래가 BUY인 종목을 오픈 포지션으로 반환
- _connect()에 WAL 모드 + busy_timeout=8000 추가 (트레이딩 루프와 동시 읽기 안전)
- init_db()에 idx_trades_stock_market_ts 인덱스 추가 (포지션 쿼리 최적화)
- index.html: 카드와 P&L 차트 사이에 포지션 패널 삽입 (종목/시장/수량/진입가/보유시간)
- 포지션 패널 테스트 3개 추가 (open BUY 반환, SELL 제외, 빈 DB 처리)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 20:52:51 +09:00
af5bfbac24 Merge pull request 'fix: BUY 결정 전 기존 포지션 체크 추가 — 중복 매수 방지 (#191)' (#192) from feature/issue-191-duplicate-buy-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #192
2026-02-21 09:38:59 +09:00
agentson
7e9a573390 fix: BUY 결정 전 기존 포지션 체크 추가 — 중복 매수 방지 (#191)
Some checks failed
CI / test (pull_request) Has been cancelled
어제(2026-02-20) 거래 로그에서 NP 7번, KNRX 5번 중복 매수 발생.
trading_cycle()의 BUY 브랜치에 get_open_position() 체크를 추가하여
이미 보유 중인 종목은 HOLD로 전환, 재매수를 차단함.

- src/main.py: BUY 결정 직후 기존 포지션 확인 → 있으면 HOLD 변환
- tests/test_main.py: 테스트 2개 추가
  - test_buy_suppressed_when_open_position_exists
  - test_buy_proceeds_when_no_open_position

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 09:35:39 +09:00
7dbc48260c Merge pull request 'fix: 해외주식 모의투자 SELL TR_ID 오류 수정 VTTT1006U → VTTT1001U (#189)' (#190) from feature/issue-189-overseas-sell-tr-id-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #190
2026-02-21 03:14:34 +09:00
agentson
4b883a4fc4 docs: KIS API TR_ID 공식 문서 참조 규칙 추가 (#189)
Some checks failed
CI / test (pull_request) Has been cancelled
docs/commands.md에 "KIS API TR_ID 참조 문서" 섹션 추가:
- 공식 문서 경로 명시: 한국투자증권_오픈API_전체문서_20260221_030000.xlsx
- 모의투자/실전투자 TR_ID 표 정리
- 비공식 자료(블로그 등) 사용 금지 경고
- 출처 주석 작성 가이드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 03:14:00 +09:00
agentson
98071a8ee3 fix: 해외주식 모의투자 SELL TR_ID 오류 수정 VTTT1006U → VTTT1001U (#189)
Some checks failed
CI / test (pull_request) Has been cancelled
KIS 공식 문서(20260221) '해외주식 주문' 시트 확인 결과:
- 모의투자 미국 매수: VTTT1002U (기존 정상)
- 모의투자 미국 매도: VTTT1001U (기존 VTTT1006U → 잘못된 TR_ID)

VTTT1006U는 존재하지 않는 TR_ID로, 모든 해외 SELL 주문이
"모의투자에서는 해당업무가 제공되지 않습니다." 오류로 거부되었음.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 03:12:00 +09:00
agentson
f2ad270e8b docs: 2026-02-21 요구사항 로그 업데이트 (#187)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 00:34:16 +09:00
04c73a1a06 Merge pull request 'fix: SELL 주문에서 Fat Finger 오탐 수정 — 손절/익절 차단 버그 (#187)' (#188) from feature/issue-187-sell-fat-finger-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #188
2026-02-21 00:33:46 +09:00
13 changed files with 1396 additions and 18 deletions

View File

@@ -192,6 +192,27 @@ When `TELEGRAM_COMMANDS_ENABLED=true` (default), the bot accepts these interacti
Commands are only processed from the authorized `TELEGRAM_CHAT_ID`.
## KIS API TR_ID 참조 문서
**TR_ID를 추가하거나 수정할 때 반드시 공식 문서를 먼저 확인할 것.**
공식 문서: `docs/한국투자증권_오픈API_전체문서_20260221_030000.xlsx`
> ⚠️ 커뮤니티 블로그, GitHub 예제 등 비공식 자료의 TR_ID는 오래되거나 틀릴 수 있음.
> 실제로 `VTTT1006U`(미국 매도 — 잘못됨)가 오랫동안 코드에 남아있던 사례가 있음 (Issue #189).
### 주요 TR_ID 목록
| 구분 | 모의투자 TR_ID | 실전투자 TR_ID | 시트명 |
|------|---------------|---------------|--------|
| 해외주식 매수 (미국) | `VTTT1002U` | `TTTT1002U` | 해외주식 주문 |
| 해외주식 매도 (미국) | `VTTT1001U` | `TTTT1006U` | 해외주식 주문 |
새로운 TR_ID가 필요할 때:
1. 위 xlsx 파일에서 해당 거래 유형의 시트를 찾는다.
2. 모의투자(`VTTT`) / 실전투자(`TTTT`) 컬럼을 구분하여 정확한 값을 사용한다.
3. 코드에 출처 주석을 남긴다: `# Source: 한국투자증권_오픈API_전체문서 — '<시트명>' 시트`
## Environment Setup
```bash

View File

@@ -7,6 +7,32 @@
---
## 2026-02-21
### 거래 상태 확인 중 발견된 버그 (#187)
- 거래 상태 점검 요청 → SELL 주문(손절/익절)이 Fat Finger에 막혀 전혀 실행 안 됨 발견
- **#187 (Critical)**: SELL 주문에서 Fat Finger 오탐 — `order_amount/total_cash > 30%`가 SELL에도 적용되어 대형 포지션 매도 불가
- JELD stop-loss -6.20% → 차단, RXT take-profit +46.13% → 차단
- 수정: SELL은 `check_circuit_breaker`만 호출, `validate_order`(Fat Finger 포함) 미호출
---
## 2026-02-20
### 지속적 모니터링 및 개선점 도출 (이슈 #178~#182)
- Dashboard 포함해서 실행하며 간헐적 문제 모니터링 및 개선점 자동 도출 요청
- 모니터링 결과 발견된 이슈 목록:
- **#178**: uvicorn 미설치 → dashboard 미작동 + 오해의 소지 있는 시작 로그 → uvicorn 설치 완료
- **#179 (Critical)**: 잔액 부족 주문 실패 후 매 사이클마다 무한 재시도 (MLECW 20분 이상 반복)
- **#180**: 다중 인스턴스 실행 시 Telegram 409 충돌
- **#181**: implied_rsi 공식 포화 문제 (change_rate≥12.5% → RSI=100)
- **#182 (Critical)**: 보유 종목이 SmartScanner 변동성 필터에 걸려 SELL 신호 미생성 → SELL 체결 0건, 잔고 소진
- 요구사항: 모니터링 자동화 및 주기적 개선점 리포트 도출
---
## 2026-02-05
### API 효율화

View File

@@ -285,7 +285,10 @@ class KISBroker:
await self._rate_limiter.acquire()
session = self._get_session()
headers = await self._auth_headers("VTTC8434R") # 모의투자 잔고조회
# TR_ID: 실전 TTTC8434R, 모의 VTTC8434R
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '국내주식 잔고조회' 시트
tr_id = "TTTC8434R" if self._settings.MODE == "live" else "VTTC8434R"
headers = await self._auth_headers(tr_id)
params = {
"CANO": self._account_no,
"ACNT_PRDT_CD": self._product_cd,
@@ -330,7 +333,13 @@ class KISBroker:
await self._rate_limiter.acquire()
session = self._get_session()
tr_id = "VTTC0802U" if order_type == "BUY" else "VTTC0801U"
# TR_ID: 실전 BUY=TTTC0012U SELL=TTTC0011U, 모의 BUY=VTTC0012U SELL=VTTC0011U
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '주식주문(현금)' 시트
# ※ TTTC0802U/VTTC0802U는 미수매수(증거금40% 계좌 전용) — 현금주문에 사용 금지
if self._settings.MODE == "live":
tr_id = "TTTC0012U" if order_type == "BUY" else "TTTC0011U"
else:
tr_id = "VTTC0012U" if order_type == "BUY" else "VTTC0011U"
# KRX requires limit orders to be rounded down to the tick unit.
# ORD_DVSN: "00"=지정가, "01"=시장가

View File

@@ -175,8 +175,12 @@ class OverseasBroker:
await self._broker._rate_limiter.acquire()
session = self._broker._get_session()
# Virtual trading TR_ID for overseas balance inquiry
headers = await self._broker._auth_headers("VTTS3012R")
# TR_ID: 실전 TTTS3012R, 모의 VTTS3012R
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 잔고조회' 시트
balance_tr_id = (
"TTTS3012R" if self._broker._settings.MODE == "live" else "VTTS3012R"
)
headers = await self._broker._auth_headers(balance_tr_id)
params = {
"CANO": self._broker._account_no,
"ACNT_PRDT_CD": self._broker._product_cd,
@@ -229,8 +233,12 @@ class OverseasBroker:
await self._broker._rate_limiter.acquire()
session = self._broker._get_session()
# Virtual trading TR_IDs for overseas orders
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1006U"
# TR_ID: 실전 BUY=TTTT1002U SELL=TTTT1006U, 모의 BUY=VTTT1002U SELL=VTTT1001U
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 주문' 시트
if self._broker._settings.MODE == "live":
tr_id = "TTTT1002U" if order_type == "BUY" else "TTTT1006U"
else:
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1001U"
body = {
"CANO": self._broker._account_no,

View File

@@ -13,7 +13,7 @@ class Settings(BaseSettings):
KIS_APP_KEY: str
KIS_APP_SECRET: str
KIS_ACCOUNT_NO: str # format: "XXXXXXXX-XX"
KIS_BASE_URL: str = "https://openapivts.koreainvestment.com:9443"
KIS_BASE_URL: str = "https://openapivts.koreainvestment.com:29443"
# Google Gemini
GEMINI_API_KEY: str

View File

@@ -3,8 +3,9 @@
from __future__ import annotations
import json
import os
import sqlite3
from datetime import UTC, datetime
from datetime import UTC, datetime, timezone
from pathlib import Path
from typing import Any
@@ -79,6 +80,35 @@ def create_dashboard_app(db_path: str) -> FastAPI:
total_pnl += market_status[market]["total_pnl"]
total_decisions += market_status[market]["decision_count"]
cb_threshold = float(os.getenv("CIRCUIT_BREAKER_PCT", "-3.0"))
pnl_pct_rows = conn.execute(
"""
SELECT key, value
FROM system_metrics
WHERE key LIKE 'portfolio_pnl_pct_%'
ORDER BY updated_at DESC
LIMIT 20
"""
).fetchall()
current_pnl_pct: float | None = None
if pnl_pct_rows:
values = [
json.loads(row["value"]).get("pnl_pct")
for row in pnl_pct_rows
if json.loads(row["value"]).get("pnl_pct") is not None
]
if values:
current_pnl_pct = round(min(values), 4)
if current_pnl_pct is None:
cb_status = "unknown"
elif current_pnl_pct <= cb_threshold:
cb_status = "tripped"
elif current_pnl_pct <= cb_threshold + 1.0:
cb_status = "warning"
else:
cb_status = "ok"
return {
"date": today,
"markets": market_status,
@@ -87,6 +117,11 @@ def create_dashboard_app(db_path: str) -> FastAPI:
"total_pnl": round(total_pnl, 2),
"decision_count": total_decisions,
},
"circuit_breaker": {
"threshold_pct": cb_threshold,
"current_pnl_pct": current_pnl_pct,
"status": cb_status,
},
}
@app.get("/api/playbook/{date_str}")
@@ -341,12 +376,68 @@ def create_dashboard_app(db_path: str) -> FastAPI:
)
return {"market": market, "date": date_str, "count": len(matches), "matches": matches}
@app.get("/api/positions")
def get_positions() -> dict[str, Any]:
"""Return all currently open positions (last trade per symbol is BUY)."""
with _connect(db_path) as conn:
rows = conn.execute(
"""
SELECT stock_code, market, exchange_code,
price AS entry_price, quantity, timestamp AS entry_time,
decision_id
FROM (
SELECT stock_code, market, exchange_code, price, quantity,
timestamp, decision_id, action,
ROW_NUMBER() OVER (
PARTITION BY stock_code, market
ORDER BY timestamp DESC
) AS rn
FROM trades
)
WHERE rn = 1 AND action = 'BUY'
ORDER BY entry_time DESC
"""
).fetchall()
now = datetime.now(timezone.utc)
positions = []
for row in rows:
entry_time_str = row["entry_time"]
try:
entry_dt = datetime.fromisoformat(entry_time_str.replace("Z", "+00:00"))
held_seconds = int((now - entry_dt).total_seconds())
held_hours = held_seconds // 3600
held_minutes = (held_seconds % 3600) // 60
if held_hours >= 1:
held_display = f"{held_hours}h {held_minutes}m"
else:
held_display = f"{held_minutes}m"
except (ValueError, TypeError):
held_display = "--"
positions.append(
{
"stock_code": row["stock_code"],
"market": row["market"],
"exchange_code": row["exchange_code"],
"entry_price": row["entry_price"],
"quantity": row["quantity"],
"entry_time": entry_time_str,
"held": held_display,
"decision_id": row["decision_id"],
}
)
return {"count": len(positions), "positions": positions}
return app
def _connect(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=8000")
return conn

View File

@@ -13,6 +13,7 @@
--muted: #9fb3c8;
--accent: #3cb371;
--red: #e05555;
--warn: #e8a040;
--border: #28455f;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -43,6 +44,25 @@
}
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
/* CB Gauge */
.cb-gauge-wrap {
display: flex; align-items: center; gap: 8px;
font-size: 11px; color: var(--muted);
}
.cb-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.cb-dot.ok { background: var(--accent); }
.cb-dot.warning { background: var(--warn); animation: pulse-warn 1.2s ease-in-out infinite; }
.cb-dot.tripped { background: var(--red); animation: pulse-warn 0.6s ease-in-out infinite; }
.cb-dot.unknown { background: var(--border); }
@keyframes pulse-warn {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.cb-bar-wrap { width: 64px; height: 5px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
.cb-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s, background 0.4s; }
/* Summary cards */
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
@media (max-width: 700px) { .cards { grid-template-columns: repeat(2, 1fr); } }
@@ -123,9 +143,80 @@
.rationale-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
.empty-row td { text-align: center; color: var(--muted); padding: 24px; }
/* Positions panel */
.positions-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 20px;
}
.positions-table { width: 100%; border-collapse: collapse; margin-top: 14px; }
.positions-table th {
text-align: left; color: var(--muted); font-size: 11px; font-weight: 600;
padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap;
}
.positions-table td {
padding: 8px 8px; border-bottom: 1px solid rgba(40, 69, 95, 0.5);
vertical-align: middle; white-space: nowrap;
}
.positions-table tr:last-child td { border-bottom: none; }
.positions-table tr:hover td { background: rgba(255,255,255,0.02); }
.pos-empty { color: var(--muted); text-align: center; padding: 20px 0; font-size: 12px; }
.pos-count {
display: inline-block; background: rgba(60, 179, 113, 0.12);
color: var(--accent); font-size: 11px; font-weight: 700;
padding: 2px 8px; border-radius: 10px; margin-left: 8px;
}
/* Spinner */
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Generic panel */
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-top: 20px;
}
/* Playbook panel - details/summary accordion */
.playbook-panel details { border: 1px solid var(--border); border-radius: 4px; margin-bottom: 6px; }
.playbook-panel summary { padding: 8px 12px; cursor: pointer; font-weight: 600; background: var(--bg); color: var(--fg); }
.playbook-panel summary:hover { color: var(--accent); }
.playbook-panel pre { margin: 0; padding: 12px; background: var(--bg); overflow-x: auto;
font-size: 11px; color: #a0c4ff; white-space: pre-wrap; }
/* Scorecard KPI card grid */
.scorecard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
.kpi-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px; text-align: center; }
.kpi-card .kpi-label { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
.kpi-card .kpi-value { font-size: 20px; font-weight: 700; color: var(--fg); }
/* Scenarios table */
.scenarios-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.scenarios-table th { background: var(--bg); padding: 8px; text-align: left; border-bottom: 1px solid var(--border);
color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; }
.scenarios-table td { padding: 7px 8px; border-bottom: 1px solid rgba(40,69,95,0.5); }
.scenarios-table tr:hover td { background: rgba(255,255,255,0.02); }
/* Context table */
.context-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.context-table th { background: var(--bg); padding: 8px; text-align: left; border-bottom: 1px solid var(--border);
color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; }
.context-table td { padding: 6px 8px; border-bottom: 1px solid rgba(40,69,95,0.5); vertical-align: top; }
.context-value { max-height: 60px; overflow-y: auto; color: #a0c4ff; word-break: break-all; }
/* Common panel select controls */
.panel-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.panel-controls select, .panel-controls input[type="number"] {
background: var(--bg); color: var(--fg); border: 1px solid var(--border);
border-radius: 4px; padding: 4px 8px; font-size: 13px; font-family: inherit;
}
.panel-date { color: var(--muted); font-size: 12px; }
.empty-msg { color: var(--muted); text-align: center; padding: 20px 0; font-size: 12px; }
</style>
</head>
<body>
@@ -134,6 +225,13 @@
<header>
<h1>&#x1F40D; The Ouroboros</h1>
<div class="header-right">
<div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker">
<span class="cb-dot unknown" id="cb-dot"></span>
<span id="cb-label">CB --</span>
<div class="cb-bar-wrap">
<div class="cb-bar-fill" id="cb-bar" style="width:0%;background:var(--accent)"></div>
</div>
</div>
<span id="last-updated">--</span>
<button class="refresh-btn" onclick="refreshAll()">&#x21BA; 새로고침</button>
</div>
@@ -163,6 +261,30 @@
</div>
</div>
<!-- Open Positions -->
<div class="positions-panel">
<div class="panel-header">
<span class="panel-title">
현재 보유 포지션
<span class="pos-count" id="positions-count">0</span>
</span>
</div>
<table class="positions-table">
<thead>
<tr>
<th>종목</th>
<th>시장</th>
<th>수량</th>
<th>진입가</th>
<th>보유 시간</th>
</tr>
</thead>
<tbody id="positions-body">
<tr><td colspan="5" class="pos-empty"><span class="spinner"></span></td></tr>
</tbody>
</table>
</div>
<!-- P&L Chart -->
<div class="chart-panel">
<div class="panel-header">
@@ -206,6 +328,72 @@
</tbody>
</table>
</div>
<!-- playbook panel -->
<div class="panel playbook-panel">
<div class="panel-header">
<span class="panel-title">&#x1F4CB; 프리마켓 플레이북</span>
<div class="panel-controls">
<select id="pb-market-select" onchange="fetchPlaybook()">
<option value="KR">KR</option>
<option value="US_NASDAQ">US_NASDAQ</option>
<option value="US_NYSE">US_NYSE</option>
</select>
<span id="pb-date" class="panel-date"></span>
</div>
</div>
<div id="playbook-content"><p class="empty-msg">데이터 없음</p></div>
</div>
<!-- scorecard panel -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">&#x1F4CA; 일간 스코어카드</span>
<div class="panel-controls">
<select id="sc-market-select" onchange="fetchScorecard()">
<option value="KR">KR</option>
<option value="US_NASDAQ">US_NASDAQ</option>
</select>
<span id="sc-date" class="panel-date"></span>
</div>
</div>
<div id="scorecard-grid" class="scorecard-grid"><p class="empty-msg">데이터 없음</p></div>
</div>
<!-- scenarios panel -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">&#x1F3AF; 활성 시나리오 매칭</span>
<div class="panel-controls">
<select id="scen-market-select" onchange="fetchScenarios()">
<option value="KR">KR</option>
<option value="US_NASDAQ">US_NASDAQ</option>
</select>
</div>
</div>
<div id="scenarios-content"><p class="empty-msg">데이터 없음</p></div>
</div>
<!-- context layer panel -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">&#x1F9E0; 컨텍스트 트리</span>
<div class="panel-controls">
<select id="ctx-layer-select" onchange="fetchContext()">
<option value="L7_REALTIME">L7_REALTIME</option>
<option value="L6_DAILY">L6_DAILY</option>
<option value="L5_WEEKLY">L5_WEEKLY</option>
<option value="L4_MONTHLY">L4_MONTHLY</option>
<option value="L3_QUARTERLY">L3_QUARTERLY</option>
<option value="L2_YEARLY">L2_YEARLY</option>
<option value="L1_LIFETIME">L1_LIFETIME</option>
</select>
<input id="ctx-limit" type="number" value="20" min="1" max="200"
style="width:60px;" onchange="fetchContext()">
</div>
</div>
<div id="context-content"><p class="empty-msg">데이터 없음</p></div>
</div>
</div>
<script>
@@ -242,6 +430,71 @@
</div>`;
}
function fmtPrice(v, market) {
if (v === null || v === undefined) return '--';
const n = parseFloat(v);
const sym = market === 'KR' ? '₩' : market === 'JP' ? '¥' : market === 'HK' ? 'HK$' : '$';
return sym + n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 4 });
}
async function fetchPositions() {
const tbody = document.getElementById('positions-body');
const countEl = document.getElementById('positions-count');
try {
const r = await fetch('/api/positions');
if (!r.ok) throw new Error('fetch failed');
const d = await r.json();
countEl.textContent = d.count ?? 0;
if (!d.positions || d.positions.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="pos-empty">현재 보유 중인 포지션 없음</td></tr>';
return;
}
tbody.innerHTML = d.positions.map(p => `
<tr>
<td><strong>${p.stock_code || '--'}</strong></td>
<td><span style="color:var(--muted);font-size:11px">${p.market || '--'}</span></td>
<td>${p.quantity ?? '--'}</td>
<td>${fmtPrice(p.entry_price, p.market)}</td>
<td style="color:var(--muted);font-size:11px">${p.held || '--'}</td>
</tr>
`).join('');
} catch {
tbody.innerHTML = '<tr><td colspan="5" class="pos-empty">데이터 로드 실패</td></tr>';
}
}
function renderCbGauge(cb) {
if (!cb) return;
const dot = document.getElementById('cb-dot');
const label = document.getElementById('cb-label');
const bar = document.getElementById('cb-bar');
const status = cb.status || 'unknown';
const threshold = cb.threshold_pct ?? -3.0;
const current = cb.current_pnl_pct;
// dot color
dot.className = `cb-dot ${status}`;
// label
if (current !== null && current !== undefined) {
const sign = current > 0 ? '+' : '';
label.textContent = `CB ${sign}${current.toFixed(2)}%`;
} else {
label.textContent = 'CB --';
}
// bar: fill = how much of the threshold has been consumed (0%=safe, 100%=tripped)
const colorMap = { ok: 'var(--accent)', warning: 'var(--warn)', tripped: 'var(--red)', unknown: 'var(--border)' };
bar.style.background = colorMap[status] || 'var(--border)';
if (current !== null && current !== undefined && threshold < 0) {
const fillPct = Math.min(Math.max((current / threshold) * 100, 0), 100);
bar.style.width = `${fillPct}%`;
} else {
bar.style.width = '0%';
}
}
async function fetchStatus() {
try {
const r = await fetch('/api/status');
@@ -258,6 +511,7 @@
pnlEl.className = `card-value ${n > 0 ? 'positive' : n < 0 ? 'negative' : 'neutral'}`;
}
document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}`;
renderCbGauge(d.circuit_breaker);
} catch {}
}
@@ -378,13 +632,129 @@
fetchDecisions(currentMarket);
}
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function fetchJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
async function fetchPlaybook() {
const market = document.getElementById('pb-market-select').value;
const date = todayStr();
document.getElementById('pb-date').textContent = date;
const el = document.getElementById('playbook-content');
try {
const data = await fetchJSON(`/api/playbook/${date}?market=${market}`);
const stocks = data.stock_playbooks ?? [];
if (stocks.length === 0) {
el.innerHTML = '<p class="empty-msg">오늘 플레이북 없음</p>';
return;
}
el.innerHTML = stocks.map(sp =>
`<details><summary>${esc(sp.stock_code ?? '?')}${esc(sp.signal ?? '')}</summary>` +
`<pre>${esc(JSON.stringify(sp, null, 2))}</pre></details>`
).join('');
} catch {
el.innerHTML = '<p class="empty-msg">플레이북 없음 (오늘 미생성 또는 API 오류)</p>';
}
}
async function fetchScorecard() {
const market = document.getElementById('sc-market-select').value;
const date = todayStr();
document.getElementById('sc-date').textContent = date;
const el = document.getElementById('scorecard-grid');
try {
const data = await fetchJSON(`/api/scorecard/${date}?market=${market}`);
const sc = data.scorecard ?? {};
const entries = Object.entries(sc);
if (entries.length === 0) {
el.innerHTML = '<p class="empty-msg">스코어카드 없음</p>';
return;
}
el.className = 'scorecard-grid';
el.innerHTML = entries.map(([k, v]) => `
<div class="kpi-card">
<div class="kpi-label">${esc(k)}</div>
<div class="kpi-value">${typeof v === 'number' ? v.toFixed(2) : esc(String(v))}</div>
</div>`).join('');
} catch {
el.innerHTML = '<p class="empty-msg">스코어카드 없음 (오늘 미생성 또는 API 오류)</p>';
}
}
async function fetchScenarios() {
const market = document.getElementById('scen-market-select').value;
const date = todayStr();
const el = document.getElementById('scenarios-content');
try {
const data = await fetchJSON(`/api/scenarios/active?market=${market}&date_str=${date}&limit=50`);
const matches = data.matches ?? [];
if (matches.length === 0) {
el.innerHTML = '<p class="empty-msg">활성 시나리오 없음</p>';
return;
}
el.innerHTML = `<table class="scenarios-table">
<thead><tr><th>종목</th><th>신호</th><th>신뢰도</th><th>매칭 조건</th></tr></thead>
<tbody>${matches.map(m => `
<tr>
<td>${esc(m.stock_code)}</td>
<td>${esc(m.signal ?? '-')}</td>
<td>${esc(m.confidence ?? '-')}</td>
<td><code style="font-size:11px">${esc(JSON.stringify(m.scenario_match ?? {}))}</code></td>
</tr>`).join('')}
</tbody></table>`;
} catch {
el.innerHTML = '<p class="empty-msg">데이터 없음</p>';
}
}
async function fetchContext() {
const layer = document.getElementById('ctx-layer-select').value;
const limit = Math.min(Math.max(parseInt(document.getElementById('ctx-limit').value, 10) || 20, 1), 200);
const el = document.getElementById('context-content');
try {
const data = await fetchJSON(`/api/context/${layer}?limit=${limit}`);
const entries = data.entries ?? [];
if (entries.length === 0) {
el.innerHTML = '<p class="empty-msg">컨텍스트 없음</p>';
return;
}
el.innerHTML = `<table class="context-table">
<thead><tr><th>timeframe</th><th>key</th><th>value</th><th>updated</th></tr></thead>
<tbody>${entries.map(e => `
<tr>
<td>${esc(e.timeframe)}</td>
<td>${esc(e.key)}</td>
<td><div class="context-value">${esc(JSON.stringify(e.value ?? e.raw_value))}</div></td>
<td style="font-size:11px;color:var(--muted)">${esc((e.updated_at ?? '').slice(0, 16))}</td>
</tr>`).join('')}
</tbody></table>`;
} catch {
el.innerHTML = '<p class="empty-msg">데이터 없음</p>';
}
}
async function refreshAll() {
document.getElementById('last-updated').textContent = '업데이트 중...';
await Promise.all([
fetchStatus(),
fetchPerformance(),
fetchPositions(),
fetchPnlHistory(currentDays),
fetchDecisions(currentMarket),
fetchPlaybook(),
fetchScorecard(),
fetchScenarios(),
fetchContext(),
]);
const now = new Date();
const timeStr = now.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });

View File

@@ -131,6 +131,25 @@ def init_db(db_path: str) -> sqlite3.Connection:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_decision_logs_confidence ON decision_logs(confidence)"
)
# Index for open-position queries (partition by stock_code, market, ordered by timestamp)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_trades_stock_market_ts"
" ON trades (stock_code, market, timestamp DESC)"
)
# Lightweight key-value store for trading system runtime metrics (dashboard use only)
# Intentionally separate from the AI context tree to preserve separation of concerns.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS system_metrics (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.commit()
return conn

View File

@@ -340,7 +340,13 @@ async def trading_cycle(
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
# Paper mode fallback: VTS overseas balance API often fails for many accounts.
if total_cash <= 0 and settings and settings.PAPER_OVERSEAS_CASH > 0:
# Only activate in paper mode — live mode must use real balance from KIS.
if (
total_cash <= 0
and settings
and settings.MODE == "paper"
and settings.PAPER_OVERSEAS_CASH > 0
):
logger.debug(
"Overseas cash balance is 0 for %s; using paper fallback %.2f USD",
market.exchange_code,
@@ -430,6 +436,17 @@ async def trading_cycle(
{"volume_ratio": candidate.volume_ratio},
)
# Write pnl_pct to system_metrics (dashboard-only table, separate from AI context tree)
db_conn.execute(
"INSERT OR REPLACE INTO system_metrics (key, value, updated_at) VALUES (?, ?, ?)",
(
f"portfolio_pnl_pct_{market.code}",
json.dumps({"pnl_pct": round(pnl_pct, 4)}),
datetime.now(UTC).isoformat(),
),
)
db_conn.commit()
# Build portfolio data for global rule evaluation
portfolio_data = {
"portfolio_pnl_pct": pnl_pct,
@@ -510,6 +527,33 @@ async def trading_cycle(
),
)
# BUY 결정 전 기존 포지션 체크 (중복 매수 방지)
if decision.action == "BUY":
existing_position = get_open_position(db_conn, stock_code, market.code)
if not existing_position and not market.is_domestic:
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
# 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
broker_qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=False
)
if broker_qty > 0:
existing_position = {"price": 0.0, "quantity": broker_qty}
if existing_position:
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=(
f"Already holding {stock_code} "
f"(entry={existing_position['price']:.4f}, "
f"qty={existing_position['quantity']})"
),
)
logger.info(
"BUY suppressed for %s (%s): already holding open position",
stock_code,
market.name,
)
if decision.action == "HOLD":
open_position = get_open_position(db_conn, stock_code, market.code)
if open_position:
@@ -1003,11 +1047,12 @@ async def run_daily_session(
balance_info.get("frcr_buy_amt_smtl", "0") or "0"
)
# Paper mode fallback: VTS overseas balance API often fails for many accounts.
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0:
total_cash = settings.PAPER_OVERSEAS_CASH
# VTS overseas balance API often returns 0; use paper fallback.
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0:
# Only activate in paper mode — live mode must use real balance from KIS.
if (
total_cash <= 0
and settings.MODE == "paper"
and settings.PAPER_OVERSEAS_CASH > 0
):
total_cash = settings.PAPER_OVERSEAS_CASH
# Calculate daily P&L %
@@ -1046,6 +1091,33 @@ async def run_daily_session(
decision.confidence,
)
# BUY 중복 방지: 브로커 잔고 기반 (미체결 SELL 리밋 주문 보호)
if decision.action == "BUY":
daily_existing = get_open_position(db_conn, stock_code, market.code)
if not daily_existing and not market.is_domestic:
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
# 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
broker_qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=False
)
if broker_qty > 0:
daily_existing = {"price": 0.0, "quantity": broker_qty}
if daily_existing:
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=(
f"Already holding {stock_code} "
f"(entry={daily_existing['price']:.4f}, "
f"qty={daily_existing['quantity']})"
),
)
logger.info(
"BUY suppressed for %s (%s): already holding open position",
stock_code,
market.name,
)
# Log decision
context_snapshot = {
"L1": {
@@ -1914,6 +1986,10 @@ async def run(settings: Settings) -> None:
)
except CircuitBreakerTripped:
logger.critical("Circuit breaker tripped — shutting down")
await telegram.notify_circuit_breaker(
pnl_pct=settings.CIRCUIT_BREAKER_PCT,
threshold=settings.CIRCUIT_BREAKER_PCT,
)
shutdown.set()
break
except Exception as exc:
@@ -2231,6 +2307,8 @@ async def run(settings: Settings) -> None:
except TimeoutError:
pass # Normal — timeout means it's time for next cycle
finally:
# Notify shutdown before closing resources
await telegram.notify_system_shutdown("Normal shutdown")
# Clean up resources
await command_handler.stop_polling()
await broker.close()

View File

@@ -572,4 +572,156 @@ class TestSendOrderTickRounding:
order_call = mock_post.call_args_list[1]
body = order_call[1].get("json", {})
assert body["ORD_DVSN"] == "01"
assert body["ORD_UNPR"] == "0"
# ---------------------------------------------------------------------------
# TR_ID live/paper branching (issues #201, #202, #203)
# ---------------------------------------------------------------------------
class TestTRIDBranchingDomestic:
"""get_balance and send_order must use correct TR_ID for live vs paper mode."""
def _make_broker(self, settings, mode: str) -> KISBroker:
from src.config import Settings
s = Settings(
KIS_APP_KEY=settings.KIS_APP_KEY,
KIS_APP_SECRET=settings.KIS_APP_SECRET,
KIS_ACCOUNT_NO=settings.KIS_ACCOUNT_NO,
GEMINI_API_KEY=settings.GEMINI_API_KEY,
DB_PATH=":memory:",
ENABLED_MARKETS="KR",
MODE=mode,
)
b = KISBroker(s)
b._access_token = "tok"
b._token_expires_at = float("inf")
b._rate_limiter.acquire = AsyncMock()
return b
@pytest.mark.asyncio
async def test_get_balance_paper_uses_vttc8434r(self, settings) -> None:
broker = self._make_broker(settings, "paper")
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={"output1": [], "output2": {}}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
await broker.get_balance()
headers = mock_get.call_args[1].get("headers", {})
assert headers["tr_id"] == "VTTC8434R"
@pytest.mark.asyncio
async def test_get_balance_live_uses_tttc8434r(self, settings) -> None:
broker = self._make_broker(settings, "live")
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={"output1": [], "output2": {}}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
await broker.get_balance()
headers = mock_get.call_args[1].get("headers", {})
assert headers["tr_id"] == "TTTC8434R"
@pytest.mark.asyncio
async def test_send_order_buy_paper_uses_vttc0012u(self, settings) -> None:
broker = self._make_broker(settings, "paper")
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:
await broker.send_order("005930", "BUY", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "VTTC0012U"
@pytest.mark.asyncio
async def test_send_order_buy_live_uses_tttc0012u(self, settings) -> None:
broker = self._make_broker(settings, "live")
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:
await broker.send_order("005930", "BUY", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "TTTC0012U"
@pytest.mark.asyncio
async def test_send_order_sell_paper_uses_vttc0011u(self, settings) -> None:
broker = self._make_broker(settings, "paper")
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:
await broker.send_order("005930", "SELL", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "VTTC0011U"
@pytest.mark.asyncio
async def test_send_order_sell_live_uses_tttc0011u(self, settings) -> None:
broker = self._make_broker(settings, "live")
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:
await broker.send_order("005930", "SELL", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "TTTC0011U"

View File

@@ -316,3 +316,100 @@ def test_pnl_history_market_filter(tmp_path: Path) -> None:
# KR has 1 trade with pnl=2.0
assert len(body["labels"]) >= 1
assert body["pnl"][0] == 2.0
def test_positions_returns_open_buy(tmp_path: Path) -> None:
"""BUY가 마지막 거래인 종목은 포지션으로 반환되어야 한다."""
app = _app(tmp_path)
get_positions = _endpoint(app, "/api/positions")
body = get_positions()
# seed_db: 005930은 BUY (오픈), AAPL은 SELL (마지막)
assert body["count"] == 1
pos = body["positions"][0]
assert pos["stock_code"] == "005930"
assert pos["market"] == "KR"
assert pos["quantity"] == 1
assert pos["entry_price"] == 70000
def test_positions_excludes_closed_sell(tmp_path: Path) -> None:
"""마지막 거래가 SELL인 종목은 포지션에 나타나지 않아야 한다."""
app = _app(tmp_path)
get_positions = _endpoint(app, "/api/positions")
body = get_positions()
codes = [p["stock_code"] for p in body["positions"]]
assert "AAPL" not in codes
def test_positions_empty_when_no_trades(tmp_path: Path) -> None:
"""거래 내역이 없으면 빈 포지션 목록을 반환해야 한다."""
db_path = tmp_path / "empty.db"
conn = init_db(str(db_path))
conn.close()
app = create_dashboard_app(str(db_path))
get_positions = _endpoint(app, "/api/positions")
body = get_positions()
assert body["count"] == 0
assert body["positions"] == []
def _seed_cb_context(conn: sqlite3.Connection, pnl_pct: float, market: str = "KR") -> None:
import json as _json
conn.execute(
"INSERT OR REPLACE INTO system_metrics (key, value, updated_at) VALUES (?, ?, ?)",
(
f"portfolio_pnl_pct_{market}",
_json.dumps({"pnl_pct": pnl_pct}),
"2026-02-22T10:00:00+00:00",
),
)
conn.commit()
def test_status_circuit_breaker_ok(tmp_path: Path) -> None:
"""pnl_pct가 -2.0%보다 높으면 status=ok를 반환해야 한다."""
db_path = tmp_path / "cb_ok.db"
conn = init_db(str(db_path))
_seed_cb_context(conn, -1.0)
conn.close()
app = create_dashboard_app(str(db_path))
get_status = _endpoint(app, "/api/status")
body = get_status()
cb = body["circuit_breaker"]
assert cb["status"] == "ok"
assert cb["current_pnl_pct"] == -1.0
assert cb["threshold_pct"] == -3.0
def test_status_circuit_breaker_warning(tmp_path: Path) -> None:
"""pnl_pct가 -2.0% 이하이면 status=warning을 반환해야 한다."""
db_path = tmp_path / "cb_warn.db"
conn = init_db(str(db_path))
_seed_cb_context(conn, -2.5)
conn.close()
app = create_dashboard_app(str(db_path))
get_status = _endpoint(app, "/api/status")
body = get_status()
assert body["circuit_breaker"]["status"] == "warning"
def test_status_circuit_breaker_tripped(tmp_path: Path) -> None:
"""pnl_pct가 임계값(-3.0%) 이하이면 status=tripped를 반환해야 한다."""
db_path = tmp_path / "cb_tripped.db"
conn = init_db(str(db_path))
_seed_cb_context(conn, -3.5)
conn.close()
app = create_dashboard_app(str(db_path))
get_status = _endpoint(app, "/api/status")
body = get_status()
assert body["circuit_breaker"]["status"] == "tripped"
def test_status_circuit_breaker_unknown_when_no_data(tmp_path: Path) -> None:
"""L7 context에 pnl_pct 데이터가 없으면 status=unknown을 반환해야 한다."""
app = _app(tmp_path) # seed_db에는 portfolio_pnl_pct 없음
get_status = _endpoint(app, "/api/status")
body = get_status()
cb = body["circuit_breaker"]
assert cb["status"] == "unknown"
assert cb["current_pnl_pct"] is None

View File

@@ -2848,3 +2848,338 @@ class TestMarketOutlookConfidenceThreshold:
call_args = decision_logger.log_decision.call_args
assert call_args is not None
assert call_args.kwargs["action"] == "BUY"
@pytest.mark.asyncio
async def test_buy_suppressed_when_open_position_exists() -> None:
"""BUY should be suppressed when an open position already exists for the stock."""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
# 기존 BUY 포지션 DB에 기록 (중복 매수 상황)
buy_decision_id = decision_logger.log_decision(
stock_code="NP",
market="US",
exchange_code="AMS",
action="BUY",
confidence=90,
rationale="initial entry",
context_snapshot={},
input_data={},
)
log_trade(
conn=db_conn,
stock_code="NP",
action="BUY",
confidence=90,
rationale="initial entry",
quantity=10,
price=50.0,
market="US",
exchange_code="AMS",
decision_id=buy_decision_id,
)
broker = MagicMock()
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "51.0", "rate": "2.0", "high": "52.0", "low": "50.0", "tvol": "1000000"}}
)
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [],
"output2": [{"frcr_dncl_amt_2": "10000", "frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}],
}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"})
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match(stock_code="NP"))
market = MagicMock()
market.name = "United States"
market.code = "US"
market.exchange_code = "AMS"
market.is_domestic = False
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
await trading_cycle(
broker=broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US"),
risk=MagicMock(),
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code="NP",
scan_candidates={},
)
# 이미 보유 중이므로 주문이 실행되지 않아야 함
broker.send_order.assert_not_called()
overseas_broker.send_overseas_order.assert_not_called()
@pytest.mark.asyncio
async def test_buy_proceeds_when_no_open_position() -> None:
"""BUY should proceed normally when no open position exists for the stock."""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
# DB가 비어있는 상태 — 기존 포지션 없음
broker = MagicMock()
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "100.0", "rate": "1.0", "high": "101.0", "low": "99.0", "tvol": "500000"}}
)
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [],
"output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}],
}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"})
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match(stock_code="KNRX"))
market = MagicMock()
market.name = "United States"
market.code = "US"
market.exchange_code = "NAS"
market.is_domestic = False
risk = MagicMock()
risk.validate_order = MagicMock()
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
await trading_cycle(
broker=broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US"),
risk=risk,
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code="KNRX",
scan_candidates={},
)
# 포지션이 없으므로 해외 주문이 실행되어야 함
overseas_broker.send_overseas_order.assert_called_once()
class TestOverseasBrokerIntegration:
"""Test overseas broker live-balance gating for double-buy prevention.
Issue #195: KIS VTS SELL limit orders are accepted (rt_cd=0) immediately
but may not fill until the market price reaches the limit. During this window,
the DB records the position as closed, causing the next cycle to BUY again.
These tests verify that live broker balance is used as the authoritative source.
"""
@pytest.mark.asyncio
async def test_overseas_buy_suppressed_by_broker_balance_when_db_shows_closed(
self,
) -> None:
"""BUY must be suppressed when broker still holds shares even if DB says closed.
Scenario: SELL limit order was accepted (DB shows closed), but hasn't
filled yet — broker balance still shows 10 AAPL shares.
Expected: send_overseas_order is NOT called.
"""
db_conn = init_db(":memory:")
# DB: BUY then SELL recorded → get_open_position returns None (closed)
log_trade(
conn=db_conn,
stock_code="AAPL",
action="BUY",
confidence=90,
rationale="entry",
quantity=10,
price=180.0,
market="US_NASDAQ",
exchange_code="NASD",
)
log_trade(
conn=db_conn,
stock_code="AAPL",
action="SELL",
confidence=90,
rationale="sell order accepted",
quantity=10,
price=182.0,
market="US_NASDAQ",
exchange_code="NASD",
)
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "182.50"}}
)
# 브로커: 여전히 AAPL 10주 보유 중 (SELL 미체결)
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}],
"output2": [
{
"frcr_dncl_amt_2": "50000.00",
"frcr_evlu_tota": "60000.00",
"frcr_buy_amt_smtl": "50000.00",
}
],
}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL"))
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
await trading_cycle(
broker=MagicMock(),
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US"),
risk=MagicMock(),
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code="AAPL",
scan_candidates={},
)
# 브로커 잔고에 보유 중이므로 BUY 주문이 억제되어야 함 (이중 매수 방지)
overseas_broker.send_overseas_order.assert_not_called()
@pytest.mark.asyncio
async def test_overseas_buy_proceeds_when_broker_shows_no_holding(
self,
) -> None:
"""BUY must proceed when both DB and broker confirm no existing holding.
Scenario: No prior trades in DB and broker balance shows no AAPL.
Expected: send_overseas_order IS called (normal buy flow).
"""
db_conn = init_db(":memory:")
# DB: 레코드 없음 (신규 포지션)
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "182.50"}}
)
# 브로커: AAPL 미보유
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [],
"output2": [
{
"frcr_dncl_amt_2": "50000.00",
"frcr_evlu_tota": "50000.00",
"frcr_buy_amt_smtl": "0.00",
}
],
}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL"))
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=MagicMock(),
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US"),
risk=MagicMock(),
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code="AAPL",
scan_candidates={},
)
# DB도 브로커도 보유 없음 → BUY 주문이 실행되어야 함 (회귀 테스트)
overseas_broker.send_overseas_order.assert_called_once()

View File

@@ -414,7 +414,7 @@ class TestSendOverseasOrder:
@pytest.mark.asyncio
async def test_sell_limit_order(self, overseas_broker: OverseasBroker) -> None:
"""Limit sell order should use VTTT1006U and ORD_DVSN=00."""
"""Limit sell order should use VTTT1001U and ORD_DVSN=00."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
@@ -428,7 +428,7 @@ class TestSendOverseasOrder:
result = await overseas_broker.send_overseas_order("NYSE", "MSFT", "SELL", 5, price=350.0)
assert result["rt_cd"] == "0"
overseas_broker._broker._auth_headers.assert_called_with("VTTT1006U")
overseas_broker._broker._auth_headers.assert_called_with("VTTT1001U")
call_args = mock_session.post.call_args
body = call_args[1]["json"]
@@ -640,4 +640,176 @@ class TestPaperOverseasCash:
GEMINI_API_KEY="g",
)
assert settings.PAPER_OVERSEAS_CASH == 0.0
del os.environ["PAPER_OVERSEAS_CASH"]
# ---------------------------------------------------------------------------
# TR_ID live/paper branching — overseas (issues #201, #203)
# ---------------------------------------------------------------------------
def _make_overseas_broker_with_mode(mode: str) -> OverseasBroker:
s = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
DB_PATH=":memory:",
MODE=mode,
)
kis = KISBroker(s)
kis._access_token = "tok"
kis._token_expires_at = float("inf")
kis._rate_limiter.acquire = AsyncMock()
return OverseasBroker(kis)
class TestOverseasTRIDBranching:
"""get_overseas_balance and send_overseas_order must use correct TR_ID."""
@pytest.mark.asyncio
async def test_get_overseas_balance_paper_uses_vtts3012r(self) -> None:
broker = _make_overseas_broker_with_mode("paper")
captured: list[str] = []
async def mock_auth_headers(tr_id: str) -> dict:
captured.append(tr_id)
return {"tr_id": tr_id, "authorization": "Bearer tok"}
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output1": [], "output2": []})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
broker._broker._get_session = MagicMock(return_value=mock_session)
await broker.get_overseas_balance("NASD")
assert "VTTS3012R" in captured
@pytest.mark.asyncio
async def test_get_overseas_balance_live_uses_ttts3012r(self) -> None:
broker = _make_overseas_broker_with_mode("live")
captured: list[str] = []
async def mock_auth_headers(tr_id: str) -> dict:
captured.append(tr_id)
return {"tr_id": tr_id, "authorization": "Bearer tok"}
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output1": [], "output2": []})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
broker._broker._get_session = MagicMock(return_value=mock_session)
await broker.get_overseas_balance("NASD")
assert "TTTS3012R" in captured
@pytest.mark.asyncio
async def test_send_overseas_order_buy_paper_uses_vttt1002u(self) -> None:
broker = _make_overseas_broker_with_mode("paper")
captured: list[str] = []
async def mock_auth_headers(tr_id: str) -> dict:
captured.append(tr_id)
return {"tr_id": tr_id, "authorization": "Bearer tok"}
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=mock_resp)
broker._broker._get_session = MagicMock(return_value=mock_session)
await broker.send_overseas_order("NASD", "AAPL", "BUY", 1)
assert "VTTT1002U" in captured
@pytest.mark.asyncio
async def test_send_overseas_order_buy_live_uses_tttt1002u(self) -> None:
broker = _make_overseas_broker_with_mode("live")
captured: list[str] = []
async def mock_auth_headers(tr_id: str) -> dict:
captured.append(tr_id)
return {"tr_id": tr_id, "authorization": "Bearer tok"}
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=mock_resp)
broker._broker._get_session = MagicMock(return_value=mock_session)
await broker.send_overseas_order("NASD", "AAPL", "BUY", 1)
assert "TTTT1002U" in captured
@pytest.mark.asyncio
async def test_send_overseas_order_sell_paper_uses_vttt1001u(self) -> None:
broker = _make_overseas_broker_with_mode("paper")
captured: list[str] = []
async def mock_auth_headers(tr_id: str) -> dict:
captured.append(tr_id)
return {"tr_id": tr_id, "authorization": "Bearer tok"}
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=mock_resp)
broker._broker._get_session = MagicMock(return_value=mock_session)
await broker.send_overseas_order("NASD", "AAPL", "SELL", 1)
assert "VTTT1001U" in captured
@pytest.mark.asyncio
async def test_send_overseas_order_sell_live_uses_tttt1006u(self) -> None:
broker = _make_overseas_broker_with_mode("live")
captured: list[str] = []
async def mock_auth_headers(tr_id: str) -> dict:
captured.append(tr_id)
return {"tr_id": tr_id, "authorization": "Bearer tok"}
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=mock_resp)
broker._broker._get_session = MagicMock(return_value=mock_session)
await broker.send_overseas_order("NASD", "AAPL", "SELL", 1)
assert "TTTT1006U" in captured