Compare commits

..

23 Commits

Author SHA1 Message Date
agentson
d31a61cd0b fix: prompt_override 경로 _total_decisions 미카운트, 완료 로그 추가, 테스트 보완
Some checks failed
CI / test (pull_request) Has been cancelled
리뷰 지적 사항 반영:
- _total_decisions 카운트 제거 (플레이북 생성은 거래 결정이 아님 → 메트릭 왜곡 방지)
- "Gemini raw response received" INFO 로그 추가 (완료 추적 가능)
- test_prompt_override_takes_priority_over_optimization 신규 추가
  (enable_optimization=True 상태에서도 prompt_override 우선됨을 검증)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:54:55 +09:00
agentson
1c7a17320c fix: prompt_override 시 parse_response 건너뛰어 Missing fields 경고 제거 (#247)
Some checks failed
CI / test (pull_request) Has been cancelled
pre_market_planner처럼 prompt_override를 사용하는 호출자는 플레이북 JSON 등
TradeDecision이 아닌 raw 텍스트를 기대한다. 기존에는 parse_response를 통과시켜
항상 "Missing fields" 경고가 발생했다.

decide()에서 prompt_override 감지 시 parse_response를 건너뛰고 raw 응답을
rationale에 담아 직접 반환하도록 수정한다.
정상 응답인데 경고가 뜨는 문제가 해결된다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:46:21 +09:00
f58d42fdb0 Merge pull request 'fix: parse_response missing fields 시 raw 보존으로 플레이북 생성 복구 (#245)' (#246) from feature/issue-245-parse-response-preserve-raw into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #246
2026-02-25 01:33:34 +09:00
agentson
0b20251de0 fix: parse_response에서 missing fields 시 raw 텍스트 보존 (#245)
Some checks failed
CI / test (pull_request) Has been cancelled
pre_market_planner는 prompt_override로 Gemini에 플레이북 JSON을 요청한다.
Gemini가 플레이북 JSON을 반환해도 parse_response가 action/confidence/rationale 키가
없다는 이유로 rationale="Missing required fields"를 반환해 실제 응답이 버려졌다.

이로 인해 플레이북 생성이 항상 실패하고 RSI 기반 기본 폴백이 사용됐으며,
RSI가 없는 해외 시장 데이터와 매칭되지 않아 모든 결정이 HOLD(confidence=0)였다.

수정: missing fields 시 rationale=raw로 설정해 실제 Gemini 응답을 보존한다.
pre_market_planner가 decision.rationale에서 플레이북 JSON을 추출하여 정상 파싱 가능.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:31:54 +09:00
bffe6e9288 Merge pull request 'fix: Gemini compressed prompt 키 불일치 및 해외 스캐너 GUBN=0 수정 (#242, #243)' (#244) from feature/issue-242-243-gemini-key-fix-overseas-scanner into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #244
2026-02-25 01:18:41 +09:00
agentson
0146d1bf8a fix: Gemini compressed prompt 키 불일치 및 해외 스캐너 GUBN=0 수정 (#242, #243)
Some checks failed
CI / test (pull_request) Has been cancelled
- prompt_optimizer: build_compressed_prompt의 JSON 키를 act/conf/reason에서
  action/confidence/rationale로 수정 (parse_response와 일치시킴)
  → Gemini 응답 100% HOLD로 처리되던 버그 수정
- overseas: fetch_overseas_rankings의 GUBN 파라미터를 1(상승)에서 0(전체)으로 변경
  → 변동성 스캐너가 상승/하락 모두 대상으로 NASDAQ 후보 발견 가능
- test: GUBN==0 검증, build_compressed_prompt 키 이름 검증 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:16:51 +09:00
497564e75c Merge pull request 'fix: KR 등락률순위 API 파라미터 오류 수정 — 스캐너 미동작 해결 (#240)' (#241) from feature/issue-240-kr-scanner-rank-param-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #241
2026-02-24 09:18:11 +09:00
agentson
988a56c07c fix: KR 등락률순위 API 파라미터 오류 수정 — 스캐너 미동작 해결 (#240)
Some checks failed
CI / test (pull_request) Has been cancelled
실전 API가 fid_rank_sort_cls_code='0000'(4자리)를 거부함.
'0'(1자리)으로 수정하고, 실전 응답의 종목코드 키가
mksc_shrn_iscd 대신 stck_shrn_iscd임을 반영하여 파싱 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 09:15:40 +09:00
c9f1345e3c Merge pull request 'fix: 대시보드 mode 배지 os.getenv 대신 settings.MODE 사용 (#237)' (#239) from feature/issue-237-dashboard-mode-badge-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #239
2026-02-24 06:52:29 +09:00
agentson
8c492eae3a fix: 대시보드 mode 배지 os.getenv 대신 settings.MODE 사용 (#237)
Some checks failed
CI / test (pull_request) Has been cancelled
os.getenv("MODE")는 .env 파일을 읽지 못해 항상 paper를 반환함.
create_dashboard_app에 mode 파라미터 추가 후 main.py에서
settings.MODE를 직접 전달하도록 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 06:52:10 +09:00
271c592a46 Merge pull request 'feat: 대시보드 헤더에 모의투자/실전투자 모드 배지 표시 (#237)' (#238) from feature/issue-237-dashboard-mode-badge into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #238
2026-02-24 06:49:21 +09:00
agentson
a063bd9d10 feat: 대시보드 헤더에 모의투자/실전투자 모드 배지 표시 (#237)
Some checks failed
CI / test (pull_request) Has been cancelled
- /api/status 응답에 MODE 환경변수 기반 mode 필드 추가
- 대시보드 헤더에 모드 배지 표시 (live=빨간색 깜빡임, paper=노란색)
- 모드 관련 테스트 3개 추가 (total 26 passed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 06:48:22 +09:00
847456e0af Merge pull request 'fix: 해외잔고 ord_psbl_qty 우선 적용 및 ghost position SELL 반복 방지 (#235)' (#236) from feature/issue-235-overseas-balance-ord-psbl-qty into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #236
2026-02-24 06:08:31 +09:00
agentson
a3a9fd1f24 docs: requirements-log에 #235 ghost position 수정 기록 추가
Some checks failed
CI / test (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 05:59:58 +09:00
agentson
f34117bc81 fix: 해외잔고 ord_psbl_qty 우선 적용 및 ghost position SELL 반복 방지 (#235)
Some checks failed
CI / test (pull_request) Has been cancelled
- _extract_held_codes_from_balance / _extract_held_qty_from_balance:
  해외 잔고 수량 필드를 ovrs_cblc_qty(총 보유수량) → ord_psbl_qty(주문가능수량)
  우선으로 변경. KIS 공식 문서(VTTS3012R) 확인 결과 ord_psbl_qty가 실제
  매도 가능 수량이며, ovrs_cblc_qty는 만료/결제 미완료 포지션을 포함함.
  MLECW 등 만료된 Warrant는 ovrs_cblc_qty=289456이지만 ord_psbl_qty=0이라
  startup sync 대상에서 제외되고 SELL 수량도 0이 됨.

- trading_cycle: 해외 SELL이 '잔고내역이 없습니다'로 실패할 때 DB 포지션을
  ghost-close SELL 로그로 닫아 무한 재시도 방지. exchange code 불일치 등
  예외 상황에서 DB가 계속 open 상태로 남는 문제 해소.

- docstring: _extract_held_qty_from_balance 해외 필드 설명 업데이트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 05:59:06 +09:00
17e012cd04 Merge pull request 'feat: 국내주식 지정가 전환 및 미체결 처리 (#232)' (#234) from feature/issue-232-domestic-limit-order-pending into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #234
2026-02-23 22:03:40 +09:00
agentson
a030dcc0dc docs: requirements-log에 #232 국내주식 지정가 전환 기록
Some checks failed
CI / test (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:02:09 +09:00
agentson
d1698dee33 feat: 국내주식 지정가 전환 및 미체결 처리 (#232)
- KISBroker에 get_domestic_pending_orders (TTTC0084R, 실전전용)
  및 cancel_domestic_order (실전 TTTC0013U / 모의 VTTC0013U) 추가
- main.py 국내 주문 price=0 → 지정가 전환 (2곳):
  · BUY +0.2% / SELL -0.2%, kr_round_down으로 KRX 틱 반올림 적용
- handle_domestic_pending_orders 함수 추가:
  · BUY 미체결 → 취소 + buy_cooldown 설정
  · SELL 미체결 → 취소 후 -0.4% 재주문 (최대 1회)
- daily/realtime 두 모드 market 루프 내 domestic pending 호출 추가
  (sell_resubmit_counts는 해외용과 공유, key prefix "KR:" vs 거래소코드)
- 테스트 14개 추가:
  · test_broker.py: TestGetDomesticPendingOrders 3개 + TestCancelDomesticOrder 5개
  · test_main.py: TestHandleDomesticPendingOrders 4개 + TestDomesticLimitOrderPrice 2개

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:02:09 +09:00
8a8ba3b0cb Merge pull request 'feat: 해외주식 미체결 주문 감지 및 처리 (#229)' (#231) from feature/issue-229-overseas-pending-order-handling into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #231
2026-02-23 22:00:10 +09:00
agentson
6b74e4cc77 feat: 해외주식 미체결 주문 감지 및 처리 (#229)
Some checks failed
CI / test (pull_request) Has been cancelled
- OverseasBroker에 get_overseas_pending_orders (TTTS3018R, 실전전용)
  및 cancel_overseas_order (거래소별 TR_ID, hashkey 필수) 추가
- TelegramClient에 notify_unfilled_order 추가
  (BUY취소=MEDIUM, SELL미체결=HIGH 우선순위)
- handle_overseas_pending_orders 함수 추가:
  · BUY 미체결 → 취소 + 쿨다운 설정
  · SELL 미체결 → 취소 후 -0.4% 재주문 (최대 1회)
  · 미국 거래소(NASD/NYSE/AMEX) 중복 조회 방지
- daily/realtime 두 모드 모두 market 루프 시작 전 호출
- 테스트 13개 추가 (test_overseas_broker.py 8개, test_main.py 5개)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:12:34 +09:00
1a1fe7e637 Merge pull request 'feat: 해외주식 지정가 버퍼 최적화 BUY +0.2% / SELL -0.2% (#211)' (#230) from feature/issue-211-overseas-limit-price-policy into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #230
2026-02-23 17:47:34 +09:00
agentson
2e27000760 feat: 해외주식 지정가 버퍼 최적화 BUY +0.2% / SELL -0.2% (#211)
Some checks failed
CI / test (pull_request) Has been cancelled
기존 정책(BUY +0.5%, SELL 현재가)의 두 가지 문제를 해결:
- BUY 0.5% 버퍼는 대형주에서 불필요한 과다 지불 유발 ($50K 규모에서 연간 수십 달러 손실)
- SELL 현재가 지정가는 가격이 소폭 하락 시 미체결 위험 (bid < last_price 구간)

변경:
- BUY: current_price * 1.005 → current_price * 1.002 (+0.2%)
  대형주 기준 90%+ 체결률 유지하면서 과다 지불 최소화
- SELL: current_price → current_price * 0.998 (-0.2%)
  bid가 last_price 아래일 때도 체결 보장
- VTS(paper)와 live 동일 정책 적용 — 더 현실적인 시뮬레이션
- KIS 시장가 주문은 상한가 기준 수량 계산 버그로 사용 안 함(유지)

테스트:
- test_overseas_buy_order_uses_limit_price: 1.005 → 1.002 업데이트
- test_overseas_sell_order_uses_limit_price_below_current: 신규 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 17:25:15 +09:00
5a41f86112 Merge pull request 'feat: 시작 시 브로커 포지션 → DB 동기화 및 국내주식 이중 매수 방지 (#206)' (#228) from feature/issue-206-startup-position-sync into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #228
2026-02-23 17:04:01 +09:00
15 changed files with 2202 additions and 40 deletions

View File

@@ -292,3 +292,66 @@ Order result: 모의투자 매수주문이 완료 되었습니다. ✓
``` ```
**이슈/PR:** #149, #150 **이슈/PR:** #149, #150
---
## 2026-02-23
### 국내주식 지정가 전환 및 미체결 처리 (#232)
**배경:**
- 해외주식은 #211에서 지정가로 전환했으나 국내주식은 여전히 `price=0` (시장가)
- KRX도 지정가 주문 사용 시 동일한 미체결 위험이 존재
- 지정가 전환 + 미체결 처리를 함께 구현
**구현 내용:**
1. `src/broker/kis_api.py`
- `get_domestic_pending_orders()`: 모의 즉시 `[]`, 실전 `TTTC0084R` GET
- `cancel_domestic_order()`: 실전 `TTTC0013U` / 모의 `VTTC0013U`, hashkey 필수
2. `src/main.py`
- import `kr_round_down` 추가
- `trading_cycle`, `run_daily_session` 국내 주문 `price=0` → 지정가:
BUY +0.2% / SELL -0.2%, `kr_round_down` KRX 틱 반올림 적용
- `handle_domestic_pending_orders` 함수: BUY→취소+쿨다운, SELL→취소+재주문(-0.4%, 최대1회)
- daily/realtime 두 모드에서 domestic pending 체크 호출 추가
3. 테스트 14개 추가:
- `TestGetDomesticPendingOrders` (3), `TestCancelDomesticOrder` (5)
- `TestHandleDomesticPendingOrders` (4), `TestDomesticLimitOrderPrice` (2)
**이슈/PR:** #232, PR #233
---
## 2026-02-24
### 해외잔고 ghost position 수정 — '모의투자 잔고내역이 없습니다' 반복 방지 (#235)
**배경:**
- 모의투자 실행 시 MLECW, KNRX, NBY, SNSE 등 만료/정지된 종목에 대해
`모의투자 잔고내역이 없습니다` 오류가 매 사이클 반복됨
**근본 원인:**
1. `ovrs_cblc_qty` (해외잔고수량, 총 보유) vs `ord_psbl_qty` (주문가능수량, 실제 매도 가능)
- 기존 코드: `ovrs_cblc_qty` 우선 사용 → 만료 Warrant가 `ovrs_cblc_qty=289456`이지만 실제 `ord_psbl_qty=0`
- startup sync / build_overseas_symbol_universe가 이 종목들을 포지션으로 기록
2. SELL 실패 시 DB 포지션이 닫히지 않아 다음 사이클에서도 재시도 (무한 반복)
**구현 내용:**
1. `src/main.py``_extract_held_codes_from_balance`, `_extract_held_qty_from_balance`
- 해외 잔고 필드 우선순위 변경: `ord_psbl_qty``ovrs_cblc_qty``hldg_qty` (fallback 유지)
- KIS 공식 문서(VTTS3012R) 기준: `ord_psbl_qty`가 실제 매도 가능 수량
2. `src/main.py``trading_cycle` ghost-close 처리
- 해외 SELL이 `잔고내역이 없습니다`로 실패 시 DB 포지션을 `[ghost-close]` SELL로 종료
- exchange code 불일치 등 예외 상황에서 무한 반복 방지
3. 테스트 7개 추가:
- `TestExtractHeldQtyFromBalance` 3개: ord_psbl_qty 우선, 0이면 0 반환, fallback
- `TestExtractHeldCodesFromBalance` 2개: ord_psbl_qty=0인 종목 제외, fallback
- `TestOverseasGhostPositionClose` 2개: ghost-close 로그 확인, 일반 오류 무시
**이슈/PR:** #235, PR #236

View File

@@ -346,8 +346,10 @@ class GeminiClient:
# Validate required fields # Validate required fields
if not all(k in data for k in ("action", "confidence", "rationale")): if not all(k in data for k in ("action", "confidence", "rationale")):
logger.warning("Missing fields in Gemini response — defaulting to HOLD") logger.warning("Missing fields in Gemini response — defaulting to HOLD")
# Preserve raw text in rationale so prompt_override callers (e.g. pre_market_planner)
# can extract their own JSON format from decision.rationale (#245)
return TradeDecision( return TradeDecision(
action="HOLD", confidence=0, rationale="Missing required fields" action="HOLD", confidence=0, rationale=raw
) )
action = str(data["action"]).upper() action = str(data["action"]).upper()
@@ -439,6 +441,18 @@ class GeminiClient:
action="HOLD", confidence=0, rationale=f"API error: {exc}", token_count=token_count action="HOLD", confidence=0, rationale=f"API error: {exc}", token_count=token_count
) )
# prompt_override callers (e.g. pre_market_planner) expect raw text back,
# not a parsed TradeDecision. Skip parse_response to avoid spurious
# "Missing fields" warnings and return the raw response directly. (#247)
if "prompt_override" in market_data:
logger.info(
"Gemini raw response received (prompt_override, tokens=%d)", token_count
)
# Not a trade decision — don't inflate _total_decisions metrics
return TradeDecision(
action="HOLD", confidence=0, rationale=raw, token_count=token_count
)
decision = self.parse_response(raw) decision = self.parse_response(raw)
self._total_decisions += 1 self._total_decisions += 1

View File

@@ -179,8 +179,8 @@ class PromptOptimizer:
# Minimal instructions # Minimal instructions
prompt = ( prompt = (
f"{market_name} trader. Analyze:\n{data_str}\n\n" f"{market_name} trader. Analyze:\n{data_str}\n\n"
'Return JSON: {"act":"BUY"|"SELL"|"HOLD","conf":<0-100>,"reason":"<text>"}\n' 'Return JSON: {"action":"BUY"|"SELL"|"HOLD","confidence":<0-100>,"rationale":"<text>"}\n'
"Rules: act=BUY/SELL/HOLD, conf=0-100, reason=concise. No markdown." "Rules: action=BUY/SELL/HOLD, confidence=0-100, rationale=concise. No markdown."
) )
else: else:
# Data only (for cached contexts where instructions are known) # Data only (for cached contexts where instructions are known)

View File

@@ -8,7 +8,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import ssl import ssl
from typing import Any from typing import Any, cast
import aiohttp import aiohttp
@@ -430,7 +430,7 @@ class KISBroker:
"fid_cond_mrkt_div_code": "J", "fid_cond_mrkt_div_code": "J",
"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": "0000", "fid_rank_sort_cls_code": "0",
"fid_input_cnt_1": str(limit), "fid_input_cnt_1": str(limit),
"fid_prc_cls_code": "0", "fid_prc_cls_code": "0",
"fid_input_price_1": "0", "fid_input_price_1": "0",
@@ -466,7 +466,7 @@ class KISBroker:
rankings = [] rankings = []
for item in data.get("output", [])[:limit]: for item in data.get("output", [])[:limit]:
rankings.append({ rankings.append({
"stock_code": item.get("mksc_shrn_iscd", ""), "stock_code": item.get("stck_shrn_iscd") or item.get("mksc_shrn_iscd", ""),
"name": item.get("hts_kor_isnm", ""), "name": item.get("hts_kor_isnm", ""),
"price": _safe_float(item.get("stck_prpr", "0")), "price": _safe_float(item.get("stck_prpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")), "volume": _safe_float(item.get("acml_vol", "0")),
@@ -478,6 +478,112 @@ class KISBroker:
except (TimeoutError, aiohttp.ClientError) as exc: except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching rankings: {exc}") from exc raise ConnectionError(f"Network error fetching rankings: {exc}") from exc
async def get_domestic_pending_orders(self) -> list[dict[str, Any]]:
"""Fetch unfilled (pending) domestic limit orders.
The KIS pending-orders API (TTTC0084R) is unsupported in paper (VTS)
mode, so this method returns an empty list immediately when MODE is
not "live".
Returns:
List of pending order dicts from the KIS ``output`` field.
Each dict includes keys such as ``odno``, ``orgn_odno``,
``ord_gno_brno``, ``psbl_qty``, ``sll_buy_dvsn_cd``, ``pdno``.
"""
if self._settings.MODE != "live":
logger.debug(
"get_domestic_pending_orders: paper mode — TTTC0084R unsupported, returning []"
)
return []
await self._rate_limiter.acquire()
session = self._get_session()
# TR_ID: 실전 TTTC0084R (모의 미지원)
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '주식 미체결조회' 시트
headers = await self._auth_headers("TTTC0084R")
params = {
"CANO": self._account_no,
"ACNT_PRDT_CD": self._product_cd,
"INQR_DVSN_1": "0",
"INQR_DVSN_2": "0",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
}
url = f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl"
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_domestic_pending_orders failed ({resp.status}): {text}"
)
data = await resp.json()
return data.get("output", []) or []
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching domestic pending orders: {exc}"
) from exc
async def cancel_domestic_order(
self,
stock_code: str,
orgn_odno: str,
krx_fwdg_ord_orgno: str,
qty: int,
) -> dict[str, Any]:
"""Cancel an unfilled domestic limit order.
Args:
stock_code: 6-digit domestic stock code (``pdno``).
orgn_odno: Original order number from pending-orders response
(``orgn_odno`` field).
krx_fwdg_ord_orgno: KRX forwarding order branch number from
pending-orders response (``ord_gno_brno`` field).
qty: Quantity to cancel (use ``psbl_qty`` from pending order).
Returns:
Raw KIS API response dict (check ``rt_cd == "0"`` for success).
"""
await self._rate_limiter.acquire()
session = self._get_session()
# TR_ID: 실전 TTTC0013U, 모의 VTTC0013U
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '주식주문(정정취소)' 시트
tr_id = "TTTC0013U" if self._settings.MODE == "live" else "VTTC0013U"
body = {
"CANO": self._account_no,
"ACNT_PRDT_CD": self._product_cd,
"KRX_FWDG_ORD_ORGNO": krx_fwdg_ord_orgno,
"ORGN_ODNO": orgn_odno,
"ORD_DVSN": "00",
"ORD_QTY": str(qty),
"ORD_UNPR": "0",
"RVSE_CNCL_DVSN_CD": "02",
"QTY_ALL_ORD_YN": "Y",
}
hash_key = await self._get_hash_key(body)
headers = await self._auth_headers(tr_id)
headers["hashkey"] = hash_key
url = f"{self._base_url}/uapi/domestic-stock/v1/trading/order-rvsecncl"
try:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"cancel_domestic_order failed ({resp.status}): {text}"
)
return cast(dict[str, Any], await resp.json())
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error cancelling domestic order: {exc}"
) from exc
async def get_daily_prices( async def get_daily_prices(
self, self,
stock_code: str, stock_code: str,

View File

@@ -29,6 +29,20 @@ _RANKING_EXCHANGE_MAP: dict[str, str] = {
# NASD → NAS, NYSE → NYS, AMEX → AMS (confirmed: AMEX returns empty, AMS returns price). # NASD → NAS, NYSE → NYS, AMEX → AMS (confirmed: AMEX returns empty, AMS returns price).
_PRICE_EXCHANGE_MAP: dict[str, str] = _RANKING_EXCHANGE_MAP _PRICE_EXCHANGE_MAP: dict[str, str] = _RANKING_EXCHANGE_MAP
# Cancel order TR_IDs per exchange code — (live_tr_id, paper_tr_id).
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 주문취소' 시트
_CANCEL_TR_ID_MAP: dict[str, tuple[str, str]] = {
"NASD": ("TTTT1004U", "VTTT1004U"),
"NYSE": ("TTTT1004U", "VTTT1004U"),
"AMEX": ("TTTT1004U", "VTTT1004U"),
"SEHK": ("TTTS1003U", "VTTS1003U"),
"TSE": ("TTTS0309U", "VTTS0309U"),
"SHAA": ("TTTS0302U", "VTTS0302U"),
"SZAA": ("TTTS0306U", "VTTS0306U"),
"HNX": ("TTTS0312U", "VTTS0312U"),
"HSX": ("TTTS0312U", "VTTS0312U"),
}
class OverseasBroker: class OverseasBroker:
"""KIS Overseas Stock API wrapper that reuses KISBroker infrastructure.""" """KIS Overseas Stock API wrapper that reuses KISBroker infrastructure."""
@@ -119,7 +133,7 @@ class OverseasBroker:
"AUTH": "", "AUTH": "",
"EXCD": ranking_excd, "EXCD": ranking_excd,
"NDAY": "0", "NDAY": "0",
"GUBN": "1", "GUBN": "0", # 0=전체(상승+하락), 1=상승만 — 변동성 스캐너는 전체 필요
"VOL_RANG": "0", "VOL_RANG": "0",
} }
@@ -292,6 +306,131 @@ class OverseasBroker:
f"Network error sending overseas order: {exc}" f"Network error sending overseas order: {exc}"
) from exc ) from exc
async def get_overseas_pending_orders(
self, exchange_code: str
) -> list[dict[str, Any]]:
"""Fetch unfilled (pending) overseas orders for a given exchange.
Args:
exchange_code: Exchange code (e.g., "NASD", "SEHK").
For US markets, NASD returns all US pending orders (NASD/NYSE/AMEX).
Returns:
List of pending order dicts with fields: odno, pdno, sll_buy_dvsn_cd,
ft_ord_qty, nccs_qty, ft_ord_unpr3, ovrs_excg_cd.
Always returns [] in paper mode (TTTS3018R is live-only).
Raises:
ConnectionError: On network or API errors (live mode only).
"""
if self._broker._settings.MODE != "live":
logger.debug(
"Pending orders API (TTTS3018R) not supported in paper mode; returning []"
)
return []
await self._broker._rate_limiter.acquire()
session = self._broker._get_session()
# TTTS3018R: 해외주식 미체결내역조회 (실전 전용)
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 미체결조회' 시트
headers = await self._broker._auth_headers("TTTS3018R")
params = {
"CANO": self._broker._account_no,
"ACNT_PRDT_CD": self._broker._product_cd,
"OVRS_EXCG_CD": exchange_code,
"SORT_SQN": "DS",
"CTX_AREA_FK200": "",
"CTX_AREA_NK200": "",
}
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-nccs"
)
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_overseas_pending_orders failed ({resp.status}): {text}"
)
data = await resp.json()
output = data.get("output", [])
if isinstance(output, list):
return output
return []
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching pending orders: {exc}"
) from exc
async def cancel_overseas_order(
self,
exchange_code: str,
stock_code: str,
odno: str,
qty: int,
) -> dict[str, Any]:
"""Cancel an overseas limit order.
Args:
exchange_code: Exchange code (e.g., "NASD", "SEHK").
stock_code: Stock ticker symbol.
odno: Original order number to cancel.
qty: Unfilled quantity to cancel.
Returns:
API response dict containing rt_cd and msg1.
Raises:
ValueError: If exchange_code has no cancel TR_ID mapping.
ConnectionError: On network or API errors.
"""
tr_ids = _CANCEL_TR_ID_MAP.get(exchange_code)
if tr_ids is None:
raise ValueError(f"No cancel TR_ID mapping for exchange: {exchange_code}")
live_tr_id, paper_tr_id = tr_ids
tr_id = live_tr_id if self._broker._settings.MODE == "live" else paper_tr_id
await self._broker._rate_limiter.acquire()
session = self._broker._get_session()
# RVSE_CNCL_DVSN_CD="02" means cancel (not revision).
# OVRS_ORD_UNPR must be "0" for cancellations.
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 정정취소주문' 시트
body = {
"CANO": self._broker._account_no,
"ACNT_PRDT_CD": self._broker._product_cd,
"OVRS_EXCG_CD": exchange_code,
"PDNO": stock_code,
"ORGN_ODNO": odno,
"RVSE_CNCL_DVSN_CD": "02",
"ORD_QTY": str(qty),
"OVRS_ORD_UNPR": "0",
"ORD_SVR_DVSN_CD": "0",
}
hash_key = await self._broker._get_hash_key(body)
headers = await self._broker._auth_headers(tr_id)
headers["hashkey"] = hash_key
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/order-rvsecncl"
)
try:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"cancel_overseas_order failed ({resp.status}): {text}"
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error cancelling overseas order: {exc}"
) from exc
def _get_currency_code(self, exchange_code: str) -> str: def _get_currency_code(self, exchange_code: str) -> str:
""" """
Map exchange code to currency code. Map exchange code to currency code.

View File

@@ -13,10 +13,11 @@ from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
def create_dashboard_app(db_path: str) -> FastAPI: def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI:
"""Create dashboard FastAPI app bound to a SQLite database path.""" """Create dashboard FastAPI app bound to a SQLite database path."""
app = FastAPI(title="The Ouroboros Dashboard", version="1.0.0") app = FastAPI(title="The Ouroboros Dashboard", version="1.0.0")
app.state.db_path = db_path app.state.db_path = db_path
app.state.mode = mode
@app.get("/") @app.get("/")
def index() -> FileResponse: def index() -> FileResponse:
@@ -111,6 +112,7 @@ def create_dashboard_app(db_path: str) -> FastAPI:
return { return {
"date": today, "date": today,
"mode": mode,
"markets": market_status, "markets": market_status,
"totals": { "totals": {
"trade_count": total_trades, "trade_count": total_trades,

View File

@@ -43,6 +43,19 @@
font-size: 12px; transition: border-color 0.2s; font-size: 12px; transition: border-color 0.2s;
} }
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); } .refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
.mode-badge {
padding: 3px 10px; border-radius: 5px; font-size: 12px; font-weight: 700;
letter-spacing: 0.5px;
}
.mode-badge.live {
background: rgba(224, 85, 85, 0.15); color: var(--red);
border: 1px solid rgba(224, 85, 85, 0.4);
animation: pulse-warn 2s ease-in-out infinite;
}
.mode-badge.paper {
background: rgba(232, 160, 64, 0.15); color: var(--warn);
border: 1px solid rgba(232, 160, 64, 0.4);
}
/* CB Gauge */ /* CB Gauge */
.cb-gauge-wrap { .cb-gauge-wrap {
@@ -225,6 +238,7 @@
<header> <header>
<h1>&#x1F40D; The Ouroboros</h1> <h1>&#x1F40D; The Ouroboros</h1>
<div class="header-right"> <div class="header-right">
<span class="mode-badge" id="mode-badge">--</span>
<div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker"> <div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker">
<span class="cb-dot unknown" id="cb-dot"></span> <span class="cb-dot unknown" id="cb-dot"></span>
<span id="cb-label">CB --</span> <span id="cb-label">CB --</span>
@@ -512,9 +526,22 @@
} }
document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}`; document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}`;
renderCbGauge(d.circuit_breaker); renderCbGauge(d.circuit_breaker);
renderModeBadge(d.mode);
} catch {} } catch {}
} }
function renderModeBadge(mode) {
const el = document.getElementById('mode-badge');
if (!el) return;
if (mode === 'live') {
el.textContent = '🔴 실전투자';
el.className = 'mode-badge live';
} else {
el.textContent = '🟡 모의투자';
el.className = 'mode-badge paper';
}
}
async function fetchPerformance() { async function fetchPerformance() {
try { try {
const r = await fetch('/api/performance?market=all'); const r = await fetch('/api/performance?market=all');

View File

@@ -19,7 +19,7 @@ from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
from src.analysis.volatility import VolatilityAnalyzer from src.analysis.volatility import VolatilityAnalyzer
from src.brain.context_selector import ContextSelector from src.brain.context_selector import ContextSelector
from src.brain.gemini_client import GeminiClient, TradeDecision from src.brain.gemini_client import GeminiClient, TradeDecision
from src.broker.kis_api import KISBroker from src.broker.kis_api import KISBroker, kr_round_down
from src.broker.overseas import OverseasBroker from src.broker.overseas import OverseasBroker
from src.config import Settings from src.config import Settings
from src.context.aggregator import ContextAggregator from src.context.aggregator import ContextAggregator
@@ -257,7 +257,15 @@ def _extract_held_codes_from_balance(
if is_domestic: if is_domestic:
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0) qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
else: else:
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0) # ord_psbl_qty (주문가능수량) is the actual sellable quantity.
# ovrs_cblc_qty (해외잔고수량) includes unsettled/expired holdings
# that cannot actually be sold (e.g. expired warrants).
qty = int(
holding.get("ord_psbl_qty")
or holding.get("ovrs_cblc_qty")
or holding.get("hldg_qty")
or 0
)
if qty > 0: if qty > 0:
codes.append(code) codes.append(code)
return codes return codes
@@ -280,10 +288,12 @@ def _extract_held_qty_from_balance(
ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled) ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled)
hldg_qty — 보유수량 (fallback) hldg_qty — 보유수량 (fallback)
Overseas fields (output1): Overseas fields (VTTS3012R / TTTS3012R output1):
ovrs_pdno — 종목코드 ovrs_pdno — 종목코드
ovrs_cblc_qty — 해외잔고수량 (preferred) ord_psbl_qty 주문가능수량 (preferred: actual sellable qty)
hldg_qty — 보유수량 (fallback) ovrs_cblc_qty — 해외잔고수량 (fallback: total holding, may include
unsettled or expired positions with ord_psbl_qty=0)
hldg_qty — 보유수량 (last-resort fallback)
""" """
output1 = balance_data.get("output1", []) output1 = balance_data.get("output1", [])
if isinstance(output1, dict): if isinstance(output1, dict):
@@ -301,7 +311,12 @@ def _extract_held_qty_from_balance(
if is_domestic: if is_domestic:
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0) qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
else: else:
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0) qty = int(
holding.get("ord_psbl_qty")
or holding.get("ovrs_cblc_qty")
or holding.get("hldg_qty")
or 0
)
return qty return qty
return 0 return 0
@@ -853,28 +868,39 @@ async def trading_cycle(
# 5. Send order # 5. Send order
order_succeeded = True order_succeeded = True
if market.is_domestic: if market.is_domestic:
# Use limit orders (지정가) for domestic stocks to avoid market order
# quantity calculation issues. KRX tick rounding applied via kr_round_down.
# BUY: +0.2% — ensures fill even when ask is slightly above last price.
# SELL: -0.2% — ensures fill even when bid is slightly below last price.
if decision.action == "BUY":
order_price = kr_round_down(current_price * 1.002)
else:
order_price = kr_round_down(current_price * 0.998)
result = await broker.send_order( result = await broker.send_order(
stock_code=stock_code, stock_code=stock_code,
order_type=decision.action, order_type=decision.action,
quantity=quantity, quantity=quantity,
price=0, # market order price=order_price,
) )
else: else:
# For overseas orders: # For overseas orders, always use limit orders (지정가):
# - KIS VTS only accepts limit orders (지정가만 가능) # - KIS market orders (ORD_DVSN=01) calculate quantity based on upper limit
# - BUY: use 0.5% premium over last price to improve fill probability # price (상한가 기준), resulting in only 60-80% of intended cash being used.
# (ask price is typically slightly above last, and VTS won't fill below ask) # - BUY: +0.2% above last price — tight enough to minimise overpayment while
# - SELL: use last price as the limit # achieving >90% fill rate on large-cap US stocks.
# - SELL: -0.2% below last price — ensures fill even when price dips slightly
# (placing at exact last price risks no-fill if the bid is just below).
overseas_price: float
if decision.action == "BUY": if decision.action == "BUY":
order_price = round(current_price * 1.005, 4) overseas_price = round(current_price * 1.002, 4)
else: else:
order_price = current_price overseas_price = round(current_price * 0.998, 4)
result = await overseas_broker.send_overseas_order( result = await overseas_broker.send_overseas_order(
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
stock_code=stock_code, stock_code=stock_code,
order_type=decision.action, order_type=decision.action,
quantity=quantity, quantity=quantity,
price=order_price, # limit order — KIS VTS rejects market orders price=overseas_price, # limit order
) )
# Check if KIS rejected the order (rt_cd != "0") # Check if KIS rejected the order (rt_cd != "0")
if result.get("rt_cd", "") != "0": if result.get("rt_cd", "") != "0":
@@ -897,6 +923,33 @@ async def trading_cycle(
stock_code, stock_code,
_BUY_COOLDOWN_SECONDS, _BUY_COOLDOWN_SECONDS,
) )
# Close ghost position when broker has no matching balance.
# This prevents infinite SELL retry cycles for positions that
# exist in the DB (from startup sync) but are no longer
# sellable at the broker (expired warrants, delisted stocks, etc.)
if decision.action == "SELL" and "잔고내역이 없습니다" in msg1:
logger.warning(
"Ghost position detected for %s (%s): broker reports no balance."
" Closing DB position to prevent infinite retry.",
stock_code,
market.exchange_code,
)
log_trade(
conn=db_conn,
stock_code=stock_code,
action="SELL",
confidence=0,
rationale=(
"[ghost-close] Broker reported no balance;"
" position closed without fill"
),
quantity=0,
price=0.0,
pnl=0.0,
market=market.code,
exchange_code=market.exchange_code,
mode=settings.MODE if settings else "paper",
)
logger.info("Order result: %s", result.get("msg1", "OK")) logger.info("Order result: %s", result.get("msg1", "OK"))
# 5.5. Notify trade execution (only on success) # 5.5. Notify trade execution (only on success)
@@ -976,6 +1029,328 @@ async def trading_cycle(
) )
async def handle_domestic_pending_orders(
broker: KISBroker,
telegram: TelegramClient,
settings: Settings,
sell_resubmit_counts: dict[str, int],
buy_cooldown: dict[str, float] | None = None,
) -> None:
"""Check and handle unfilled (pending) domestic limit orders.
Called once per market loop iteration before new orders are considered.
In paper mode the KIS pending-orders API (TTTC0084R) is unsupported, so
``get_domestic_pending_orders`` returns [] immediately and this function
exits without making further API calls.
BUY pending → cancel (to free up balance) + optionally set cooldown.
SELL pending → cancel then resubmit at a wider spread (-0.4% from last
price, kr_round_down applied). Resubmission is attempted
at most once per key per session to avoid infinite loops.
Args:
broker: KISBroker instance.
telegram: TelegramClient for notifications.
settings: Application settings.
sell_resubmit_counts: Mutable dict tracking SELL resubmission attempts
per "KR:{stock_code}" key. Passed by reference so counts persist
across calls within the same session.
buy_cooldown: Optional cooldown dict shared with the main trading loop.
When provided, cancelled BUY orders are added with a
_BUY_COOLDOWN_SECONDS expiry.
"""
try:
orders = await broker.get_domestic_pending_orders()
except Exception as exc:
logger.warning("Failed to fetch domestic pending orders: %s", exc)
return
now = asyncio.get_event_loop().time()
for order in orders:
try:
stock_code = order.get("pdno", "")
orgn_odno = order.get("orgn_odno", "")
krx_fwdg_ord_orgno = order.get("ord_gno_brno", "")
sll_buy = order.get("sll_buy_dvsn_cd", "") # "01"=SELL, "02"=BUY
psbl_qty = int(order.get("psbl_qty", "0") or "0")
key = f"KR:{stock_code}"
if not stock_code or not orgn_odno or psbl_qty <= 0:
continue
# Cancel the pending order first regardless of direction.
cancel_result = await broker.cancel_domestic_order(
stock_code=stock_code,
orgn_odno=orgn_odno,
krx_fwdg_ord_orgno=krx_fwdg_ord_orgno,
qty=psbl_qty,
)
if cancel_result.get("rt_cd") != "0":
logger.warning(
"Cancel failed for KR %s: rt_cd=%s msg=%s",
stock_code,
cancel_result.get("rt_cd"),
cancel_result.get("msg1"),
)
continue
if sll_buy == "02":
# BUY pending → cancelled; set cooldown to avoid immediate re-buy.
if buy_cooldown is not None:
buy_cooldown[key] = now + _BUY_COOLDOWN_SECONDS
try:
await telegram.notify_unfilled_order(
stock_code=stock_code,
market="KR",
action="BUY",
quantity=psbl_qty,
outcome="cancelled",
)
except Exception as notify_exc:
logger.warning("notify_unfilled_order failed: %s", notify_exc)
elif sll_buy == "01":
# SELL pending — attempt one resubmit at a wider spread.
if sell_resubmit_counts.get(key, 0) >= 1:
# Already resubmitted once — only cancel (already done above).
logger.warning(
"SELL KR %s already resubmitted once — no further resubmit",
stock_code,
)
try:
await telegram.notify_unfilled_order(
stock_code=stock_code,
market="KR",
action="SELL",
quantity=psbl_qty,
outcome="cancelled",
)
except Exception as notify_exc:
logger.warning(
"notify_unfilled_order failed: %s", notify_exc
)
else:
# First unfilled SELL → resubmit at last * 0.996 (-0.4%).
try:
last_price, _, _ = await broker.get_current_price(stock_code)
if last_price <= 0:
raise ValueError(
f"Invalid price ({last_price}) for {stock_code}"
)
new_price = kr_round_down(last_price * 0.996)
await broker.send_order(
stock_code=stock_code,
order_type="SELL",
quantity=psbl_qty,
price=new_price,
)
sell_resubmit_counts[key] = (
sell_resubmit_counts.get(key, 0) + 1
)
try:
await telegram.notify_unfilled_order(
stock_code=stock_code,
market="KR",
action="SELL",
quantity=psbl_qty,
outcome="resubmitted",
new_price=float(new_price),
)
except Exception as notify_exc:
logger.warning(
"notify_unfilled_order failed: %s", notify_exc
)
except Exception as exc:
logger.error(
"SELL resubmit failed for KR %s: %s",
stock_code,
exc,
)
except Exception as exc:
logger.error(
"Error handling domestic pending order for %s: %s",
order.get("pdno", "?"),
exc,
)
async def handle_overseas_pending_orders(
overseas_broker: OverseasBroker,
telegram: TelegramClient,
settings: Settings,
sell_resubmit_counts: dict[str, int],
buy_cooldown: dict[str, float] | None = None,
) -> None:
"""Check and handle unfilled (pending) overseas limit orders.
Called once per market loop iteration before new orders are considered.
In paper mode the KIS pending-orders API (TTTS3018R) is unsupported, so
this function returns immediately without making any API calls.
BUY pending → cancel (to free up balance) + optionally set cooldown.
SELL pending → cancel then resubmit at a wider spread (-0.4% from last
price). Resubmission is attempted at most once per key
per session to avoid infinite retry loops.
Args:
overseas_broker: OverseasBroker instance.
telegram: TelegramClient for notifications.
settings: Application settings (MODE, ENABLED_MARKETS).
sell_resubmit_counts: Mutable dict tracking SELL resubmission attempts
per "{exchange_code}:{stock_code}" key. Passed by reference so
counts persist across calls within the same session.
buy_cooldown: Optional cooldown dict shared with the main trading loop.
When provided, cancelled BUY orders are added with a
_BUY_COOLDOWN_SECONDS expiry.
"""
# Determine which exchange codes to query, deduplicating US exchanges.
# NASD alone returns all US (NASD/NYSE/AMEX) pending orders.
us_exchanges = frozenset({"NASD", "NYSE", "AMEX"})
exchange_codes: list[str] = []
seen_us = False
for market_code in settings.enabled_market_list:
market_info = MARKETS.get(market_code)
if market_info is None or market_info.is_domestic:
continue
exc_code = market_info.exchange_code
if exc_code in us_exchanges:
if not seen_us:
exchange_codes.append("NASD")
seen_us = True
elif exc_code not in exchange_codes:
exchange_codes.append(exc_code)
now = asyncio.get_event_loop().time()
for exchange_code in exchange_codes:
try:
orders = await overseas_broker.get_overseas_pending_orders(exchange_code)
except Exception as exc:
logger.warning(
"Failed to fetch pending orders for %s: %s", exchange_code, exc
)
continue
for order in orders:
try:
stock_code = order.get("pdno", "")
odno = order.get("odno", "")
sll_buy = order.get("sll_buy_dvsn_cd", "") # "01"=SELL, "02"=BUY
nccs_qty = int(order.get("nccs_qty", "0") or "0")
order_exchange = order.get("ovrs_excg_cd") or exchange_code
key = f"{order_exchange}:{stock_code}"
if not stock_code or not odno or nccs_qty <= 0:
continue
# Cancel the pending order first regardless of direction.
cancel_result = await overseas_broker.cancel_overseas_order(
exchange_code=order_exchange,
stock_code=stock_code,
odno=odno,
qty=nccs_qty,
)
if cancel_result.get("rt_cd") != "0":
logger.warning(
"Cancel failed for %s %s: rt_cd=%s msg=%s",
order_exchange,
stock_code,
cancel_result.get("rt_cd"),
cancel_result.get("msg1"),
)
continue
if sll_buy == "02":
# BUY pending → cancelled; set cooldown to avoid immediate re-buy.
if buy_cooldown is not None:
buy_cooldown[key] = now + _BUY_COOLDOWN_SECONDS
try:
await telegram.notify_unfilled_order(
stock_code=stock_code,
market=order_exchange,
action="BUY",
quantity=nccs_qty,
outcome="cancelled",
)
except Exception as notify_exc:
logger.warning("notify_unfilled_order failed: %s", notify_exc)
elif sll_buy == "01":
# SELL pending — attempt one resubmit at a wider spread.
if sell_resubmit_counts.get(key, 0) >= 1:
# Already resubmitted once — only cancel (already done above).
logger.warning(
"SELL %s %s already resubmitted once — no further resubmit",
order_exchange,
stock_code,
)
try:
await telegram.notify_unfilled_order(
stock_code=stock_code,
market=order_exchange,
action="SELL",
quantity=nccs_qty,
outcome="cancelled",
)
except Exception as notify_exc:
logger.warning(
"notify_unfilled_order failed: %s", notify_exc
)
else:
# First unfilled SELL → resubmit at last * 0.996 (-0.4%).
try:
price_data = await overseas_broker.get_overseas_price(
order_exchange, stock_code
)
last_price = float(
price_data.get("output", {}).get("last", "0") or "0"
)
if last_price <= 0:
raise ValueError(
f"Invalid price ({last_price}) for {stock_code}"
)
new_price = round(last_price * 0.996, 4)
await overseas_broker.send_overseas_order(
exchange_code=order_exchange,
stock_code=stock_code,
order_type="SELL",
quantity=nccs_qty,
price=new_price,
)
sell_resubmit_counts[key] = (
sell_resubmit_counts.get(key, 0) + 1
)
try:
await telegram.notify_unfilled_order(
stock_code=stock_code,
market=order_exchange,
action="SELL",
quantity=nccs_qty,
outcome="resubmitted",
new_price=new_price,
)
except Exception as notify_exc:
logger.warning(
"notify_unfilled_order failed: %s", notify_exc
)
except Exception as exc:
logger.error(
"SELL resubmit failed for %s %s: %s",
order_exchange,
stock_code,
exc,
)
except Exception as exc:
logger.error(
"Error handling pending order for %s: %s",
order.get("pdno", "?"),
exc,
)
async def run_daily_session( async def run_daily_session(
broker: KISBroker, broker: KISBroker,
overseas_broker: OverseasBroker, overseas_broker: OverseasBroker,
@@ -1020,11 +1395,40 @@ async def run_daily_session(
# BUY cooldown: prevents retrying stocks rejected for insufficient balance # BUY cooldown: prevents retrying stocks rejected for insufficient balance
daily_buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp daily_buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp
# Tracks SELL resubmission attempts per "{exchange_code}:{stock_code}" (max 1 per session).
sell_resubmit_counts: dict[str, int] = {}
# Process each open market # Process each open market
for market in open_markets: for market in open_markets:
# Use market-local date for playbook keying # Use market-local date for playbook keying
market_today = datetime.now(market.timezone).date() market_today = datetime.now(market.timezone).date()
# Check and handle domestic pending (unfilled) limit orders before new decisions.
if market.is_domestic:
try:
await handle_domestic_pending_orders(
broker,
telegram,
settings,
sell_resubmit_counts,
daily_buy_cooldown,
)
except Exception as exc:
logger.warning("Domestic pending order check failed: %s", exc)
# Check and handle overseas pending (unfilled) limit orders before new decisions.
if not market.is_domestic:
try:
await handle_overseas_pending_orders(
overseas_broker,
telegram,
settings,
sell_resubmit_counts,
daily_buy_cooldown,
)
except Exception as exc:
logger.warning("Pending order check failed: %s", exc)
# Dynamic stock discovery via scanner (no static watchlists) # Dynamic stock discovery via scanner (no static watchlists)
candidates_list: list[ScanCandidate] = [] candidates_list: list[ScanCandidate] = []
fallback_stocks: list[str] | None = None fallback_stocks: list[str] | None = None
@@ -1414,11 +1818,21 @@ async def run_daily_session(
order_succeeded = True order_succeeded = True
try: try:
if market.is_domestic: if market.is_domestic:
# Use limit orders (지정가) for domestic stocks.
# KRX tick rounding applied via kr_round_down.
if decision.action == "BUY":
order_price = kr_round_down(
stock_data["current_price"] * 1.002
)
else:
order_price = kr_round_down(
stock_data["current_price"] * 0.998
)
result = await broker.send_order( result = await broker.send_order(
stock_code=stock_code, stock_code=stock_code,
order_type=decision.action, order_type=decision.action,
quantity=quantity, quantity=quantity,
price=0, # market order price=order_price,
) )
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
@@ -1631,7 +2045,7 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
import uvicorn import uvicorn
from src.dashboard import create_dashboard_app from src.dashboard import create_dashboard_app
app = create_dashboard_app(settings.DB_PATH) app = create_dashboard_app(settings.DB_PATH, mode=settings.MODE)
uvicorn.run( uvicorn.run(
app, app,
host=settings.DASHBOARD_HOST, host=settings.DASHBOARD_HOST,
@@ -2083,6 +2497,9 @@ async def run(settings: Settings) -> None:
# BUY cooldown: prevents retrying a stock rejected for insufficient balance # BUY cooldown: prevents retrying a stock rejected for insufficient balance
buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp
# Tracks SELL resubmission attempts per "{exchange_code}:{stock_code}" (max 1 until restart).
sell_resubmit_counts: dict[str, int] = {}
# Initialize latency control system # Initialize latency control system
criticality_assessor = CriticalityAssessor( criticality_assessor = CriticalityAssessor(
critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0% critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0%
@@ -2268,6 +2685,32 @@ async def run(settings: Settings) -> None:
logger.warning("Market open notification failed: %s", exc) logger.warning("Market open notification failed: %s", exc)
_market_states[market.code] = True _market_states[market.code] = True
# Check and handle domestic pending (unfilled) limit orders.
if market.is_domestic:
try:
await handle_domestic_pending_orders(
broker,
telegram,
settings,
sell_resubmit_counts,
buy_cooldown,
)
except Exception as exc:
logger.warning("Domestic pending order check failed: %s", exc)
# Check and handle overseas pending (unfilled) limit orders.
if not market.is_domestic:
try:
await handle_overseas_pending_orders(
overseas_broker,
telegram,
settings,
sell_resubmit_counts,
buy_cooldown,
)
except Exception as exc:
logger.warning("Pending order check failed: %s", exc)
# Smart Scanner: dynamic stock discovery (no static watchlists) # Smart Scanner: dynamic stock discovery (no static watchlists)
now_timestamp = asyncio.get_event_loop().time() now_timestamp = asyncio.get_event_loop().time()
last_scan = last_scan_time.get(market.code, 0.0) last_scan = last_scan_time.get(market.code, 0.0)

View File

@@ -473,6 +473,48 @@ class TelegramClient:
NotificationMessage(priority=priority, message=message) NotificationMessage(priority=priority, message=message)
) )
async def notify_unfilled_order(
self,
stock_code: str,
market: str,
action: str,
quantity: int,
outcome: str,
new_price: float | None = None,
) -> None:
"""Notify about an unfilled overseas order that was cancelled or resubmitted.
Args:
stock_code: Stock ticker symbol.
market: Exchange/market code (e.g., "NASD", "SEHK").
action: "BUY" or "SELL".
quantity: Unfilled quantity.
outcome: "cancelled" or "resubmitted".
new_price: New order price if resubmitted (None if only cancelled).
"""
if not self._filter.trades:
return
# SELL resubmit is high priority — position liquidation at risk.
# BUY cancel is medium priority — only cash is freed.
priority = (
NotificationPriority.HIGH
if action == "SELL"
else NotificationPriority.MEDIUM
)
outcome_emoji = "🔄" if outcome == "resubmitted" else ""
outcome_label = "재주문" if outcome == "resubmitted" else "취소됨"
action_emoji = "🔴" if action == "SELL" else "🟢"
lines = [
f"<b>{outcome_emoji} 미체결 주문 {outcome_label}</b>",
f"Symbol: <code>{stock_code}</code> ({market})",
f"Action: {action_emoji} {action}",
f"Quantity: {quantity:,} shares",
]
if new_price is not None:
lines.append(f"New Price: {new_price:.4f}")
message = "\n".join(lines)
await self._send_notification(NotificationMessage(priority=priority, message=message))
async def notify_error( async def notify_error(
self, error_type: str, error_msg: str, context: str self, error_type: str, error_msg: str, context: str
) -> None: ) -> None:

View File

@@ -93,9 +93,21 @@ class TestMalformedJsonHandling:
def test_json_with_missing_fields_returns_hold(self, settings): def test_json_with_missing_fields_returns_hold(self, settings):
client = GeminiClient(settings) client = GeminiClient(settings)
decision = client.parse_response('{"action": "BUY"}') raw = '{"action": "BUY"}'
decision = client.parse_response(raw)
assert decision.action == "HOLD" assert decision.action == "HOLD"
assert decision.confidence == 0 assert decision.confidence == 0
# rationale preserves raw so prompt_override callers (e.g. pre_market_planner)
# can extract non-TradeDecision JSON from decision.rationale (#245)
assert decision.rationale == raw
def test_non_trade_decision_json_preserves_raw_in_rationale(self, settings):
"""Playbook JSON (no action/confidence/rationale) must be preserved for planner."""
client = GeminiClient(settings)
playbook_json = '{"market_outlook": "neutral", "stocks": []}'
decision = client.parse_response(playbook_json)
assert decision.action == "HOLD"
assert decision.rationale == playbook_json
def test_json_with_invalid_action_returns_hold(self, settings): def test_json_with_invalid_action_returns_hold(self, settings):
client = GeminiClient(settings) client = GeminiClient(settings)
@@ -290,9 +302,10 @@ class TestPromptOverride:
client = GeminiClient(settings) client = GeminiClient(settings)
custom_prompt = "You are a playbook generator. Return JSON with scenarios." custom_prompt = "You are a playbook generator. Return JSON with scenarios."
playbook_json = '{"market_outlook": "neutral", "stocks": []}'
mock_response = MagicMock() mock_response = MagicMock()
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "test"}' mock_response.text = playbook_json
with patch.object( with patch.object(
client._client.aio.models, client._client.aio.models,
@@ -305,7 +318,7 @@ class TestPromptOverride:
"current_price": 0, "current_price": 0,
"prompt_override": custom_prompt, "prompt_override": custom_prompt,
} }
await client.decide(market_data) decision = await client.decide(market_data)
# Verify the custom prompt was sent, not a built prompt # Verify the custom prompt was sent, not a built prompt
mock_generate.assert_called_once() mock_generate.assert_called_once()
@@ -313,17 +326,50 @@ class TestPromptOverride:
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None "contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
) )
assert actual_prompt == custom_prompt assert actual_prompt == custom_prompt
# Raw response preserved in rationale without parse_response (#247)
assert decision.rationale == playbook_json
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prompt_override_skips_optimization(self, settings): async def test_prompt_override_skips_parse_response(self, settings):
"""prompt_override should bypass prompt optimization.""" """prompt_override bypasses parse_response — no Missing fields warning, raw preserved."""
client = GeminiClient(settings) client = GeminiClient(settings)
client._enable_optimization = True client._enable_optimization = True
custom_prompt = "Custom playbook prompt" custom_prompt = "Custom playbook prompt"
playbook_json = '{"market_outlook": "bullish", "stocks": [{"stock_code": "AAPL"}]}'
mock_response = MagicMock() mock_response = MagicMock()
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "ok"}' mock_response.text = playbook_json
with patch.object(
client._client.aio.models,
"generate_content",
new_callable=AsyncMock,
return_value=mock_response,
):
with patch.object(client, "parse_response") as mock_parse:
market_data = {
"stock_code": "PLANNER",
"current_price": 0,
"prompt_override": custom_prompt,
}
decision = await client.decide(market_data)
# parse_response must NOT be called for prompt_override
mock_parse.assert_not_called()
# Raw playbook JSON preserved in rationale
assert decision.rationale == playbook_json
@pytest.mark.asyncio
async def test_prompt_override_takes_priority_over_optimization(self, settings):
"""prompt_override must win over enable_optimization=True."""
client = GeminiClient(settings)
client._enable_optimization = True
custom_prompt = "Explicit playbook prompt"
mock_response = MagicMock()
mock_response.text = '{"market_outlook": "neutral", "stocks": []}'
with patch.object( with patch.object(
client._client.aio.models, client._client.aio.models,
@@ -341,6 +387,7 @@ class TestPromptOverride:
actual_prompt = mock_generate.call_args[1].get( actual_prompt = mock_generate.call_args[1].get(
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None "contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
) )
# The custom prompt must be used, not the compressed prompt
assert actual_prompt == custom_prompt assert actual_prompt == custom_prompt
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -354,6 +354,8 @@ class TestFetchMarketRankings:
assert "ranking/fluctuation" in url assert "ranking/fluctuation" in url
assert headers.get("tr_id") == "FHPST01700000" assert headers.get("tr_id") == "FHPST01700000"
assert params.get("fid_cond_scr_div_code") == "20170" assert params.get("fid_cond_scr_div_code") == "20170"
# 실전 API는 4자리("0000") 거부 — 1자리("0")여야 한다 (#240)
assert params.get("fid_rank_sort_cls_code") == "0"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None: async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None:
@@ -376,6 +378,27 @@ class TestFetchMarketRankings:
assert result[0]["price"] == 75000.0 assert result[0]["price"] == 75000.0
assert result[0]["change_rate"] == 2.5 assert result[0]["change_rate"] == 2.5
@pytest.mark.asyncio
async def test_fluctuation_parses_stck_shrn_iscd(self, broker: KISBroker) -> None:
"""실전 API는 mksc_shrn_iscd 대신 stck_shrn_iscd를 반환한다 (#240)."""
items = [
{
"stck_shrn_iscd": "015260",
"hts_kor_isnm": "에이엔피",
"stck_prpr": "794",
"acml_vol": "4896196",
"prdy_ctrt": "29.74",
"vol_inrt": "0",
}
]
mock_resp = _make_ranking_mock(items)
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
result = await broker.fetch_market_rankings(ranking_type="fluctuation")
assert len(result) == 1
assert result[0]["stock_code"] == "015260"
assert result[0]["change_rate"] == 29.74
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# KRX tick unit / round-down helpers (issue #157) # KRX tick unit / round-down helpers (issue #157)
@@ -725,3 +748,195 @@ class TestTRIDBranchingDomestic:
order_headers = mock_post.call_args_list[1][1].get("headers", {}) order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "TTTC0011U" assert order_headers["tr_id"] == "TTTC0011U"
# ---------------------------------------------------------------------------
# Domestic Pending Orders (get_domestic_pending_orders)
# ---------------------------------------------------------------------------
class TestGetDomesticPendingOrders:
"""get_domestic_pending_orders must return [] in paper mode and call TTTC0084R in live."""
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_paper_mode_returns_empty(self, settings) -> None:
"""Paper mode must return [] immediately without any API call."""
broker = self._make_broker(settings, "paper")
with patch("aiohttp.ClientSession.get") as mock_get:
result = await broker.get_domestic_pending_orders()
assert result == []
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_live_mode_calls_tttc0084r_with_correct_params(
self, settings
) -> None:
"""Live mode must call TTTC0084R with INQR_DVSN_1/2 and paging params."""
broker = self._make_broker(settings, "live")
pending = [{"odno": "001", "pdno": "005930", "psbl_qty": "10"}]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": pending})
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:
result = await broker.get_domestic_pending_orders()
assert result == pending
headers = mock_get.call_args[1].get("headers", {})
assert headers["tr_id"] == "TTTC0084R"
params = mock_get.call_args[1].get("params", {})
assert params["INQR_DVSN_1"] == "0"
assert params["INQR_DVSN_2"] == "0"
@pytest.mark.asyncio
async def test_live_mode_connection_error(self, settings) -> None:
"""Network error must raise ConnectionError."""
import aiohttp as _aiohttp
broker = self._make_broker(settings, "live")
with patch(
"aiohttp.ClientSession.get",
side_effect=_aiohttp.ClientError("timeout"),
):
with pytest.raises(ConnectionError):
await broker.get_domestic_pending_orders()
# ---------------------------------------------------------------------------
# Domestic Order Cancellation (cancel_domestic_order)
# ---------------------------------------------------------------------------
class TestCancelDomesticOrder:
"""cancel_domestic_order must use correct TR_ID and build body correctly."""
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
def _make_post_mocks(self, order_payload: dict) -> tuple:
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=order_payload)
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
return mock_hash, mock_order
@pytest.mark.asyncio
async def test_live_uses_tttc0013u(self, settings) -> None:
"""Live mode must use TR_ID TTTC0013U."""
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "TTTC0013U"
@pytest.mark.asyncio
async def test_paper_uses_vttc0013u(self, settings) -> None:
"""Paper mode must use TR_ID VTTC0013U."""
broker = self._make_broker(settings, "paper")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "VTTC0013U"
@pytest.mark.asyncio
async def test_cancel_sets_rvse_cncl_dvsn_cd_02(self, settings) -> None:
"""Body must have RVSE_CNCL_DVSN_CD='02' (취소) and QTY_ALL_ORD_YN='Y'."""
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5)
body = mock_post.call_args_list[1][1].get("json", {})
assert body["RVSE_CNCL_DVSN_CD"] == "02"
assert body["QTY_ALL_ORD_YN"] == "Y"
assert body["ORD_UNPR"] == "0"
@pytest.mark.asyncio
async def test_cancel_sets_krx_fwdg_ord_orgno_in_body(self, settings) -> None:
"""Body must include KRX_FWDG_ORD_ORGNO and ORGN_ODNO from arguments."""
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD123", "BRN456", 3)
body = mock_post.call_args_list[1][1].get("json", {})
assert body["KRX_FWDG_ORD_ORGNO"] == "BRN456"
assert body["ORGN_ODNO"] == "ORD123"
assert body["ORD_QTY"] == "3"
@pytest.mark.asyncio
async def test_cancel_sets_hashkey_header(self, settings) -> None:
"""Request must include hashkey header (same pattern as send_order)."""
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 2)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert "hashkey" in order_headers
assert order_headers["hashkey"] == "h"

View File

@@ -413,3 +413,39 @@ def test_status_circuit_breaker_unknown_when_no_data(tmp_path: Path) -> None:
cb = body["circuit_breaker"] cb = body["circuit_breaker"]
assert cb["status"] == "unknown" assert cb["status"] == "unknown"
assert cb["current_pnl_pct"] is None assert cb["current_pnl_pct"] is None
def test_status_mode_paper(tmp_path: Path) -> None:
"""mode=paper로 생성하면 status 응답에 mode=paper가 포함돼야 한다."""
db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path))
_seed_db(conn)
conn.close()
app = create_dashboard_app(str(db_path), mode="paper")
get_status = _endpoint(app, "/api/status")
body = get_status()
assert body["mode"] == "paper"
def test_status_mode_live(tmp_path: Path) -> None:
"""mode=live로 생성하면 status 응답에 mode=live가 포함돼야 한다."""
db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path))
_seed_db(conn)
conn.close()
app = create_dashboard_app(str(db_path), mode="live")
get_status = _endpoint(app, "/api/status")
body = get_status()
assert body["mode"] == "live"
def test_status_mode_default_paper(tmp_path: Path) -> None:
"""mode 파라미터 미전달 시 기본값은 paper여야 한다."""
db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path))
_seed_db(conn)
conn.close()
app = create_dashboard_app(str(db_path))
get_status = _endpoint(app, "/api/status")
body = get_status()
assert body["mode"] == "paper"

View File

@@ -22,6 +22,8 @@ from src.main import (
_run_context_scheduler, _run_context_scheduler,
_run_evolution_loop, _run_evolution_loop,
_start_dashboard_server, _start_dashboard_server,
handle_domestic_pending_orders,
handle_overseas_pending_orders,
run_daily_session, run_daily_session,
safe_float, safe_float,
sync_positions_from_broker, sync_positions_from_broker,
@@ -99,10 +101,24 @@ class TestExtractHeldQtyFromBalance:
balance = {"output1": [], "output2": [{}]} balance = {"output1": [], "output2": [{}]}
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 0 assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 0
def test_overseas_returns_ovrs_cblc_qty(self) -> None: def test_overseas_returns_ord_psbl_qty_first(self) -> None:
"""ord_psbl_qty (주문가능수량) takes priority over ovrs_cblc_qty."""
balance = {
"output1": [{"ovrs_pdno": "AAPL", "ord_psbl_qty": "8", "ovrs_cblc_qty": "10"}]
}
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 8
def test_overseas_fallback_to_ovrs_cblc_qty_when_ord_psbl_qty_absent(self) -> None:
balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}]} balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}]}
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 10 assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 10
def test_overseas_returns_zero_when_ord_psbl_qty_zero(self) -> None:
"""Expired/delisted securities: ovrs_cblc_qty large but ord_psbl_qty=0."""
balance = {
"output1": [{"ovrs_pdno": "MLECW", "ord_psbl_qty": "0", "ovrs_cblc_qty": "289456"}]
}
assert _extract_held_qty_from_balance(balance, "MLECW", is_domestic=False) == 0
def test_overseas_fallback_to_hldg_qty(self) -> None: def test_overseas_fallback_to_hldg_qty(self) -> None:
balance = {"output1": [{"ovrs_pdno": "AAPL", "hldg_qty": "4"}]} balance = {"output1": [{"ovrs_pdno": "AAPL", "hldg_qty": "4"}]}
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 4 assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 4
@@ -145,6 +161,26 @@ class TestExtractHeldCodesFromBalance:
result = _extract_held_codes_from_balance(balance, is_domestic=False) result = _extract_held_codes_from_balance(balance, is_domestic=False)
assert result == ["AAPL"] assert result == ["AAPL"]
def test_overseas_uses_ord_psbl_qty_to_filter(self) -> None:
"""ord_psbl_qty=0 should exclude stock even if ovrs_cblc_qty is large."""
balance = {
"output1": [
{"ovrs_pdno": "MLECW", "ord_psbl_qty": "0", "ovrs_cblc_qty": "289456"},
{"ovrs_pdno": "AAPL", "ord_psbl_qty": "5", "ovrs_cblc_qty": "5"},
]
}
result = _extract_held_codes_from_balance(balance, is_domestic=False)
assert "MLECW" not in result
assert "AAPL" in result
def test_overseas_includes_stock_when_ord_psbl_qty_absent_and_ovrs_cblc_qty_positive(
self,
) -> None:
"""Fallback to ovrs_cblc_qty when ord_psbl_qty field is missing."""
balance = {"output1": [{"ovrs_pdno": "TSLA", "ovrs_cblc_qty": "3"}]}
result = _extract_held_codes_from_balance(balance, is_domestic=False)
assert "TSLA" in result
class TestDetermineOrderQuantity: class TestDetermineOrderQuantity:
"""Test _determine_order_quantity() — SELL uses broker_held_qty.""" """Test _determine_order_quantity() — SELL uses broker_held_qty."""
@@ -1105,10 +1141,11 @@ class TestOverseasBalanceParsing:
mock_telegram: MagicMock, mock_telegram: MagicMock,
mock_overseas_market: MagicMock, mock_overseas_market: MagicMock,
) -> None: ) -> None:
"""Overseas BUY order must use current_price (limit), not 0 (market). """Overseas BUY order must use current_price +0.2% limit, not market order.
KIS VTS rejects market orders for overseas paper trading. KIS market orders (ORD_DVSN=01) calculate quantity based on upper limit price
Regression test for issue #149. (상한가 기준), resulting in only 60-80% of intended cash being used.
Regression test for issue #149 / #211.
""" """
mock_telegram.notify_trade_execution = AsyncMock() mock_telegram.notify_trade_execution = AsyncMock()
@@ -1129,14 +1166,93 @@ class TestOverseasBalanceParsing:
scan_candidates={}, scan_candidates={},
) )
# Verify limit order was sent with actual price + 0.5% premium (issue #151), not 0.0 # Verify BUY limit order uses +0.2% premium (issue #211)
mock_overseas_broker_with_buy_scenario.send_overseas_order.assert_called_once() mock_overseas_broker_with_buy_scenario.send_overseas_order.assert_called_once()
call_kwargs = mock_overseas_broker_with_buy_scenario.send_overseas_order.call_args call_kwargs = mock_overseas_broker_with_buy_scenario.send_overseas_order.call_args
sent_price = call_kwargs[1].get("price") or call_kwargs[0][4] sent_price = call_kwargs[1].get("price") or call_kwargs[0][4]
expected_price = round(182.5 * 1.005, 4) # 0.5% premium for BUY limit orders expected_price = round(182.5 * 1.002, 4) # 0.2% premium for BUY limit orders
assert sent_price == expected_price, ( assert sent_price == expected_price, (
f"Expected limit price {expected_price} (182.5 * 1.005) but got {sent_price}. " f"Expected limit price {expected_price} (182.5 * 1.002) but got {sent_price}. "
"KIS VTS only accepts limit orders; BUY uses 0.5% premium to improve fill rate." "BUY uses +0.2% to improve fill rate while minimising overpayment (#211)."
)
@pytest.mark.asyncio
async def test_overseas_sell_order_uses_limit_price_below_current(
self,
mock_domestic_broker: MagicMock,
mock_playbook: DayPlaybook,
mock_risk: MagicMock,
mock_db: MagicMock,
mock_decision_logger: MagicMock,
mock_context_store: MagicMock,
mock_criticality_assessor: MagicMock,
mock_telegram: MagicMock,
mock_overseas_market: MagicMock,
) -> None:
"""Overseas SELL order must use current_price -0.2% limit (#211).
Placing SELL at exact last price risks no-fill when the bid is just below.
Using -0.2% ensures the order fills even if the price dips slightly.
"""
sell_price = 182.5
# Broker mock: returns price data and a balance with 5 AAPL shares held.
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": str(sell_price), "rate": "1.5", "tvol": "5000000"}}
)
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [
{
"ovrs_pdno": "AAPL",
"ovrs_cblc_qty": "5",
"pchs_avg_pric": "170.0",
"evlu_pfls_rt": "7.35",
}
],
"output2": [
{
"frcr_evlu_tota": "100000.00",
"frcr_dncl_amt_2": "50000.00",
"frcr_buy_amt_smtl": "50000.00",
}
],
}
)
overseas_broker.send_overseas_order = AsyncMock(
return_value={"rt_cd": "0", "msg1": "OK"}
)
sell_engine = MagicMock(spec=ScenarioEngine)
sell_engine.evaluate = MagicMock(return_value=_make_sell_match("AAPL"))
mock_telegram.notify_trade_execution = AsyncMock()
with patch("src.main.log_trade"), patch("src.main.get_open_position") as mock_pos:
mock_pos.return_value = {"quantity": 5, "stock_code": "AAPL", "price": 170.0}
await trading_cycle(
broker=mock_domestic_broker,
overseas_broker=overseas_broker,
scenario_engine=sell_engine,
playbook=mock_playbook,
risk=mock_risk,
db_conn=mock_db,
decision_logger=mock_decision_logger,
context_store=mock_context_store,
criticality_assessor=mock_criticality_assessor,
telegram=mock_telegram,
market=mock_overseas_market,
stock_code="AAPL",
scan_candidates={},
)
overseas_broker.send_overseas_order.assert_called_once()
call_kwargs = overseas_broker.send_overseas_order.call_args
sent_price = call_kwargs[1].get("price") or call_kwargs[0][4]
expected_price = round(sell_price * 0.998, 4) # -0.2% for SELL limit orders
assert sent_price == expected_price, (
f"Expected SELL limit price {expected_price} (182.5 * 0.998) but got {sent_price}. "
"SELL uses -0.2% to ensure fill even when price dips slightly (#211)."
) )
@@ -3792,3 +3908,693 @@ class TestDomesticBuyDoublePreventionTradingCycle:
# BUY must NOT have been executed because broker still holds the stock # BUY must NOT have been executed because broker still holds the stock
broker.send_order.assert_not_called() broker.send_order.assert_not_called()
class TestHandleOverseasPendingOrders:
"""Tests for handle_overseas_pending_orders function."""
def _make_settings(self, markets: str = "US_NASDAQ,US_NYSE,US_AMEX") -> Settings:
return Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
ENABLED_MARKETS=markets,
)
def _make_telegram(self) -> MagicMock:
t = MagicMock()
t.notify_unfilled_order = AsyncMock()
return t
@pytest.mark.asyncio
async def test_buy_pending_is_cancelled_and_cooldown_set(self) -> None:
"""BUY pending order should be cancelled and buy_cooldown should be set."""
settings = self._make_settings("US_NASDAQ")
telegram = self._make_telegram()
pending_order = {
"pdno": "AAPL",
"odno": "ORD001",
"sll_buy_dvsn_cd": "02", # BUY
"nccs_qty": "3",
"ovrs_excg_cd": "NASD",
}
overseas_broker = MagicMock()
overseas_broker.get_overseas_pending_orders = AsyncMock(
return_value=[pending_order]
)
overseas_broker.cancel_overseas_order = AsyncMock(
return_value={"rt_cd": "0", "msg1": "OK"}
)
sell_resubmit_counts: dict[str, int] = {}
buy_cooldown: dict[str, float] = {}
await handle_overseas_pending_orders(
overseas_broker, telegram, settings, sell_resubmit_counts, buy_cooldown
)
overseas_broker.cancel_overseas_order.assert_called_once_with(
exchange_code="NASD",
stock_code="AAPL",
odno="ORD001",
qty=3,
)
assert "NASD:AAPL" in buy_cooldown
telegram.notify_unfilled_order.assert_called_once()
call_kwargs = telegram.notify_unfilled_order.call_args[1]
assert call_kwargs["action"] == "BUY"
assert call_kwargs["outcome"] == "cancelled"
@pytest.mark.asyncio
async def test_sell_pending_is_cancelled_then_resubmitted(self) -> None:
"""First unfilled SELL should be cancelled then resubmitted at -0.4% price."""
settings = self._make_settings("US_NASDAQ")
telegram = self._make_telegram()
pending_order = {
"pdno": "AAPL",
"odno": "ORD002",
"sll_buy_dvsn_cd": "01", # SELL
"nccs_qty": "5",
"ovrs_excg_cd": "NASD",
}
overseas_broker = MagicMock()
overseas_broker.get_overseas_pending_orders = AsyncMock(
return_value=[pending_order]
)
overseas_broker.cancel_overseas_order = AsyncMock(
return_value={"rt_cd": "0", "msg1": "OK"}
)
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "200.0"}}
)
overseas_broker.send_overseas_order = AsyncMock(
return_value={"rt_cd": "0", "msg1": "OK"}
)
sell_resubmit_counts: dict[str, int] = {}
await handle_overseas_pending_orders(
overseas_broker, telegram, settings, sell_resubmit_counts
)
overseas_broker.cancel_overseas_order.assert_called_once()
overseas_broker.send_overseas_order.assert_called_once()
resubmit_kwargs = overseas_broker.send_overseas_order.call_args[1]
assert resubmit_kwargs["order_type"] == "SELL"
assert resubmit_kwargs["price"] == round(200.0 * 0.996, 4)
assert sell_resubmit_counts.get("NASD:AAPL") == 1
notify_kwargs = telegram.notify_unfilled_order.call_args[1]
assert notify_kwargs["outcome"] == "resubmitted"
@pytest.mark.asyncio
async def test_sell_cancel_failure_skips_resubmit(self) -> None:
"""When cancel returns rt_cd != '0', resubmit should NOT be attempted."""
settings = self._make_settings("US_NASDAQ")
telegram = self._make_telegram()
pending_order = {
"pdno": "AAPL",
"odno": "ORD003",
"sll_buy_dvsn_cd": "01", # SELL
"nccs_qty": "2",
"ovrs_excg_cd": "NASD",
}
overseas_broker = MagicMock()
overseas_broker.get_overseas_pending_orders = AsyncMock(
return_value=[pending_order]
)
overseas_broker.cancel_overseas_order = AsyncMock(
return_value={"rt_cd": "1", "msg1": "Error"} # failure
)
overseas_broker.send_overseas_order = AsyncMock()
sell_resubmit_counts: dict[str, int] = {}
await handle_overseas_pending_orders(
overseas_broker, telegram, settings, sell_resubmit_counts
)
overseas_broker.send_overseas_order.assert_not_called()
telegram.notify_unfilled_order.assert_not_called()
@pytest.mark.asyncio
async def test_sell_already_resubmitted_is_only_cancelled(self) -> None:
"""Second unfilled SELL (sell_resubmit_counts >= 1) should only cancel, no resubmit."""
settings = self._make_settings("US_NASDAQ")
telegram = self._make_telegram()
pending_order = {
"pdno": "AAPL",
"odno": "ORD004",
"sll_buy_dvsn_cd": "01", # SELL
"nccs_qty": "4",
"ovrs_excg_cd": "NASD",
}
overseas_broker = MagicMock()
overseas_broker.get_overseas_pending_orders = AsyncMock(
return_value=[pending_order]
)
overseas_broker.cancel_overseas_order = AsyncMock(
return_value={"rt_cd": "0", "msg1": "OK"}
)
overseas_broker.send_overseas_order = AsyncMock()
# Already resubmitted once
sell_resubmit_counts: dict[str, int] = {"NASD:AAPL": 1}
await handle_overseas_pending_orders(
overseas_broker, telegram, settings, sell_resubmit_counts
)
overseas_broker.cancel_overseas_order.assert_called_once()
overseas_broker.send_overseas_order.assert_not_called()
notify_kwargs = telegram.notify_unfilled_order.call_args[1]
assert notify_kwargs["outcome"] == "cancelled"
assert notify_kwargs["action"] == "SELL"
@pytest.mark.asyncio
async def test_us_exchanges_deduplicated_to_nasd(self) -> None:
"""US_NASDAQ, US_NYSE, US_AMEX should result in only one NASD query."""
settings = self._make_settings("US_NASDAQ,US_NYSE,US_AMEX")
telegram = self._make_telegram()
overseas_broker = MagicMock()
overseas_broker.get_overseas_pending_orders = AsyncMock(return_value=[])
sell_resubmit_counts: dict[str, int] = {}
await handle_overseas_pending_orders(
overseas_broker, telegram, settings, sell_resubmit_counts
)
# Should be called exactly once with "NASD"
assert overseas_broker.get_overseas_pending_orders.call_count == 1
overseas_broker.get_overseas_pending_orders.assert_called_once_with("NASD")
# ---------------------------------------------------------------------------
# Domestic Pending Order Handling
# ---------------------------------------------------------------------------
class TestHandleDomesticPendingOrders:
"""Tests for handle_domestic_pending_orders function."""
def _make_settings(self) -> Settings:
return Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
ENABLED_MARKETS="KR",
)
def _make_telegram(self) -> MagicMock:
t = MagicMock()
t.notify_unfilled_order = AsyncMock()
return t
@pytest.mark.asyncio
async def test_buy_pending_is_cancelled_and_cooldown_set(self) -> None:
"""BUY pending order should be cancelled and buy_cooldown should be set."""
settings = self._make_settings()
telegram = self._make_telegram()
pending_order = {
"pdno": "005930",
"orgn_odno": "ORD001",
"ord_gno_brno": "BRN01",
"sll_buy_dvsn_cd": "02", # BUY
"psbl_qty": "3",
}
broker = MagicMock()
broker.get_domestic_pending_orders = AsyncMock(return_value=[pending_order])
broker.cancel_domestic_order = AsyncMock(
return_value={"rt_cd": "0", "msg1": "OK"}
)
sell_resubmit_counts: dict[str, int] = {}
buy_cooldown: dict[str, float] = {}
await handle_domestic_pending_orders(
broker, telegram, settings, sell_resubmit_counts, buy_cooldown
)
broker.cancel_domestic_order.assert_called_once_with(
stock_code="005930",
orgn_odno="ORD001",
krx_fwdg_ord_orgno="BRN01",
qty=3,
)
assert "KR:005930" in buy_cooldown
telegram.notify_unfilled_order.assert_called_once()
call_kwargs = telegram.notify_unfilled_order.call_args[1]
assert call_kwargs["action"] == "BUY"
assert call_kwargs["outcome"] == "cancelled"
assert call_kwargs["market"] == "KR"
@pytest.mark.asyncio
async def test_sell_pending_is_cancelled_then_resubmitted(self) -> None:
"""First unfilled SELL should be cancelled then resubmitted at -0.4% price."""
from src.broker.kis_api import kr_round_down
settings = self._make_settings()
telegram = self._make_telegram()
pending_order = {
"pdno": "005930",
"orgn_odno": "ORD002",
"ord_gno_brno": "BRN02",
"sll_buy_dvsn_cd": "01", # SELL
"psbl_qty": "5",
}
broker = MagicMock()
broker.get_domestic_pending_orders = AsyncMock(return_value=[pending_order])
broker.cancel_domestic_order = AsyncMock(
return_value={"rt_cd": "0", "msg1": "OK"}
)
broker.get_current_price = AsyncMock(return_value=(50000.0, 0.0, 0.0))
broker.send_order = AsyncMock(return_value={"rt_cd": "0"})
sell_resubmit_counts: dict[str, int] = {}
await handle_domestic_pending_orders(
broker, telegram, settings, sell_resubmit_counts
)
broker.cancel_domestic_order.assert_called_once()
broker.send_order.assert_called_once()
resubmit_kwargs = broker.send_order.call_args[1]
assert resubmit_kwargs["order_type"] == "SELL"
expected_price = kr_round_down(50000.0 * 0.996)
assert resubmit_kwargs["price"] == expected_price
assert sell_resubmit_counts.get("KR:005930") == 1
notify_kwargs = telegram.notify_unfilled_order.call_args[1]
assert notify_kwargs["outcome"] == "resubmitted"
@pytest.mark.asyncio
async def test_sell_cancel_failure_skips_resubmit(self) -> None:
"""When cancel returns rt_cd != '0', resubmit should NOT be attempted."""
settings = self._make_settings()
telegram = self._make_telegram()
pending_order = {
"pdno": "005930",
"orgn_odno": "ORD003",
"ord_gno_brno": "BRN03",
"sll_buy_dvsn_cd": "01", # SELL
"psbl_qty": "2",
}
broker = MagicMock()
broker.get_domestic_pending_orders = AsyncMock(return_value=[pending_order])
broker.cancel_domestic_order = AsyncMock(
return_value={"rt_cd": "1", "msg1": "Error"} # failure
)
broker.send_order = AsyncMock()
sell_resubmit_counts: dict[str, int] = {}
await handle_domestic_pending_orders(
broker, telegram, settings, sell_resubmit_counts
)
broker.send_order.assert_not_called()
telegram.notify_unfilled_order.assert_not_called()
@pytest.mark.asyncio
async def test_sell_already_resubmitted_is_only_cancelled(self) -> None:
"""Second unfilled SELL (sell_resubmit_counts >= 1) should only cancel, no resubmit."""
settings = self._make_settings()
telegram = self._make_telegram()
pending_order = {
"pdno": "005930",
"orgn_odno": "ORD004",
"ord_gno_brno": "BRN04",
"sll_buy_dvsn_cd": "01", # SELL
"psbl_qty": "4",
}
broker = MagicMock()
broker.get_domestic_pending_orders = AsyncMock(return_value=[pending_order])
broker.cancel_domestic_order = AsyncMock(
return_value={"rt_cd": "0", "msg1": "OK"}
)
broker.send_order = AsyncMock()
# Already resubmitted once
sell_resubmit_counts: dict[str, int] = {"KR:005930": 1}
await handle_domestic_pending_orders(
broker, telegram, settings, sell_resubmit_counts
)
broker.cancel_domestic_order.assert_called_once()
broker.send_order.assert_not_called()
notify_kwargs = telegram.notify_unfilled_order.call_args[1]
assert notify_kwargs["outcome"] == "cancelled"
assert notify_kwargs["action"] == "SELL"
# ---------------------------------------------------------------------------
# Domestic Limit Order Price in trading_cycle
# ---------------------------------------------------------------------------
class TestDomesticLimitOrderPrice:
"""trading_cycle must use kr_round_down limit prices for domestic orders."""
def _make_market(self) -> MagicMock:
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
return market
def _make_broker(self, current_price: float, balance_data: dict) -> MagicMock:
broker = MagicMock()
broker.get_current_price = AsyncMock(return_value=(current_price, 0.0, 0.0))
broker.get_balance = AsyncMock(return_value=balance_data)
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
return broker
@pytest.mark.asyncio
async def test_trading_cycle_domestic_buy_uses_limit_price(self) -> None:
"""BUY order for domestic stock must use kr_round_down(price * 1.002)."""
from src.broker.kis_api import kr_round_down
from src.strategy.models import ScenarioAction
current_price = 70000.0
balance_data = {
"output2": [
{
"tot_evlu_amt": "10000000",
"dnca_tot_amt": "5000000",
"pchs_amt_smtl_amt": "5000000",
}
]
}
broker = self._make_broker(current_price, balance_data)
market = self._make_market()
buy_match = ScenarioMatch(
stock_code="005930",
matched_scenario=None,
action=ScenarioAction.BUY,
confidence=85,
rationale="test",
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=buy_match)
risk = MagicMock()
risk.validate_order = MagicMock()
risk.check_circuit_breaker = MagicMock()
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
with patch("src.main.log_trade"):
await trading_cycle(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=_make_playbook(),
risk=risk,
db_conn=MagicMock(),
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
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="005930",
scan_candidates={},
)
broker.send_order.assert_called_once()
call_kwargs = broker.send_order.call_args[1]
expected_price = kr_round_down(current_price * 1.002)
assert call_kwargs["price"] == expected_price
assert call_kwargs["order_type"] == "BUY"
@pytest.mark.asyncio
async def test_trading_cycle_domestic_sell_uses_limit_price(self) -> None:
"""SELL order for domestic stock must use kr_round_down(price * 0.998)."""
from src.broker.kis_api import kr_round_down
from src.strategy.models import ScenarioAction
current_price = 70000.0
stock_code = "005930"
balance_data = {
"output1": [
{"pdno": stock_code, "hldg_qty": "5", "prpr": "70000", "evlu_amt": "350000"}
],
"output2": [
{
"tot_evlu_amt": "350000",
"dnca_tot_amt": "0",
"pchs_amt_smtl_amt": "350000",
}
],
}
broker = self._make_broker(current_price, balance_data)
market = self._make_market()
sell_match = ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=ScenarioAction.SELL,
confidence=85,
rationale="test",
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=sell_match)
risk = MagicMock()
risk.validate_order = MagicMock()
risk.check_circuit_breaker = MagicMock()
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
with patch("src.main.log_trade"):
await trading_cycle(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=_make_playbook(),
risk=risk,
db_conn=MagicMock(),
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
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=stock_code,
scan_candidates={},
)
broker.send_order.assert_called_once()
call_kwargs = broker.send_order.call_args[1]
expected_price = kr_round_down(current_price * 0.998)
assert call_kwargs["price"] == expected_price
assert call_kwargs["order_type"] == "SELL"
# ---------------------------------------------------------------------------
# Ghost position — overseas SELL "잔고내역이 없습니다" handling
# ---------------------------------------------------------------------------
class TestOverseasGhostPositionClose:
"""trading_cycle must close ghost DB position when broker returns 잔고없음."""
def _make_overseas_market(self) -> MagicMock:
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
return market
def _make_overseas_broker(
self,
current_price: float,
balance_data: dict,
sell_result: dict,
) -> MagicMock:
ob = MagicMock()
ob.get_overseas_price = AsyncMock(
return_value={"output": {"last": str(current_price), "rate": "0.0"}}
)
ob.get_overseas_balance = AsyncMock(return_value=balance_data)
ob.send_overseas_order = AsyncMock(return_value=sell_result)
return ob
@pytest.mark.asyncio
async def test_ghost_position_closes_db_on_no_balance_error(self) -> None:
"""When SELL fails with '잔고내역이 없습니다', log_trade is called to close the ghost.
This can happen when exchange code recorded at startup differs from the
exchange code used in the SELL cycle (e.g. KNRX recorded as NASD but
actually traded on AMEX), causing the broker to see no matching balance.
The position has ord_psbl_qty > 0 (so a SELL is attempted), but KIS
rejects it with '잔고내역이 없습니다'.
"""
from src.strategy.models import ScenarioAction
stock_code = "KNRX"
current_price = 1.5
# ord_psbl_qty=5 means the code passes the qty check and a SELL is sent
balance_data = {
"output1": [
{"ovrs_pdno": stock_code, "ord_psbl_qty": "5", "ovrs_cblc_qty": "5"}
],
"output2": [{"tot_evlu_amt": "10000", "frcr_dncl_amt_2": "10000"}],
}
sell_result = {"rt_cd": "1", "msg1": "모의투자 잔고내역이 없습니다"}
domestic_broker = MagicMock()
domestic_broker.get_balance = AsyncMock(return_value={"output1": [], "output2": [{}]})
overseas_broker = self._make_overseas_broker(current_price, balance_data, sell_result)
market = self._make_overseas_market()
sell_match = ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=ScenarioAction.SELL,
confidence=85,
rationale="test ghost KNRX",
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=sell_match)
risk = MagicMock()
risk.validate_order = MagicMock()
risk.check_circuit_breaker = MagicMock()
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
db_conn = MagicMock()
settings = MagicMock(spec=Settings)
settings.MODE = "paper"
settings.POSITION_SIZING_ENABLED = False
settings.PAPER_OVERSEAS_CASH = 0
with patch("src.main.log_trade") as mock_log_trade, patch(
"src.main.get_open_position", return_value=None
), patch("src.main.get_latest_buy_trade", return_value=None):
await trading_cycle(
broker=domestic_broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US_NASDAQ"),
risk=risk,
db_conn=db_conn,
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
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=stock_code,
scan_candidates={},
settings=settings,
)
# log_trade must be called with action="SELL" to close the ghost position
ghost_close_calls = [
c
for c in mock_log_trade.call_args_list
if c.kwargs.get("action") == "SELL"
and "[ghost-close]" in (c.kwargs.get("rationale") or "")
]
assert ghost_close_calls, "Expected ghost-close log_trade call was not made"
@pytest.mark.asyncio
async def test_normal_sell_failure_does_not_close_db(self) -> None:
"""Non-잔고없음 SELL failures must NOT close the DB position."""
from src.strategy.models import ScenarioAction
stock_code = "TSLA"
current_price = 250.0
balance_data = {
"output1": [{"ovrs_pdno": stock_code, "ord_psbl_qty": "5", "ovrs_cblc_qty": "5"}],
"output2": [{"tot_evlu_amt": "100000", "frcr_dncl_amt_2": "100000"}],
}
sell_result = {"rt_cd": "1", "msg1": "일시적 오류가 발생했습니다"}
domestic_broker = MagicMock()
domestic_broker.get_balance = AsyncMock(return_value={"output1": [], "output2": [{}]})
overseas_broker = self._make_overseas_broker(current_price, balance_data, sell_result)
market = self._make_overseas_market()
sell_match = ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=ScenarioAction.SELL,
confidence=85,
rationale="test",
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=sell_match)
risk = MagicMock()
risk.validate_order = MagicMock()
risk.check_circuit_breaker = MagicMock()
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
db_conn = MagicMock()
with patch("src.main.log_trade") as mock_log_trade, patch(
"src.main.get_open_position", return_value=None
):
await trading_cycle(
broker=domestic_broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US_NASDAQ"),
risk=risk,
db_conn=db_conn,
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
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=stock_code,
scan_candidates={},
)
ghost_close_calls = [
c
for c in mock_log_trade.call_args_list
if c.kwargs.get("action") == "SELL"
and "[ghost-close]" in (c.kwargs.get("rationale") or "")
]
assert not ghost_close_calls, "Ghost-close must NOT be triggered for non-잔고없음 errors"

View File

@@ -124,7 +124,7 @@ class TestFetchOverseasRankings:
assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url
assert params["EXCD"] == "NAS" assert params["EXCD"] == "NAS"
assert params["NDAY"] == "0" assert params["NDAY"] == "0"
assert params["GUBN"] == "1" assert params["GUBN"] == "0" # 0=전체(상승+하락), 변동성 스캐너에 필요
assert params["VOL_RANG"] == "0" assert params["VOL_RANG"] == "0"
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000") overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000")
@@ -813,3 +813,221 @@ class TestOverseasTRIDBranching:
await broker.send_overseas_order("NASD", "AAPL", "SELL", 1) await broker.send_overseas_order("NASD", "AAPL", "SELL", 1)
assert "TTTT1006U" in captured assert "TTTT1006U" in captured
class TestGetOverseasPendingOrders:
"""Tests for get_overseas_pending_orders method."""
@pytest.mark.asyncio
async def test_paper_mode_returns_empty(
self, overseas_broker: OverseasBroker
) -> None:
"""Paper mode should immediately return [] without any API call."""
# Default mock_settings has MODE="paper"
overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy(
update={"MODE": "paper"}
)
mock_session = MagicMock()
_setup_broker_mocks(overseas_broker, mock_session)
result = await overseas_broker.get_overseas_pending_orders("NASD")
assert result == []
mock_session.get.assert_not_called()
@pytest.mark.asyncio
async def test_live_mode_calls_ttts3018r_with_correct_params(
self, overseas_broker: OverseasBroker
) -> None:
"""Live mode should call TTTS3018R with OVRS_EXCG_CD and return output list."""
overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy(
update={"MODE": "live"}
)
captured_tr_id: list[str] = []
captured_params: list[dict] = []
async def mock_auth_headers(tr_id: str) -> dict:
captured_tr_id.append(tr_id)
return {}
overseas_broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
pending_orders = [
{"odno": "001", "pdno": "AAPL", "sll_buy_dvsn_cd": "02", "nccs_qty": "5"}
]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": pending_orders})
mock_session = MagicMock()
def _capture_get(url: str, **kwargs: object) -> MagicMock:
captured_params.append(kwargs.get("params", {}))
return _make_async_cm(mock_resp)
mock_session.get = MagicMock(side_effect=_capture_get)
overseas_broker._broker._rate_limiter.acquire = AsyncMock()
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
result = await overseas_broker.get_overseas_pending_orders("NASD")
assert result == pending_orders
assert captured_tr_id == ["TTTS3018R"]
assert captured_params[0]["OVRS_EXCG_CD"] == "NASD"
@pytest.mark.asyncio
async def test_live_mode_connection_error(
self, overseas_broker: OverseasBroker
) -> None:
"""Network error in live mode should raise ConnectionError."""
overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy(
update={"MODE": "live"}
)
cm = MagicMock()
cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("timeout"))
cm.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=cm)
_setup_broker_mocks(overseas_broker, mock_session)
with pytest.raises(ConnectionError, match="Network error fetching pending orders"):
await overseas_broker.get_overseas_pending_orders("NASD")
class TestCancelOverseasOrder:
"""Tests for cancel_overseas_order method."""
def _setup_cancel_mocks(
self, overseas_broker: OverseasBroker, response: dict
) -> tuple[list[str], MagicMock]:
"""Wire up mocks for a successful cancel call; return captured TR_IDs and session."""
captured_tr_ids: list[str] = []
async def mock_auth_headers(tr_id: str) -> dict:
captured_tr_ids.append(tr_id)
return {}
overseas_broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hash_val") # type: ignore[method-assign]
overseas_broker._broker._rate_limiter.acquire = AsyncMock()
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value=response)
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
return captured_tr_ids, mock_session
@pytest.mark.asyncio
async def test_us_live_uses_tttt1004u(
self, overseas_broker: OverseasBroker
) -> None:
"""US exchange in live mode should use TTTT1004U."""
overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy(
update={"MODE": "live"}
)
captured, _ = self._setup_cancel_mocks(
overseas_broker, {"rt_cd": "0", "msg1": "OK"}
)
await overseas_broker.cancel_overseas_order("NASD", "AAPL", "ORD001", 5)
assert "TTTT1004U" in captured
@pytest.mark.asyncio
async def test_us_paper_uses_vttt1004u(
self, overseas_broker: OverseasBroker
) -> None:
"""US exchange in paper mode should use VTTT1004U."""
# Default mock_settings has MODE="paper"
captured, _ = self._setup_cancel_mocks(
overseas_broker, {"rt_cd": "0", "msg1": "OK"}
)
await overseas_broker.cancel_overseas_order("NASD", "AAPL", "ORD001", 5)
assert "VTTT1004U" in captured
@pytest.mark.asyncio
async def test_hk_live_uses_ttts1003u(
self, overseas_broker: OverseasBroker
) -> None:
"""SEHK exchange in live mode should use TTTS1003U."""
overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy(
update={"MODE": "live"}
)
captured, _ = self._setup_cancel_mocks(
overseas_broker, {"rt_cd": "0", "msg1": "OK"}
)
await overseas_broker.cancel_overseas_order("SEHK", "0700", "ORD002", 10)
assert "TTTS1003U" in captured
@pytest.mark.asyncio
async def test_cancel_sets_rvse_cncl_dvsn_cd_02(
self, overseas_broker: OverseasBroker
) -> None:
"""Cancel body must include RVSE_CNCL_DVSN_CD='02' and OVRS_ORD_UNPR='0'."""
captured_body: list[dict] = []
async def mock_auth_headers(tr_id: str) -> dict:
return {}
overseas_broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
overseas_broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign]
overseas_broker._broker._rate_limiter.acquire = AsyncMock()
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
mock_session = MagicMock()
def _capture_post(url: str, **kwargs: object) -> MagicMock:
captured_body.append(kwargs.get("json", {}))
return _make_async_cm(mock_resp)
mock_session.post = MagicMock(side_effect=_capture_post)
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
await overseas_broker.cancel_overseas_order("NASD", "AAPL", "ORD003", 3)
assert captured_body[0]["RVSE_CNCL_DVSN_CD"] == "02"
assert captured_body[0]["OVRS_ORD_UNPR"] == "0"
assert captured_body[0]["ORGN_ODNO"] == "ORD003"
@pytest.mark.asyncio
async def test_cancel_sets_hashkey_header(
self, overseas_broker: OverseasBroker
) -> None:
"""hashkey must be set in the request headers."""
captured_headers: list[dict] = []
overseas_broker._broker._get_hash_key = AsyncMock(return_value="test_hash") # type: ignore[method-assign]
overseas_broker._broker._rate_limiter.acquire = AsyncMock()
async def mock_auth_headers(tr_id: str) -> dict:
return {"tr_id": tr_id}
overseas_broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
mock_session = MagicMock()
def _capture_post(url: str, **kwargs: object) -> MagicMock:
captured_headers.append(dict(kwargs.get("headers", {})))
return _make_async_cm(mock_resp)
mock_session.post = MagicMock(side_effect=_capture_post)
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
await overseas_broker.cancel_overseas_order("NASD", "AAPL", "ORD004", 2)
assert captured_headers[0].get("hashkey") == "test_hash"

View File

@@ -124,6 +124,10 @@ class TestPromptOptimizer:
assert len(prompt) < 300 assert len(prompt) < 300
assert "005930" in prompt assert "005930" in prompt
assert "75000" in prompt assert "75000" in prompt
# Keys must match parse_response expectations (#242)
assert '"action"' in prompt
assert '"confidence"' in prompt
assert '"rationale"' in prompt
def test_build_compressed_prompt_no_instructions(self): def test_build_compressed_prompt_no_instructions(self):
"""Test compressed prompt without instructions.""" """Test compressed prompt without instructions."""