Compare commits

...

17 Commits

Author SHA1 Message Date
da585ee547 Merge pull request 'feat: Daily 모드 ConnectionError 재시도 로직 추가 (#209)' (#223) from feature/issue-209-daily-connection-retry into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #223
2026-02-23 14:57:26 +09:00
c737d5009a Merge pull request 'test: 테스트 커버리지 77% → 80% 달성 (#204)' (#222) from feature/issue-204-test-coverage-80 into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #222
2026-02-23 14:56:22 +09:00
agentson
ac4fb00644 feat: Daily 모드 ConnectionError 재시도 로직 추가 (issue #209)
Some checks failed
CI / test (pull_request) Has been cancelled
- _retry_connection() 헬퍼 추가: MAX_CONNECTION_RETRIES(3회) 지수 백오프
  (2^attempt 초) 재시도, 읽기 전용 API 호출에만 적용 (주문 제외)
- run_daily_session(): get_current_price / get_overseas_price 호출에 적용
- run_daily_session(): get_balance / get_overseas_balance 호출에 적용
  - 잔고 조회 전체 실패 시 해당 마켓을 skip하고 다른 마켓은 계속 처리
- 테스트 5개 추가: TestRetryConnection 클래스

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:51:15 +09:00
agentson
4fc4a57036 test: 테스트 커버리지 77% → 80% 달성 (issue #204)
Some checks failed
CI / test (pull_request) Has been cancelled
신규/추가 테스트:
- tests/test_logging_config.py: JSONFormatter, setup_logging 전체 커버 (14줄)
- tests/test_strategies_base.py: BaseStrategy 추상 클래스 커버 (6줄)
- tests/test_backup.py: BackupExporter 미커버 경로(빈 CSV, compress=True CSV,
  포맷 실패 로깅, 기본 formats) + CloudStorage boto3 모킹 테스트 20개 (113줄)
- tests/test_context.py: ContextSummarizer 전체 커버 22개 테스트 (50줄)

총 815개 테스트 통과, TOTAL 커버리지 80% (1046줄 미커버 / 5225줄 전체)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:48:08 +09:00
641f3e8811 Merge pull request 'feat: trades 테이블 mode 컬럼 추가 (#212)' (#221) from feature/issue-212-trades-mode-column into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #221
2026-02-23 12:34:26 +09:00
agentson
ebd0a0297c chore: PR #221 충돌 해결 — WAL 테스트(#210)와 mode 컬럼 테스트(#212) 병합
Some checks failed
CI / test (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:34:06 +09:00
02a72e0f7e Merge pull request 'feat: DB WAL 모드 적용, .env.example 정리 (#210, #213, #216)' (#220) from feature/issue-210-213-216-db-wal-env-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #220
2026-02-23 12:32:36 +09:00
478a659ac2 Merge pull request 'feat: 실전 투자 전환 — TR_ID 분기, URL, 신뢰도 임계값, 텔레그램 알림 (#201~#205, #208, #214)' (#219) from feature/issue-201-202-203-broker-live-mode into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #219
2026-02-23 12:32:21 +09:00
agentson
16b9b6832d fix: BULLISH confidence 임계값 75로 복원 (#205)
Some checks failed
CI / test (pull_request) Has been cancelled
CLAUDE.md 규칙 개정에 따라 BULLISH 시장은 75로 유지.
시장 전망별 임계값: BEARISH=90, NEUTRAL=80, BULLISH=75.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:30:51 +09:00
agentson
48b87a79f6 docs: CLAUDE.md confidence 규칙 BULLISH=75 명시 (#205)
시장 전망별 BUY confidence 최소 임계값:
- BEARISH: 90 (더 엄격)
- NEUTRAL/기본: 80
- BULLISH: 75 (낙관적 시장에서 완화)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:29:39 +09:00
agentson
ad79082dcc docs: CLAUDE.md 비협상 규칙 명시 강화 — BULLISH 시 confidence 임계값 포함 (#205)
Some checks failed
CI / test (pull_request) Has been cancelled
BULLISH 시장에서도 confidence < 80 → HOLD 규칙이 동일하게 적용됨을 명시.
시장 전망별 임계값: BEARISH=90(더 엄격), BULLISH/NEUTRAL=80(최소값).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:28:30 +09:00
agentson
11dff9d3e5 feat: trades 테이블 mode 컬럼 추가 (paper/live 거래 분리) (#212)
Some checks failed
CI / test (pull_request) Has been cancelled
- trades 테이블에 mode TEXT DEFAULT 'paper' 컬럼 추가
- 기존 DB 마이그레이션: ALTER TABLE으로 mode 컬럼 자동 추가
- log_trade() 함수에 mode 파라미터 추가 (기본값 'paper')
- trading_cycle(), run_daily_session()에서 settings.MODE 전달
- 테스트 5개 추가 (mode 저장, 기본값, 스키마 검증, 마이그레이션)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 10:33:02 +09:00
agentson
3c5f1752e6 feat: DB WAL 모드 적용, .env.example 정리 (#210, #213, #216)
Some checks failed
CI / test (pull_request) Has been cancelled
- #210: init_db()에 WAL 저널 모드 적용 (파일 DB에만, :memory: 제외)
  - 대시보드(READ)와 거래루프(WRITE) 동시 접근 시 SQLite 락 오류 방지
  - busy_timeout=5000ms 설정
- #213: RATE_LIMIT_RPS 기본값 2.0으로 통일 (.env.example이 5.0으로 잘못 표기됨)
- #216: .env.example 중요 변수 추가 및 정리
  - KIS_BASE_URL 모의/실전 URL 주석 명시 (포트 29443 수정 포함)
  - MODE, TRADE_MODE, ENABLED_MARKETS, PAPER_OVERSEAS_CASH 추가
  - GEMINI_MODEL 업데이트 (gemini-pro → gemini-2.0-flash-exp)
  - DASHBOARD 설정 섹션 추가

테스트 2개 추가 (WAL 파일 DB 적용, 메모리 DB 미적용 검증)

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 05:52:35 +09:00
b45d136894 Merge pull request 'feat: 미구현 API 4개 대시보드 프론트 연결 (#198)' (#199) from feature/issue-198-dashboard-api-frontend into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #199
2026-02-23 05:37:33 +09:00
15 changed files with 1794 additions and 36 deletions

View File

@@ -1,36 +1,82 @@
# ============================================================
# The Ouroboros — Environment Configuration
# ============================================================
# Copy this file to .env and fill in your values.
# Lines starting with # are comments.
# ============================================================
# Korea Investment Securities API # Korea Investment Securities API
# ============================================================
KIS_APP_KEY=your_app_key_here KIS_APP_KEY=your_app_key_here
KIS_APP_SECRET=your_app_secret_here KIS_APP_SECRET=your_app_secret_here
KIS_ACCOUNT_NO=12345678-01 KIS_ACCOUNT_NO=12345678-01
KIS_BASE_URL=https://openapivts.koreainvestment.com:9443
# Paper trading (VTS): https://openapivts.koreainvestment.com:29443
# Live trading: https://openapi.koreainvestment.com:9443
KIS_BASE_URL=https://openapivts.koreainvestment.com:29443
# ============================================================
# Trading Mode
# ============================================================
# paper = 모의투자 (safe for testing), live = 실전투자 (real money)
MODE=paper
# daily = batch per session, realtime = per-stock continuous scan
TRADE_MODE=daily
# Comma-separated market codes: KR, US, JP, HK, CN, VN
ENABLED_MARKETS=KR,US
# Simulated USD cash for paper (VTS) overseas trading.
# VTS overseas balance API often returns 0; this value is used as fallback.
# Set to 0 to disable fallback (not used in live mode).
PAPER_OVERSEAS_CASH=50000.0
# ============================================================
# Google Gemini # Google Gemini
# ============================================================
GEMINI_API_KEY=your_gemini_api_key_here GEMINI_API_KEY=your_gemini_api_key_here
GEMINI_MODEL=gemini-pro # Recommended: gemini-2.0-flash-exp or gemini-1.5-pro
GEMINI_MODEL=gemini-2.0-flash-exp
# ============================================================
# Risk Management # Risk Management
# ============================================================
CIRCUIT_BREAKER_PCT=-3.0 CIRCUIT_BREAKER_PCT=-3.0
FAT_FINGER_PCT=30.0 FAT_FINGER_PCT=30.0
CONFIDENCE_THRESHOLD=80 CONFIDENCE_THRESHOLD=80
# ============================================================
# Database # Database
# ============================================================
DB_PATH=data/trade_logs.db DB_PATH=data/trade_logs.db
# Rate Limiting (requests per second for KIS API) # ============================================================
# Reduced to 5.0 to avoid "초당 거래건수 초과" errors (EGW00201) # Rate Limiting
RATE_LIMIT_RPS=5.0 # ============================================================
# KIS API real limit is ~2 RPS. Keep at 2.0 for maximum safety.
# Increasing this risks EGW00201 "초당 거래건수 초과" errors.
RATE_LIMIT_RPS=2.0
# Trading Mode (paper / live) # ============================================================
MODE=paper # External Data APIs (optional)
# ============================================================
# External Data APIs (optional — for enhanced decision-making)
# NEWS_API_KEY=your_news_api_key_here # NEWS_API_KEY=your_news_api_key_here
# NEWS_API_PROVIDER=alphavantage # NEWS_API_PROVIDER=alphavantage
# MARKET_DATA_API_KEY=your_market_data_key_here # MARKET_DATA_API_KEY=your_market_data_key_here
# ============================================================
# Telegram Notifications (optional) # Telegram Notifications (optional)
# ============================================================
# Get bot token from @BotFather on Telegram # Get bot token from @BotFather on Telegram
# Get chat ID from @userinfobot or your chat # Get chat ID from @userinfobot or your chat
# TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz # TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
# TELEGRAM_CHAT_ID=123456789 # TELEGRAM_CHAT_ID=123456789
# TELEGRAM_ENABLED=true # TELEGRAM_ENABLED=true
# ============================================================
# Dashboard (optional)
# ============================================================
# DASHBOARD_ENABLED=false
# DASHBOARD_HOST=127.0.0.1
# DASHBOARD_PORT=8080

View File

@@ -170,7 +170,7 @@ Markets auto-detected based on timezone and enabled in `ENABLED_MARKETS` env var
- `src/core/risk_manager.py` is **READ-ONLY** — changes require human approval - `src/core/risk_manager.py` is **READ-ONLY** — changes require human approval
- Circuit breaker at -3.0% P&L — may only be made **stricter** - Circuit breaker at -3.0% P&L — may only be made **stricter**
- Fat-finger protection: max 30% of cash per order — always enforced - Fat-finger protection: max 30% of cash per order — always enforced
- Confidence < 80 → force HOLD — cannot be weakened - Confidence 임계값 (market_outlook별, 낮출 수 없음): BEARISH ≥ 90, NEUTRAL/기본 ≥ 80, BULLISH ≥ 75
- All code changes → corresponding tests → coverage ≥ 80% - All code changes → corresponding tests → coverage ≥ 80%
## Contributing ## Contributing

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,11 @@ def init_db(db_path: str) -> sqlite3.Connection:
if db_path != ":memory:": if db_path != ":memory:":
Path(db_path).parent.mkdir(parents=True, exist_ok=True) Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
# Enable WAL mode for concurrent read/write (dashboard + trading loop).
# WAL does not apply to in-memory databases.
if db_path != ":memory:":
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS trades ( CREATE TABLE IF NOT EXISTS trades (
@@ -28,12 +33,13 @@ def init_db(db_path: str) -> sqlite3.Connection:
pnl REAL DEFAULT 0.0, pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR', market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX', exchange_code TEXT DEFAULT 'KRX',
decision_id TEXT decision_id TEXT,
mode TEXT DEFAULT 'paper'
) )
""" """
) )
# Migration: Add market and exchange_code columns if they don't exist # Migration: Add columns if they don't exist (backward-compatible schema upgrades)
cursor = conn.execute("PRAGMA table_info(trades)") cursor = conn.execute("PRAGMA table_info(trades)")
columns = {row[1] for row in cursor.fetchall()} columns = {row[1] for row in cursor.fetchall()}
@@ -45,6 +51,8 @@ def init_db(db_path: str) -> sqlite3.Connection:
conn.execute("ALTER TABLE trades ADD COLUMN selection_context TEXT") conn.execute("ALTER TABLE trades ADD COLUMN selection_context TEXT")
if "decision_id" not in columns: if "decision_id" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN decision_id TEXT") conn.execute("ALTER TABLE trades ADD COLUMN decision_id TEXT")
if "mode" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN mode TEXT DEFAULT 'paper'")
# Context tree tables for multi-layered memory management # Context tree tables for multi-layered memory management
conn.execute( conn.execute(
@@ -167,6 +175,7 @@ def log_trade(
exchange_code: str = "KRX", exchange_code: str = "KRX",
selection_context: dict[str, any] | None = None, selection_context: dict[str, any] | None = None,
decision_id: str | None = None, decision_id: str | None = None,
mode: str = "paper",
) -> None: ) -> None:
"""Insert a trade record into the database. """Insert a trade record into the database.
@@ -182,6 +191,8 @@ def log_trade(
market: Market code market: Market code
exchange_code: Exchange code exchange_code: Exchange code
selection_context: Scanner selection data (RSI, volume_ratio, signal, score) selection_context: Scanner selection data (RSI, volume_ratio, signal, score)
decision_id: Unique decision identifier for audit linking
mode: Trading mode ('paper' or 'live') for data separation
""" """
# Serialize selection context to JSON # Serialize selection context to JSON
context_json = json.dumps(selection_context) if selection_context else None context_json = json.dumps(selection_context) if selection_context else None
@@ -190,9 +201,10 @@ def log_trade(
""" """
INSERT INTO trades ( INSERT INTO trades (
timestamp, stock_code, action, confidence, rationale, timestamp, stock_code, action, confidence, rationale,
quantity, price, pnl, market, exchange_code, selection_context, decision_id quantity, price, pnl, market, exchange_code, selection_context, decision_id,
mode
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
datetime.now(UTC).isoformat(), datetime.now(UTC).isoformat(),
@@ -207,6 +219,7 @@ def log_trade(
exchange_code, exchange_code,
context_json, context_json,
decision_id, decision_id,
mode,
), ),
) )
conn.commit() conn.commit()

View File

@@ -88,6 +88,47 @@ DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions
async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kwargs: Any) -> Any:
"""Call an async function retrying on ConnectionError with exponential backoff.
Retries up to MAX_CONNECTION_RETRIES times (exclusive of the first attempt),
sleeping 2^attempt seconds between attempts. Use only for idempotent read
operations — never for order submission.
Args:
coro_factory: Async callable (method or function) to invoke.
*args: Positional arguments forwarded to coro_factory.
label: Human-readable label for log messages.
**kwargs: Keyword arguments forwarded to coro_factory.
Raises:
ConnectionError: If all retries are exhausted.
"""
for attempt in range(1, MAX_CONNECTION_RETRIES + 1):
try:
return await coro_factory(*args, **kwargs)
except ConnectionError as exc:
if attempt < MAX_CONNECTION_RETRIES:
wait_secs = 2 ** attempt
logger.warning(
"Connection error %s (attempt %d/%d), retrying in %ds: %s",
label,
attempt,
MAX_CONNECTION_RETRIES,
wait_secs,
exc,
)
await asyncio.sleep(wait_secs)
else:
logger.error(
"Connection error %s — all %d retries exhausted: %s",
label,
MAX_CONNECTION_RETRIES,
exc,
)
raise
def _extract_symbol_from_holding(item: dict[str, Any]) -> str: def _extract_symbol_from_holding(item: dict[str, Any]) -> str:
"""Extract symbol from overseas holding payload variants.""" """Extract symbol from overseas holding payload variants."""
for key in ( for key in (
@@ -340,7 +381,13 @@ async def trading_cycle(
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0") purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
# Paper mode fallback: VTS overseas balance API often fails for many accounts. # Paper mode fallback: VTS overseas balance API often fails for many accounts.
if total_cash <= 0 and settings and settings.PAPER_OVERSEAS_CASH > 0: # Only activate in paper mode — live mode must use real balance from KIS.
if (
total_cash <= 0
and settings
and settings.MODE == "paper"
and settings.PAPER_OVERSEAS_CASH > 0
):
logger.debug( logger.debug(
"Overseas cash balance is 0 for %s; using paper fallback %.2f USD", "Overseas cash balance is 0 for %s; using paper fallback %.2f USD",
market.exchange_code, market.exchange_code,
@@ -524,6 +571,14 @@ async def trading_cycle(
# BUY 결정 전 기존 포지션 체크 (중복 매수 방지) # BUY 결정 전 기존 포지션 체크 (중복 매수 방지)
if decision.action == "BUY": if decision.action == "BUY":
existing_position = get_open_position(db_conn, stock_code, market.code) existing_position = get_open_position(db_conn, stock_code, market.code)
if not existing_position and not market.is_domestic:
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
# 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
broker_qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=False
)
if broker_qty > 0:
existing_position = {"price": 0.0, "quantity": broker_qty}
if existing_position: if existing_position:
decision = TradeDecision( decision = TradeDecision(
action="HOLD", action="HOLD",
@@ -814,6 +869,7 @@ async def trading_cycle(
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
selection_context=selection_context, selection_context=selection_context,
decision_id=decision_id, decision_id=decision_id,
mode=settings.MODE if settings else "paper",
) )
# 7. Latency monitoring # 7. Latency monitoring
@@ -949,11 +1005,18 @@ async def run_daily_session(
try: try:
if market.is_domestic: if market.is_domestic:
current_price, price_change_pct, foreigner_net = ( current_price, price_change_pct, foreigner_net = (
await broker.get_current_price(stock_code) await _retry_connection(
broker.get_current_price,
stock_code,
label=stock_code,
)
) )
else: else:
price_data = await overseas_broker.get_overseas_price( price_data = await _retry_connection(
market.exchange_code, stock_code overseas_broker.get_overseas_price,
market.exchange_code,
stock_code,
label=f"{stock_code}@{market.exchange_code}",
) )
current_price = safe_float( current_price = safe_float(
price_data.get("output", {}).get("last", "0") price_data.get("output", {}).get("last", "0")
@@ -1004,9 +1067,27 @@ async def run_daily_session(
logger.warning("No valid stock data for market %s", market.code) logger.warning("No valid stock data for market %s", market.code)
continue continue
# Get balance data once for the market # Get balance data once for the market (read-only — safe to retry)
try:
if market.is_domestic:
balance_data = await _retry_connection(
broker.get_balance, label=f"balance:{market.code}"
)
else:
balance_data = await _retry_connection(
overseas_broker.get_overseas_balance,
market.exchange_code,
label=f"overseas_balance:{market.exchange_code}",
)
except ConnectionError as exc:
logger.error(
"Balance fetch failed for market %s after all retries — skipping market: %s",
market.code,
exc,
)
continue
if market.is_domestic: if market.is_domestic:
balance_data = await broker.get_balance()
output2 = balance_data.get("output2", [{}]) output2 = balance_data.get("output2", [{}])
total_eval = safe_float( total_eval = safe_float(
output2[0].get("tot_evlu_amt", "0") output2[0].get("tot_evlu_amt", "0")
@@ -1018,7 +1099,6 @@ async def run_daily_session(
output2[0].get("pchs_amt_smtl_amt", "0") output2[0].get("pchs_amt_smtl_amt", "0")
) if output2 else 0 ) if output2 else 0
else: else:
balance_data = await overseas_broker.get_overseas_balance(market.exchange_code)
output2 = balance_data.get("output2", [{}]) output2 = balance_data.get("output2", [{}])
if isinstance(output2, list) and output2: if isinstance(output2, list) and output2:
balance_info = output2[0] balance_info = output2[0]
@@ -1033,11 +1113,12 @@ async def run_daily_session(
balance_info.get("frcr_buy_amt_smtl", "0") or "0" balance_info.get("frcr_buy_amt_smtl", "0") or "0"
) )
# Paper mode fallback: VTS overseas balance API often fails for many accounts. # Paper mode fallback: VTS overseas balance API often fails for many accounts.
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0: # Only activate in paper mode — live mode must use real balance from KIS.
total_cash = settings.PAPER_OVERSEAS_CASH if (
total_cash <= 0
# VTS overseas balance API often returns 0; use paper fallback. and settings.MODE == "paper"
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0: and settings.PAPER_OVERSEAS_CASH > 0
):
total_cash = settings.PAPER_OVERSEAS_CASH total_cash = settings.PAPER_OVERSEAS_CASH
# Calculate daily P&L % # Calculate daily P&L %
@@ -1076,6 +1157,33 @@ async def run_daily_session(
decision.confidence, decision.confidence,
) )
# BUY 중복 방지: 브로커 잔고 기반 (미체결 SELL 리밋 주문 보호)
if decision.action == "BUY":
daily_existing = get_open_position(db_conn, stock_code, market.code)
if not daily_existing and not market.is_domestic:
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
# 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
broker_qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=False
)
if broker_qty > 0:
daily_existing = {"price": 0.0, "quantity": broker_qty}
if daily_existing:
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=(
f"Already holding {stock_code} "
f"(entry={daily_existing['price']:.4f}, "
f"qty={daily_existing['quantity']})"
),
)
logger.info(
"BUY suppressed for %s (%s): already holding open position",
stock_code,
market.name,
)
# Log decision # Log decision
context_snapshot = { context_snapshot = {
"L1": { "L1": {
@@ -1283,6 +1391,7 @@ async def run_daily_session(
market=market.code, market=market.code,
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
decision_id=decision_id, decision_id=decision_id,
mode=settings.MODE,
) )
logger.info("Daily trading session completed") logger.info("Daily trading session completed")
@@ -1944,6 +2053,10 @@ async def run(settings: Settings) -> None:
) )
except CircuitBreakerTripped: except CircuitBreakerTripped:
logger.critical("Circuit breaker tripped — shutting down") logger.critical("Circuit breaker tripped — shutting down")
await telegram.notify_circuit_breaker(
pnl_pct=settings.CIRCUIT_BREAKER_PCT,
threshold=settings.CIRCUIT_BREAKER_PCT,
)
shutdown.set() shutdown.set()
break break
except Exception as exc: except Exception as exc:
@@ -2261,6 +2374,8 @@ async def run(settings: Settings) -> None:
except TimeoutError: except TimeoutError:
pass # Normal — timeout means it's time for next cycle pass # Normal — timeout means it's time for next cycle
finally: finally:
# Notify shutdown before closing resources
await telegram.notify_system_shutdown("Normal shutdown")
# Clean up resources # Clean up resources
await command_handler.stop_polling() await command_handler.stop_polling()
await broker.close() await broker.close()

View File

@@ -3,9 +3,11 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
import sys
import tempfile import tempfile
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -363,3 +365,435 @@ class TestHealthMonitor:
assert "timestamp" in report assert "timestamp" in report
assert "checks" in report assert "checks" in report
assert len(report["checks"]) == 3 assert len(report["checks"]) == 3
# ---------------------------------------------------------------------------
# BackupExporter — additional coverage for previously uncovered branches
# ---------------------------------------------------------------------------
@pytest.fixture
def empty_db(tmp_path: Path) -> Path:
"""Create a temporary database with NO trade records."""
db_path = tmp_path / "empty_trades.db"
conn = sqlite3.connect(str(db_path))
conn.execute(
"""CREATE TABLE trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
stock_code TEXT NOT NULL,
action TEXT NOT NULL,
quantity INTEGER NOT NULL,
price REAL NOT NULL,
confidence INTEGER NOT NULL,
rationale TEXT,
pnl REAL DEFAULT 0.0
)"""
)
conn.commit()
conn.close()
return db_path
class TestBackupExporterAdditional:
"""Cover branches missed in the original TestBackupExporter suite."""
def test_export_all_default_formats(self, temp_db: Path, tmp_path: Path) -> None:
"""export_all with formats=None must default to JSON+CSV+Parquet path."""
exporter = BackupExporter(str(temp_db))
# formats=None triggers the default list assignment (line 62)
results = exporter.export_all(tmp_path / "out", formats=None, compress=False)
# JSON and CSV must always succeed; Parquet needs pyarrow
assert ExportFormat.JSON in results
assert ExportFormat.CSV in results
def test_export_all_logs_error_on_failure(
self, temp_db: Path, tmp_path: Path
) -> None:
"""export_all must log an error and continue when one format fails."""
exporter = BackupExporter(str(temp_db))
# Patch _export_format to raise on JSON, succeed on CSV
original = exporter._export_format
def failing_export(fmt, *args, **kwargs): # type: ignore[no-untyped-def]
if fmt == ExportFormat.JSON:
raise RuntimeError("simulated failure")
return original(fmt, *args, **kwargs)
exporter._export_format = failing_export # type: ignore[method-assign]
results = exporter.export_all(
tmp_path / "out",
formats=[ExportFormat.JSON, ExportFormat.CSV],
compress=False,
)
# JSON failed → not in results; CSV succeeded → in results
assert ExportFormat.JSON not in results
assert ExportFormat.CSV in results
def test_export_csv_empty_trades_no_compress(
self, empty_db: Path, tmp_path: Path
) -> None:
"""CSV export with no trades and compress=False must write header row only."""
exporter = BackupExporter(str(empty_db))
results = exporter.export_all(
tmp_path / "out",
formats=[ExportFormat.CSV],
compress=False,
)
assert ExportFormat.CSV in results
out = results[ExportFormat.CSV]
assert out.exists()
content = out.read_text()
assert "timestamp" in content
def test_export_csv_empty_trades_compressed(
self, empty_db: Path, tmp_path: Path
) -> None:
"""CSV export with no trades and compress=True must write gzipped header."""
import gzip
exporter = BackupExporter(str(empty_db))
results = exporter.export_all(
tmp_path / "out",
formats=[ExportFormat.CSV],
compress=True,
)
assert ExportFormat.CSV in results
out = results[ExportFormat.CSV]
assert out.suffix == ".gz"
with gzip.open(out, "rt", encoding="utf-8") as f:
content = f.read()
assert "timestamp" in content
def test_export_csv_with_data_compressed(
self, temp_db: Path, tmp_path: Path
) -> None:
"""CSV export with data and compress=True must write gzipped rows."""
import gzip
exporter = BackupExporter(str(temp_db))
results = exporter.export_all(
tmp_path / "out",
formats=[ExportFormat.CSV],
compress=True,
)
assert ExportFormat.CSV in results
out = results[ExportFormat.CSV]
with gzip.open(out, "rt", encoding="utf-8") as f:
lines = f.readlines()
# Header + 3 data rows
assert len(lines) == 4
def test_export_parquet_raises_import_error_without_pyarrow(
self, temp_db: Path, tmp_path: Path
) -> None:
"""Parquet export must raise ImportError when pyarrow is not installed."""
exporter = BackupExporter(str(temp_db))
with patch.dict(sys.modules, {"pyarrow": None, "pyarrow.parquet": None}):
try:
import pyarrow # noqa: F401
pytest.skip("pyarrow is installed; cannot test ImportError path")
except ImportError:
pass
results = exporter.export_all(
tmp_path / "out",
formats=[ExportFormat.PARQUET],
compress=False,
)
# Parquet export fails gracefully; result dict should not contain it
assert ExportFormat.PARQUET not in results
# ---------------------------------------------------------------------------
# CloudStorage — mocked boto3 tests
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_boto3_module():
"""Inject a fake boto3 into sys.modules for the duration of the test."""
mock = MagicMock()
with patch.dict(sys.modules, {"boto3": mock}):
yield mock
@pytest.fixture
def s3_config():
"""Minimal S3Config for tests."""
from src.backup.cloud_storage import S3Config
return S3Config(
endpoint_url="http://localhost:9000",
access_key="minioadmin",
secret_key="minioadmin",
bucket_name="test-bucket",
region="us-east-1",
)
class TestCloudStorage:
"""Test CloudStorage using mocked boto3."""
def test_init_creates_s3_client(self, mock_boto3_module, s3_config) -> None:
"""CloudStorage.__init__ must call boto3.client with the correct args."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
mock_boto3_module.client.assert_called_once()
call_kwargs = mock_boto3_module.client.call_args[1]
assert call_kwargs["aws_access_key_id"] == "minioadmin"
assert call_kwargs["aws_secret_access_key"] == "minioadmin"
assert storage.config == s3_config
def test_init_raises_if_boto3_missing(self, s3_config) -> None:
"""CloudStorage.__init__ must raise ImportError when boto3 is absent."""
with patch.dict(sys.modules, {"boto3": None}): # type: ignore[dict-item]
with pytest.raises((ImportError, TypeError)):
# Re-import to trigger the try/except inside __init__
import importlib
import src.backup.cloud_storage as m
importlib.reload(m)
m.CloudStorage(s3_config)
def test_upload_file_success(
self, mock_boto3_module, s3_config, tmp_path: Path
) -> None:
"""upload_file must call client.upload_file and return the object key."""
from src.backup.cloud_storage import CloudStorage
test_file = tmp_path / "backup.json.gz"
test_file.write_bytes(b"data")
storage = CloudStorage(s3_config)
key = storage.upload_file(test_file, object_key="backups/backup.json.gz")
assert key == "backups/backup.json.gz"
storage.client.upload_file.assert_called_once()
def test_upload_file_default_key(
self, mock_boto3_module, s3_config, tmp_path: Path
) -> None:
"""upload_file without object_key must use the filename as key."""
from src.backup.cloud_storage import CloudStorage
test_file = tmp_path / "myfile.gz"
test_file.write_bytes(b"data")
storage = CloudStorage(s3_config)
key = storage.upload_file(test_file)
assert key == "myfile.gz"
def test_upload_file_not_found(
self, mock_boto3_module, s3_config, tmp_path: Path
) -> None:
"""upload_file must raise FileNotFoundError for missing files."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
with pytest.raises(FileNotFoundError):
storage.upload_file(tmp_path / "nonexistent.gz")
def test_upload_file_propagates_client_error(
self, mock_boto3_module, s3_config, tmp_path: Path
) -> None:
"""upload_file must re-raise exceptions from the boto3 client."""
from src.backup.cloud_storage import CloudStorage
test_file = tmp_path / "backup.gz"
test_file.write_bytes(b"data")
storage = CloudStorage(s3_config)
storage.client.upload_file.side_effect = RuntimeError("network error")
with pytest.raises(RuntimeError, match="network error"):
storage.upload_file(test_file)
def test_download_file_success(
self, mock_boto3_module, s3_config, tmp_path: Path
) -> None:
"""download_file must call client.download_file and return local path."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
dest = tmp_path / "downloads" / "backup.gz"
result = storage.download_file("backups/backup.gz", dest)
assert result == dest
storage.client.download_file.assert_called_once()
def test_download_file_propagates_error(
self, mock_boto3_module, s3_config, tmp_path: Path
) -> None:
"""download_file must re-raise exceptions from the boto3 client."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
storage.client.download_file.side_effect = RuntimeError("timeout")
with pytest.raises(RuntimeError, match="timeout"):
storage.download_file("key", tmp_path / "dest.gz")
def test_list_files_returns_objects(
self, mock_boto3_module, s3_config
) -> None:
"""list_files must return parsed file metadata from S3 response."""
from datetime import timezone
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
storage.client.list_objects_v2.return_value = {
"Contents": [
{
"Key": "backups/a.gz",
"Size": 1024,
"LastModified": datetime(2026, 1, 1, tzinfo=timezone.utc),
"ETag": '"abc123"',
}
]
}
files = storage.list_files(prefix="backups/")
assert len(files) == 1
assert files[0]["key"] == "backups/a.gz"
assert files[0]["size_bytes"] == 1024
def test_list_files_empty_bucket(
self, mock_boto3_module, s3_config
) -> None:
"""list_files must return empty list when bucket has no objects."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
storage.client.list_objects_v2.return_value = {}
files = storage.list_files()
assert files == []
def test_list_files_propagates_error(
self, mock_boto3_module, s3_config
) -> None:
"""list_files must re-raise exceptions from the boto3 client."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
storage.client.list_objects_v2.side_effect = RuntimeError("auth error")
with pytest.raises(RuntimeError):
storage.list_files()
def test_delete_file_success(
self, mock_boto3_module, s3_config
) -> None:
"""delete_file must call client.delete_object with the correct key."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
storage.delete_file("backups/old.gz")
storage.client.delete_object.assert_called_once_with(
Bucket="test-bucket", Key="backups/old.gz"
)
def test_delete_file_propagates_error(
self, mock_boto3_module, s3_config
) -> None:
"""delete_file must re-raise exceptions from the boto3 client."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
storage.client.delete_object.side_effect = RuntimeError("permission denied")
with pytest.raises(RuntimeError):
storage.delete_file("backups/old.gz")
def test_get_storage_stats_success(
self, mock_boto3_module, s3_config
) -> None:
"""get_storage_stats must aggregate file sizes correctly."""
from datetime import timezone
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
storage.client.list_objects_v2.return_value = {
"Contents": [
{
"Key": "a.gz",
"Size": 1024 * 1024,
"LastModified": datetime(2026, 1, 1, tzinfo=timezone.utc),
"ETag": '"x"',
},
{
"Key": "b.gz",
"Size": 1024 * 1024,
"LastModified": datetime(2026, 1, 2, tzinfo=timezone.utc),
"ETag": '"y"',
},
]
}
stats = storage.get_storage_stats()
assert stats["total_files"] == 2
assert stats["total_size_bytes"] == 2 * 1024 * 1024
assert stats["total_size_mb"] == pytest.approx(2.0)
def test_get_storage_stats_on_error(
self, mock_boto3_module, s3_config
) -> None:
"""get_storage_stats must return error dict without raising on failure."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
storage.client.list_objects_v2.side_effect = RuntimeError("no connection")
stats = storage.get_storage_stats()
assert "error" in stats
assert stats["total_files"] == 0
def test_verify_connection_success(
self, mock_boto3_module, s3_config
) -> None:
"""verify_connection must return True when head_bucket succeeds."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
result = storage.verify_connection()
assert result is True
def test_verify_connection_failure(
self, mock_boto3_module, s3_config
) -> None:
"""verify_connection must return False when head_bucket raises."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
storage.client.head_bucket.side_effect = RuntimeError("no such bucket")
result = storage.verify_connection()
assert result is False
def test_enable_versioning(
self, mock_boto3_module, s3_config
) -> None:
"""enable_versioning must call put_bucket_versioning."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
storage.enable_versioning()
storage.client.put_bucket_versioning.assert_called_once()
def test_enable_versioning_propagates_error(
self, mock_boto3_module, s3_config
) -> None:
"""enable_versioning must re-raise exceptions from the boto3 client."""
from src.backup.cloud_storage import CloudStorage
storage = CloudStorage(s3_config)
storage.client.put_bucket_versioning.side_effect = RuntimeError("denied")
with pytest.raises(RuntimeError):
storage.enable_versioning()

View File

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

View File

@@ -10,6 +10,7 @@ import pytest
from src.context.aggregator import ContextAggregator from src.context.aggregator import ContextAggregator
from src.context.layer import LAYER_CONFIG, ContextLayer from src.context.layer import LAYER_CONFIG, ContextLayer
from src.context.store import ContextStore from src.context.store import ContextStore
from src.context.summarizer import ContextSummarizer
from src.db import init_db, log_trade from src.db import init_db, log_trade
@@ -370,3 +371,259 @@ class TestLayerMetadata:
# L1 aggregates from L2 # L1 aggregates from L2
assert LAYER_CONFIG[ContextLayer.L1_LEGACY].aggregation_source == ContextLayer.L2_ANNUAL assert LAYER_CONFIG[ContextLayer.L1_LEGACY].aggregation_source == ContextLayer.L2_ANNUAL
# ---------------------------------------------------------------------------
# ContextSummarizer tests
# ---------------------------------------------------------------------------
@pytest.fixture
def summarizer(db_conn: sqlite3.Connection) -> ContextSummarizer:
"""Provide a ContextSummarizer backed by an in-memory store."""
return ContextSummarizer(ContextStore(db_conn))
class TestContextSummarizer:
"""Test suite for ContextSummarizer."""
# ------------------------------------------------------------------
# summarize_numeric_values
# ------------------------------------------------------------------
def test_summarize_empty_values(self, summarizer: ContextSummarizer) -> None:
"""Empty list must return SummaryStats with count=0 and no other fields."""
stats = summarizer.summarize_numeric_values([])
assert stats.count == 0
assert stats.mean is None
assert stats.min is None
assert stats.max is None
def test_summarize_single_value(self, summarizer: ContextSummarizer) -> None:
"""Single-element list must return correct stats with std=0 and trend=flat."""
stats = summarizer.summarize_numeric_values([42.0])
assert stats.count == 1
assert stats.mean == 42.0
assert stats.std == 0.0
assert stats.trend == "flat"
def test_summarize_upward_trend(self, summarizer: ContextSummarizer) -> None:
"""Increasing values must produce trend='up'."""
values = [1.0, 2.0, 3.0, 10.0, 20.0, 30.0]
stats = summarizer.summarize_numeric_values(values)
assert stats.trend == "up"
def test_summarize_downward_trend(self, summarizer: ContextSummarizer) -> None:
"""Decreasing values must produce trend='down'."""
values = [30.0, 20.0, 10.0, 3.0, 2.0, 1.0]
stats = summarizer.summarize_numeric_values(values)
assert stats.trend == "down"
def test_summarize_flat_trend(self, summarizer: ContextSummarizer) -> None:
"""Stable values must produce trend='flat'."""
values = [100.0, 100.1, 99.9, 100.0, 100.2, 99.8]
stats = summarizer.summarize_numeric_values(values)
assert stats.trend == "flat"
# ------------------------------------------------------------------
# summarize_layer
# ------------------------------------------------------------------
def test_summarize_layer_no_data(
self, summarizer: ContextSummarizer
) -> None:
"""summarize_layer with no data must return the 'No data' sentinel."""
result = summarizer.summarize_layer(ContextLayer.L6_DAILY)
assert result["count"] == 0
assert "No data" in result["summary"]
def test_summarize_layer_numeric(
self, summarizer: ContextSummarizer, db_conn: sqlite3.Connection
) -> None:
"""summarize_layer must collect numeric values and produce stats."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "total_pnl", 100.0)
store.set_context(ContextLayer.L6_DAILY, "2026-02-02", "total_pnl", 200.0)
result = summarizer.summarize_layer(ContextLayer.L6_DAILY)
assert "total_entries" in result
def test_summarize_layer_with_dict_values(
self, summarizer: ContextSummarizer
) -> None:
"""summarize_layer must handle dict values by extracting numeric subkeys."""
store = summarizer.store
# set_context serialises the value as JSON, so passing a dict works
store.set_context(
ContextLayer.L6_DAILY, "2026-02-01", "metrics",
{"win_rate": 65.0, "label": "good"}
)
result = summarizer.summarize_layer(ContextLayer.L6_DAILY)
assert "total_entries" in result
# numeric subkey "win_rate" should appear as "metrics.win_rate"
assert "metrics.win_rate" in result
def test_summarize_layer_with_string_values(
self, summarizer: ContextSummarizer
) -> None:
"""summarize_layer must count string values separately."""
store = summarizer.store
# set_context stores string values as JSON-encoded strings
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "outlook", "BULLISH")
result = summarizer.summarize_layer(ContextLayer.L6_DAILY)
# String fields contribute a `<key>_count` entry
assert "outlook_count" in result
# ------------------------------------------------------------------
# rolling_window_summary
# ------------------------------------------------------------------
def test_rolling_window_summary_basic(
self, summarizer: ContextSummarizer
) -> None:
"""rolling_window_summary must return the expected structure."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "pnl", 500.0)
result = summarizer.rolling_window_summary(ContextLayer.L6_DAILY)
assert "window_days" in result
assert "recent_data" in result
assert "historical_summary" in result
def test_rolling_window_summary_no_older_data(
self, summarizer: ContextSummarizer
) -> None:
"""rolling_window_summary with summarize_older=False skips history."""
result = summarizer.rolling_window_summary(
ContextLayer.L6_DAILY, summarize_older=False
)
assert result["historical_summary"] == {}
# ------------------------------------------------------------------
# aggregate_to_higher_layer
# ------------------------------------------------------------------
def test_aggregate_to_higher_layer_mean(
self, summarizer: ContextSummarizer
) -> None:
"""aggregate_to_higher_layer with 'mean' via dict subkeys returns average."""
store = summarizer.store
# Use different outer keys but same inner metric key so get_all_contexts
# returns multiple rows with the target subkey.
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day1", {"pnl": 100.0})
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day2", {"pnl": 200.0})
result = summarizer.aggregate_to_higher_layer(
ContextLayer.L6_DAILY, ContextLayer.L5_WEEKLY, "pnl", "mean"
)
assert result == pytest.approx(150.0)
def test_aggregate_to_higher_layer_sum(
self, summarizer: ContextSummarizer
) -> None:
"""aggregate_to_higher_layer with 'sum' must return the total."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day1", {"pnl": 100.0})
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day2", {"pnl": 200.0})
result = summarizer.aggregate_to_higher_layer(
ContextLayer.L6_DAILY, ContextLayer.L5_WEEKLY, "pnl", "sum"
)
assert result == pytest.approx(300.0)
def test_aggregate_to_higher_layer_max(
self, summarizer: ContextSummarizer
) -> None:
"""aggregate_to_higher_layer with 'max' must return the maximum."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day1", {"pnl": 100.0})
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day2", {"pnl": 200.0})
result = summarizer.aggregate_to_higher_layer(
ContextLayer.L6_DAILY, ContextLayer.L5_WEEKLY, "pnl", "max"
)
assert result == pytest.approx(200.0)
def test_aggregate_to_higher_layer_min(
self, summarizer: ContextSummarizer
) -> None:
"""aggregate_to_higher_layer with 'min' must return the minimum."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day1", {"pnl": 100.0})
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day2", {"pnl": 200.0})
result = summarizer.aggregate_to_higher_layer(
ContextLayer.L6_DAILY, ContextLayer.L5_WEEKLY, "pnl", "min"
)
assert result == pytest.approx(100.0)
def test_aggregate_to_higher_layer_no_data(
self, summarizer: ContextSummarizer
) -> None:
"""aggregate_to_higher_layer with no matching key must return None."""
result = summarizer.aggregate_to_higher_layer(
ContextLayer.L6_DAILY, ContextLayer.L5_WEEKLY, "nonexistent", "mean"
)
assert result is None
def test_aggregate_to_higher_layer_unknown_func_defaults_to_mean(
self, summarizer: ContextSummarizer
) -> None:
"""Unknown aggregation function must fall back to mean."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day1", {"pnl": 100.0})
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day2", {"pnl": 200.0})
result = summarizer.aggregate_to_higher_layer(
ContextLayer.L6_DAILY, ContextLayer.L5_WEEKLY, "pnl", "unknown_func"
)
assert result == pytest.approx(150.0)
# ------------------------------------------------------------------
# create_compact_summary + format_summary_for_prompt
# ------------------------------------------------------------------
def test_create_compact_summary(
self, summarizer: ContextSummarizer
) -> None:
"""create_compact_summary must produce a dict keyed by layer value."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "pnl", 100.0)
result = summarizer.create_compact_summary([ContextLayer.L6_DAILY])
assert ContextLayer.L6_DAILY.value in result
def test_format_summary_for_prompt_with_numeric_metrics(
self, summarizer: ContextSummarizer
) -> None:
"""format_summary_for_prompt must render avg/trend fields."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "pnl", 100.0)
store.set_context(ContextLayer.L6_DAILY, "2026-02-02", "pnl", 200.0)
compact = summarizer.create_compact_summary([ContextLayer.L6_DAILY])
text = summarizer.format_summary_for_prompt(compact)
assert isinstance(text, str)
def test_format_summary_for_prompt_skips_empty_layers(
self, summarizer: ContextSummarizer
) -> None:
"""format_summary_for_prompt must skip layers with no metrics."""
summary = {ContextLayer.L6_DAILY.value: {}}
text = summarizer.format_summary_for_prompt(summary)
assert text == ""
def test_format_summary_non_dict_value(
self, summarizer: ContextSummarizer
) -> None:
"""format_summary_for_prompt must render non-dict values as plain text."""
summary = {
"daily": {
"plain_count": 42,
}
}
text = summarizer.format_summary_for_prompt(summary)
assert "plain_count" in text
assert "42" in text

View File

@@ -1,5 +1,8 @@
"""Tests for database helper functions.""" """Tests for database helper functions."""
import tempfile
import os
from src.db import get_open_position, init_db, log_trade from src.db import get_open_position, init_db, log_trade
@@ -58,3 +61,135 @@ def test_get_open_position_returns_none_when_latest_is_sell() -> None:
def test_get_open_position_returns_none_when_no_trades() -> None: def test_get_open_position_returns_none_when_no_trades() -> None:
conn = init_db(":memory:") conn = init_db(":memory:")
assert get_open_position(conn, "AAPL", "US_NASDAQ") is None assert get_open_position(conn, "AAPL", "US_NASDAQ") is None
# ---------------------------------------------------------------------------
# WAL mode tests (issue #210)
# ---------------------------------------------------------------------------
def test_wal_mode_applied_to_file_db() -> None:
"""File-based DB must use WAL journal mode for dashboard concurrent reads."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
conn = init_db(db_path)
cursor = conn.execute("PRAGMA journal_mode")
mode = cursor.fetchone()[0]
assert mode == "wal", f"Expected WAL mode, got {mode}"
conn.close()
finally:
os.unlink(db_path)
# Clean up WAL auxiliary files if they exist
for ext in ("-wal", "-shm"):
path = db_path + ext
if os.path.exists(path):
os.unlink(path)
def test_wal_mode_not_applied_to_memory_db() -> None:
""":memory: DB must not apply WAL (SQLite does not support WAL for in-memory)."""
conn = init_db(":memory:")
cursor = conn.execute("PRAGMA journal_mode")
mode = cursor.fetchone()[0]
# In-memory DBs default to 'memory' journal mode
assert mode != "wal", "WAL should not be set on in-memory database"
conn.close()
# ---------------------------------------------------------------------------
# mode column tests (issue #212)
# ---------------------------------------------------------------------------
def test_log_trade_stores_mode_paper() -> None:
"""log_trade must persist mode='paper' in the trades table."""
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="005930",
action="BUY",
confidence=85,
rationale="test",
mode="paper",
)
row = conn.execute("SELECT mode FROM trades ORDER BY id DESC LIMIT 1").fetchone()
assert row is not None
assert row[0] == "paper"
def test_log_trade_stores_mode_live() -> None:
"""log_trade must persist mode='live' in the trades table."""
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="005930",
action="BUY",
confidence=85,
rationale="test",
mode="live",
)
row = conn.execute("SELECT mode FROM trades ORDER BY id DESC LIMIT 1").fetchone()
assert row is not None
assert row[0] == "live"
def test_log_trade_default_mode_is_paper() -> None:
"""log_trade without explicit mode must default to 'paper'."""
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="005930",
action="HOLD",
confidence=50,
rationale="test",
)
row = conn.execute("SELECT mode FROM trades ORDER BY id DESC LIMIT 1").fetchone()
assert row is not None
assert row[0] == "paper"
def test_mode_column_exists_in_schema() -> None:
"""trades table must have a mode column after init_db."""
conn = init_db(":memory:")
cursor = conn.execute("PRAGMA table_info(trades)")
columns = {row[1] for row in cursor.fetchall()}
assert "mode" in columns
def test_mode_migration_adds_column_to_existing_db() -> None:
"""init_db must add mode column to existing DBs that lack it (migration)."""
import sqlite3
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
# Create DB without mode column (simulate old schema)
old_conn = sqlite3.connect(db_path)
old_conn.execute(
"""CREATE TABLE trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
stock_code TEXT NOT NULL,
action TEXT NOT NULL,
confidence INTEGER NOT NULL,
rationale TEXT,
quantity INTEGER,
price REAL,
pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX',
decision_id TEXT
)"""
)
old_conn.commit()
old_conn.close()
# Run init_db — should add mode column via migration
conn = init_db(db_path)
cursor = conn.execute("PRAGMA table_info(trades)")
columns = {row[1] for row in cursor.fetchall()}
assert "mode" in columns
conn.close()
finally:
os.unlink(db_path)

View File

@@ -0,0 +1,117 @@
"""Tests for JSON structured logging configuration."""
from __future__ import annotations
import json
import logging
import sys
from src.logging_config import JSONFormatter, setup_logging
class TestJSONFormatter:
"""Test JSONFormatter output."""
def test_basic_log_record(self) -> None:
"""JSONFormatter must emit valid JSON with required fields."""
formatter = JSONFormatter()
record = logging.LogRecord(
name="test.logger",
level=logging.INFO,
pathname="",
lineno=0,
msg="Hello %s",
args=("world",),
exc_info=None,
)
output = formatter.format(record)
data = json.loads(output)
assert data["level"] == "INFO"
assert data["logger"] == "test.logger"
assert data["message"] == "Hello world"
assert "timestamp" in data
def test_includes_exception_info(self) -> None:
"""JSONFormatter must include exception info when present."""
formatter = JSONFormatter()
try:
raise ValueError("test error")
except ValueError:
exc_info = sys.exc_info()
record = logging.LogRecord(
name="test",
level=logging.ERROR,
pathname="",
lineno=0,
msg="oops",
args=(),
exc_info=exc_info,
)
output = formatter.format(record)
data = json.loads(output)
assert "exception" in data
assert "ValueError" in data["exception"]
def test_extra_trading_fields_included(self) -> None:
"""Extra trading fields attached to the record must appear in JSON."""
formatter = JSONFormatter()
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="trade",
args=(),
exc_info=None,
)
record.stock_code = "005930" # type: ignore[attr-defined]
record.action = "BUY" # type: ignore[attr-defined]
record.confidence = 85 # type: ignore[attr-defined]
record.pnl_pct = -1.5 # type: ignore[attr-defined]
record.order_amount = 1_000_000 # type: ignore[attr-defined]
output = formatter.format(record)
data = json.loads(output)
assert data["stock_code"] == "005930"
assert data["action"] == "BUY"
assert data["confidence"] == 85
assert data["pnl_pct"] == -1.5
assert data["order_amount"] == 1_000_000
def test_none_extra_fields_excluded(self) -> None:
"""Extra fields that are None must not appear in JSON output."""
formatter = JSONFormatter()
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="no extras",
args=(),
exc_info=None,
)
output = formatter.format(record)
data = json.loads(output)
assert "stock_code" not in data
assert "action" not in data
assert "confidence" not in data
class TestSetupLogging:
"""Test setup_logging function."""
def test_configures_root_logger(self) -> None:
"""setup_logging must attach a JSON handler to the root logger."""
setup_logging(level=logging.DEBUG)
root = logging.getLogger()
json_handlers = [
h for h in root.handlers if isinstance(h.formatter, JSONFormatter)
]
assert len(json_handlers) == 1
assert root.level == logging.DEBUG
def test_avoids_duplicate_handlers(self) -> None:
"""Calling setup_logging twice must not add duplicate handlers."""
setup_logging()
setup_logging()
root = logging.getLogger()
assert len(root.handlers) == 1

View File

@@ -18,6 +18,7 @@ from src.main import (
_extract_held_codes_from_balance, _extract_held_codes_from_balance,
_extract_held_qty_from_balance, _extract_held_qty_from_balance,
_handle_market_close, _handle_market_close,
_retry_connection,
_run_context_scheduler, _run_context_scheduler,
_run_evolution_loop, _run_evolution_loop,
_start_dashboard_server, _start_dashboard_server,
@@ -3001,3 +3002,272 @@ async def test_buy_proceeds_when_no_open_position() -> None:
# 포지션이 없으므로 해외 주문이 실행되어야 함 # 포지션이 없으므로 해외 주문이 실행되어야 함
overseas_broker.send_overseas_order.assert_called_once() overseas_broker.send_overseas_order.assert_called_once()
class TestOverseasBrokerIntegration:
"""Test overseas broker live-balance gating for double-buy prevention.
Issue #195: KIS VTS SELL limit orders are accepted (rt_cd=0) immediately
but may not fill until the market price reaches the limit. During this window,
the DB records the position as closed, causing the next cycle to BUY again.
These tests verify that live broker balance is used as the authoritative source.
"""
@pytest.mark.asyncio
async def test_overseas_buy_suppressed_by_broker_balance_when_db_shows_closed(
self,
) -> None:
"""BUY must be suppressed when broker still holds shares even if DB says closed.
Scenario: SELL limit order was accepted (DB shows closed), but hasn't
filled yet — broker balance still shows 10 AAPL shares.
Expected: send_overseas_order is NOT called.
"""
db_conn = init_db(":memory:")
# DB: BUY then SELL recorded → get_open_position returns None (closed)
log_trade(
conn=db_conn,
stock_code="AAPL",
action="BUY",
confidence=90,
rationale="entry",
quantity=10,
price=180.0,
market="US_NASDAQ",
exchange_code="NASD",
)
log_trade(
conn=db_conn,
stock_code="AAPL",
action="SELL",
confidence=90,
rationale="sell order accepted",
quantity=10,
price=182.0,
market="US_NASDAQ",
exchange_code="NASD",
)
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "182.50"}}
)
# 브로커: 여전히 AAPL 10주 보유 중 (SELL 미체결)
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}],
"output2": [
{
"frcr_dncl_amt_2": "50000.00",
"frcr_evlu_tota": "60000.00",
"frcr_buy_amt_smtl": "50000.00",
}
],
}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL"))
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
await trading_cycle(
broker=MagicMock(),
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US"),
risk=MagicMock(),
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code="AAPL",
scan_candidates={},
)
# 브로커 잔고에 보유 중이므로 BUY 주문이 억제되어야 함 (이중 매수 방지)
overseas_broker.send_overseas_order.assert_not_called()
@pytest.mark.asyncio
async def test_overseas_buy_proceeds_when_broker_shows_no_holding(
self,
) -> None:
"""BUY must proceed when both DB and broker confirm no existing holding.
Scenario: No prior trades in DB and broker balance shows no AAPL.
Expected: send_overseas_order IS called (normal buy flow).
"""
db_conn = init_db(":memory:")
# DB: 레코드 없음 (신규 포지션)
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "182.50"}}
)
# 브로커: AAPL 미보유
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [],
"output2": [
{
"frcr_dncl_amt_2": "50000.00",
"frcr_evlu_tota": "50000.00",
"frcr_buy_amt_smtl": "0.00",
}
],
}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL"))
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=MagicMock(),
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US"),
risk=MagicMock(),
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code="AAPL",
scan_candidates={},
)
# DB도 브로커도 보유 없음 → BUY 주문이 실행되어야 함 (회귀 테스트)
overseas_broker.send_overseas_order.assert_called_once()
# ---------------------------------------------------------------------------
# _retry_connection — unit tests (issue #209)
# ---------------------------------------------------------------------------
class TestRetryConnection:
"""Unit tests for the _retry_connection helper (issue #209)."""
@pytest.mark.asyncio
async def test_success_on_first_attempt(self) -> None:
"""Returns the result immediately when the first call succeeds."""
async def ok() -> str:
return "data"
result = await _retry_connection(ok, label="test")
assert result == "data"
@pytest.mark.asyncio
async def test_succeeds_after_one_connection_error(self) -> None:
"""Retries once on ConnectionError and returns result on 2nd attempt."""
call_count = 0
async def flaky() -> str:
nonlocal call_count
call_count += 1
if call_count < 2:
raise ConnectionError("timeout")
return "ok"
with patch("src.main.asyncio.sleep") as mock_sleep:
mock_sleep.return_value = None
result = await _retry_connection(flaky, label="flaky")
assert result == "ok"
assert call_count == 2
mock_sleep.assert_called_once()
@pytest.mark.asyncio
async def test_raises_after_all_retries_exhausted(self) -> None:
"""Raises ConnectionError after MAX_CONNECTION_RETRIES attempts."""
from src.main import MAX_CONNECTION_RETRIES
call_count = 0
async def always_fail() -> None:
nonlocal call_count
call_count += 1
raise ConnectionError("unreachable")
with patch("src.main.asyncio.sleep") as mock_sleep:
mock_sleep.return_value = None
with pytest.raises(ConnectionError, match="unreachable"):
await _retry_connection(always_fail, label="always_fail")
assert call_count == MAX_CONNECTION_RETRIES
@pytest.mark.asyncio
async def test_passes_args_and_kwargs_to_factory(self) -> None:
"""Forwards positional and keyword arguments to the callable."""
received: dict = {}
async def capture(a: int, b: int, *, key: str) -> str:
received["a"] = a
received["b"] = b
received["key"] = key
return "captured"
result = await _retry_connection(capture, 1, 2, key="val", label="test")
assert result == "captured"
assert received == {"a": 1, "b": 2, "key": "val"}
@pytest.mark.asyncio
async def test_non_connection_error_not_retried(self) -> None:
"""Non-ConnectionError exceptions propagate immediately without retry."""
call_count = 0
async def bad_input() -> None:
nonlocal call_count
call_count += 1
raise ValueError("bad data")
with pytest.raises(ValueError, match="bad data"):
await _retry_connection(bad_input, label="bad")
assert call_count == 1 # No retry for non-ConnectionError

View File

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

View File

@@ -0,0 +1,32 @@
"""Tests for BaseStrategy abstract class."""
from __future__ import annotations
from typing import Any
import pytest
from src.strategies.base import BaseStrategy
class ConcreteStrategy(BaseStrategy):
"""Minimal concrete strategy for testing."""
def evaluate(self, market_data: dict[str, Any]) -> dict[str, Any]:
return {"action": "HOLD", "confidence": 50, "rationale": "test"}
def test_base_strategy_cannot_be_instantiated() -> None:
"""BaseStrategy cannot be instantiated directly (it's abstract)."""
with pytest.raises(TypeError):
BaseStrategy() # type: ignore[abstract]
def test_concrete_strategy_evaluate_returns_decision() -> None:
"""Concrete subclass must implement evaluate and return a dict."""
strategy = ConcreteStrategy()
result = strategy.evaluate({"close": [100.0, 101.0]})
assert isinstance(result, dict)
assert result["action"] == "HOLD"
assert result["confidence"] == 50
assert "rationale" in result