Compare commits

...

91 Commits

Author SHA1 Message Date
25ad4776c9 Merge pull request 'feat: Daily CB P&L 기준을 당일 시작 평가금액으로 변경 (#207)' (#227) from feature/issue-207-daily-cb-pnl into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #227
2026-02-23 16:58:18 +09:00
agentson
9339824e22 feat: Daily CB P&L 기준을 당일 시작 평가금액으로 변경 (#207)
Some checks failed
CI / test (pull_request) Has been cancelled
- run_daily_session에 daily_start_eval 파라미터 추가 (반환 타입: float)
  - 세션 첫 잔고 조회 시 total_eval을 baseline으로 캡처
  - 이후 세션에서 pnl_pct = (total_eval - daily_start_eval) / daily_start_eval
  - 기존 purchase_total(누적) 기반 계산 제거
- run 함수 daily 루프에서 날짜 변경 시 baseline 리셋 (_cb_last_date 추적)
- early return 시 daily_start_eval 반환하도록 버그 수정 (None 반환 방지)
- TestDailyCBBaseline 클래스 4개 테스트 추가
  - no_markets: 0.0/기존값 그대로 반환
  - first session: total_eval을 baseline으로 캡처
  - subsequent session: 기존 baseline 유지 (덮어쓰기 방지)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 16:47:09 +09:00
e6eae6c6e0 Merge pull request 'docs: 모의→실전 전환 체크리스트 작성 (#218)' (#226) from feature/issue-218-live-trading-docs into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #226
2026-02-23 15:01:01 +09:00
bb6bd0392e Merge pull request 'fix: GEMINI_MODEL 기본값 gemini-pro → gemini-2.0-flash (#217)' (#225) from feature/issue-217-gemini-model-default into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #225
2026-02-23 15:00:27 +09:00
a66181b7a7 Merge pull request 'fix: 진화 전략 파일 3개 IndentationError 수정 (#215)' (#224) from feature/issue-215-evolved-strategy-syntax into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #224
2026-02-23 14:59:51 +09:00
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
f7d33e69d1 docs: 실전 전환 체크리스트 작성 (issue #218)
Some checks failed
CI / test (pull_request) Has been cancelled
docs/live-trading-checklist.md 신규 작성:
- 사전 조건: KIS 실전 계좌/OpenAPI 신청, 리스크 파라미터 검토
- 환경 설정: .env 수정 가이드, TR_ID 분기표 (모의/실전)
- 최종 확인: DB 백업, 실행 명령, 시작 직후 점검
- 비상 정지: Ctrl+C / /stop 명령 / CB 발동
- 롤백 절차: MODE=paper 복원

CLAUDE.md: 문서 목록에 체크리스트 링크 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:55:37 +09:00
agentson
7d99d8ec4a fix: GEMINI_MODEL 기본값 'gemini-pro' → 'gemini-2.0-flash' (issue #217)
Some checks failed
CI / test (pull_request) Has been cancelled
'gemini-pro'는 deprecated 모델로 API 오류 발생 가능.
.env.example은 이미 gemini-2.0-flash-exp로 설정되어 있음.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:54:30 +09:00
agentson
0727f28f77 fix: 진화 전략 파일 3개 들여쓰기 구문 오류 수정 (issue #215)
Some checks failed
CI / test (pull_request) Has been cancelled
AI가 evaluate() 메서드 내부에 또 다른 evaluate() 함수를 중첩 정의하는
실수로 생성된 IndentationError 수정.

각 파일별 수정 내용:
- v20260220_210124_evolved.py: 중첩 def evaluate 제거, 상수/로직 8칸으로 정규화
- v20260220_210159_evolved.py: 중첩 def evaluate 제거, 16칸→8칸 들여쓰기 수정
- v20260220_210244_evolved.py: 12칸→8칸 들여쓰기 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:53:41 +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
agentson
ce82121f04 feat: 미구현 API 4개 대시보드 프론트 연결 (#198)
Some checks failed
CI / test (pull_request) Has been cancelled
- Playbook(/api/playbook/{date}): 프리마켓 플레이북 아코디언 패널 추가
- Scorecard(/api/scorecard/{date}): 일간 스코어카드 KPI 카드 그리드 추가
- Scenarios(/api/scenarios/active): 활성 시나리오 매칭 테이블 추가
- Context(/api/context/{layer}): L1-L7 컨텍스트 트리 테이블 추가

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 03:12:00 +09:00
agentson
f2ad270e8b docs: 2026-02-21 요구사항 로그 업데이트 (#187)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 00:34:16 +09:00
04c73a1a06 Merge pull request 'fix: SELL 주문에서 Fat Finger 오탐 수정 — 손절/익절 차단 버그 (#187)' (#188) from feature/issue-187-sell-fat-finger-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #188
2026-02-21 00:33:46 +09:00
agentson
4da22b10eb fix: SELL 주문에서 Fat Finger 오탐 수정 — 손절/익절 차단 버그 (#187)
Some checks failed
CI / test (pull_request) Has been cancelled
SELL 주문은 현금을 소비하지 않고 받는 것이므로 Fat Finger 체크 대상이
아님. 포지션 가치가 잔여 현금의 30%를 초과해도 SELL은 정상 실행돼야 함.

- realtime/daily 사이클 두 곳 모두 수정
- SELL: check_circuit_breaker만 호출 (Fat Finger 스킵)
- BUY: 기존대로 validate_order 호출 (Fat Finger + Circuit Breaker)
- 테스트 2개 추가: SELL Fat Finger 스킵, SELL 서킷브레이커 적용 확인

재현 사례 (2026-02-21):
  JELD stop-loss -6.20% → FAT FINGER: 49,548 is 99.1% of cash 50,000
  RXT take-profit +46.13% → FAT FINGER: 88,676 is 177.4% of cash 50,000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 00:32:11 +09:00
c920b257b6 Merge pull request 'improve: implied_rsi 포화 임계점 개선 12.5%→25% (#181)' (#186) from feature/issue-181-implied-rsi-saturation into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #186
2026-02-20 10:35:10 +09:00
9927bfa13e Merge pull request 'fix: Telegram 409 다중 인스턴스 충돌 시 WARNING + 30초 백오프 (#180)' (#185) from feature/issue-180-telegram-instance-lock into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #185
2026-02-20 09:52:15 +09:00
agentson
aceba86186 fix: Telegram 409 감지 시 백오프 대신 polling 즉시 종료 (#180)
Some checks failed
CI / test (pull_request) Has been cancelled
409 충돌 감지 시 30초 백오프 후 재시도하는 방식에서
_running = False로 polling을 즉시 중단하는 방식으로 변경.

다중 인스턴스가 실행 중인 경우 재시도는 의미 없고 충돌만 반복됨.
이제 409 발생 시 이 프로세스의 Telegram 명령어 polling을 완전히 비활성화.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:35:33 +09:00
agentson
b961c53a92 improve: implied_rsi 계수 4.0→2.0으로 완화 — 포화 임계점 12.5%→25% (#181)
Some checks failed
CI / test (pull_request) Has been cancelled
SmartScanner의 implied_rsi 공식에서 계수를 4.0에서 2.0으로 수정.
12.5% 이상 변동률에서 RSI=100으로 포화되던 문제를 개선.

변경 전: 50 + (change_rate * 4.0) → 12.5% 변동 시 RSI=100
변경 후: 50 + (change_rate * 2.0) → 25% 변동 시 RSI=100

이제 10% 상승 → RSI=70, 12.5% 상승 → RSI=75 (의미 있는 구분 가능)
해외 소형주(NYSE American 등)의 RSI=100 집단 현상 완화.

- smart_scanner.py 3곳 동일 공식 모두 수정
- TestImpliedRSIFormula 클래스 5개 테스트 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:33:35 +09:00
76a7ee7cdb Merge pull request 'fix: 잔액 부족 주문 실패 후 10분간 BUY 재시도 방지 (#179)' (#183) from feature/issue-179-insufficient-balance-cooldown into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #183
2026-02-20 09:31:08 +09:00
agentson
77577f3f4d fix: Telegram 409 충돌 시 WARNING 로그 + 30초 백오프 적용 (#180)
Some checks failed
CI / test (pull_request) Has been cancelled
다중 인스턴스 실행 시 Telegram getUpdates 409 응답을 ERROR가 아닌 WARNING으로
처리하고, 30초 동안 polling을 일시 중단하여 충돌을 완화.

- _conflict_backoff_until 속성 추가
- 409 감지 시 명확한 "another instance is polling" 메시지 출력
- poll_loop에서 백오프 활성 중 polling 스킵
- TestGetUpdates에 409 관련 테스트 2개 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:31:04 +09:00
17112b864a Merge pull request 'fix: uvicorn 미설치 시 dashboard 오해 없는 실패 처리 (#178)' (#184) from feature/issue-178-dashboard-log-order into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #184
2026-02-20 09:30:48 +09:00
agentson
28bcc7acd7 fix: uvicorn 미설치 시 dashboard 실패를 동기적으로 감지하여 오해 없는 로그 출력 (#178)
Some checks failed
CI / test (pull_request) Has been cancelled
스레드 시작 전에 uvicorn import를 검증하도록 _start_dashboard_server 수정.
uvicorn 미설치 시 "started" 로그 없이 즉시 WARNING 출력 후 None 반환.

- 사전 import 검증으로 "started" → "failed" 오해 소지 있는 로그 쌍 제거
- uvicorn 미설치 시 명확한 경고 메시지 출력
- test_start_dashboard_server_returns_none_when_uvicorn_missing 테스트 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:28:23 +09:00
agentson
39b9f179f4 fix: 잔액 부족 주문 실패 후 10분간 BUY 재시도 방지 (issue #179)
Some checks failed
CI / test (pull_request) Has been cancelled
잔액 부족(주문가능금액 부족) 에러 발생 시 해당 종목을 10분간 BUY 시도에서
제외하는 cooldown 메커니즘을 realtime/daily 루프 모두에 적용.

- _BUY_COOLDOWN_SECONDS = 600 상수 추가
- trading_cycle()에 buy_cooldown 파라미터 추가
- 잔액 부족 에러(주문가능금액) 감지 후 cooldown 설정
- BUY 실행 전 cooldown 체크 (realtime + daily session 모두)
- TestBuyCooldown 테스트 클래스 4개 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:26:09 +09:00
bd2b3241b2 Merge pull request 'feat: use market_outlook to adjust BUY confidence threshold (#173)' (#177) from feature/issue-173-market-outlook-threshold into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #177
2026-02-20 08:38:52 +09:00
561faaaafa Merge pull request 'feat: use playbook allocation_pct in position sizing (#172)' (#176) from feature/issue-172-playbook-allocation-sizing into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #176
2026-02-20 08:37:59 +09:00
a33d6a145f Merge pull request 'feat: add position-aware conditions to StockCondition (#171)' (#175) from feature/issue-171-position-aware-conditions into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #175
2026-02-20 08:36:07 +09:00
7e6c912214 Merge pull request 'feat: include current holdings in pre-market AI prompt (#170)' (#174) from feature/issue-170-holdings-in-prompt into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #174
2026-02-20 08:35:16 +09:00
agentson
d6edbc0fa2 feat: use market_outlook to adjust BUY confidence threshold (#173)
Some checks failed
CI / test (pull_request) Has been cancelled
- Import MarketOutlook at module level in main.py
- After scenario evaluation, check market_outlook and apply BUY confidence
  threshold: BEARISH→90, BULLISH→75, others→settings.CONFIDENCE_THRESHOLD
- BUY actions below the adjusted threshold are downgraded to HOLD with
  a descriptive rationale including the outlook and threshold values
- Add 5 integration tests covering bearish suppression, bearish allow,
  bullish allow, bullish suppression, and neutral default threshold

Closes #173

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 08:31:24 +09:00
agentson
c7640a30d7 feat: use playbook allocation_pct in position sizing (#172)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add playbook_allocation_pct and scenario_confidence parameters to
  _determine_order_quantity() with playbook-based sizing taking priority
  over volatility-score fallback when provided
- Confidence scaling: confidence/80 multiplier (confidence 96 → 1.2x)
  clipped to [POSITION_MIN_ALLOCATION_PCT, POSITION_MAX_ALLOCATION_PCT]
- Pass matched_scenario.allocation_pct and match.confidence from
  trading_cycle so AI's allocation decisions reach order execution
- Add 4 new tests: playbook priority, confidence scaling, max clamp,
  and fallback behavior

Closes #172

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 08:29:09 +09:00
agentson
60a22d6cd4 feat: add position-aware conditions to StockCondition (#171)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add unrealized_pnl_pct_above/below and holding_days_above/below fields
  to StockCondition so AI can generate rules like 'P&L > 3% → SELL'
- Evaluate new fields in ScenarioEngine.evaluate_condition() with same
  AND-combining logic as existing technical indicator fields
- Include position fields in _build_match_details() for audit logging
- Parse new fields from AI JSON response in PreMarketPlanner._parse_scenario()
- Update prompt schema example to show new position-aware condition fields
- Add 13 tests covering all new condition combinations and edge cases

Closes #171

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 08:27:44 +09:00
agentson
b1f48d859e feat: include current holdings in pre-market AI prompt (#170)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add current_holdings parameter to generate_playbook() and _build_prompt()
- Inject '## Current Holdings' section into Gemini prompt with qty, entry
  price, unrealized PnL%, and holding days for each held position
- Instruct AI to generate SELL/HOLD scenarios for held stocks even if not
  in scanner candidates list
- Allow held stock codes in _parse_response() valid_codes set so AI-
  generated SELL scenarios for holdings pass validation
- Add 6 tests covering prompt inclusion, omission, and response parsing

Closes #170

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 08:25:38 +09:00
03f8d220a4 Merge pull request 'fix: use broker balance API as source of truth for SELL qty and holdings (#164 #165)' (#169) from feature/issue-164-165-broker-api-holdings into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #169
2026-02-20 07:52:26 +09:00
agentson
305120f599 fix: use broker balance API as source of truth for SELL qty and holdings (#164 #165)
Some checks failed
CI / test (pull_request) Has been cancelled
DB의 주문 수량 기록은 실제 체결 수량과 다를 수 있음(부분 체결, 외부 수동 거래).
브로커 잔고 API(output1)를 source of truth로 사용하도록 수정.

## 변경 사항

### SELL 수량 (#164)
- _extract_held_qty_from_balance() 추가
  - 국내: output1의 ord_psbl_qty (→ hldg_qty fallback)
  - 해외: output1의 ovrs_cblc_qty (→ hldg_qty fallback)
- _determine_order_quantity()에 broker_held_qty 파라미터 추가
  - SELL 시 broker_held_qty 반환 (0이면 주문 스킵)
- trading_cycle / run_daily_session 양쪽 호출 지점 수정
  - 이미 fetch된 balance_data에서 수량 추출 (추가 API 호출 없음)

### 보유 종목 루프 (#165)
- _extract_held_codes_from_balance() 추가
  - ord_psbl_qty > 0인 종목 코드 목록 반환
- 실시간 루프에서 스캔 시점에 get_balance() 호출해 보유 종목 병합
  - 스캐너 후보 + 실제 보유 종목 union으로 trading_cycle 순회
  - 실패 시 경고 로그 후 스캐너 후보만으로 계속 진행

### 테스트
- TestExtractHeldQtyFromBalance: 7개 (국내/해외/fallback/미보유)
- TestExtractHeldCodesFromBalance: 4개 (qty>0 포함, qty=0 제외 등)
- TestDetermineOrderQuantity: 5개 (SELL qty, BUY sizing)
- test_sell_order_uses_broker_balance_qty_not_db:
  DB 10주 기록 vs 브로커 5주 확인 → 브로커 값(5) 사용 검증
- 기존 SELL/stop-loss/take-profit 테스트에 output1 mock 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 07:40:45 +09:00
faa23b3f1b Merge pull request 'fix: enforce take_profit_pct in HOLD evaluation loop (#163)' (#166) from feature/issue-163-take-profit-enforcement into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #166
2026-02-20 07:24:14 +09:00
agentson
5844ec5ad3 fix: enforce take_profit_pct in HOLD evaluation loop (#163)
Some checks failed
CI / test (pull_request) Has been cancelled
HOLD 판정 후 보유 포지션에 대해 stop_loss와 함께 take_profit도 체크하도록 수정.
AI가 생성한 take_profit_pct가 실제 거래 로직에 반영되지 않던 구조적 결함 수정.

- HOLD 블록에서 loss_pct >= take_profit_threshold 조건 추가
- stop_loss와 상호 배타적으로 동작 (stop_loss 우선 체크)
- take_profit 기본값 3.0% (playbook 없는 경우 적용)
- 테스트 2개 추가:
  - test_hold_overridden_to_sell_when_take_profit_triggered
  - test_hold_not_overridden_when_between_stop_loss_and_take_profit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 03:00:52 +09:00
ff5ff736d8 Merge pull request 'feat: granular Telegram notification filters via .env (#161)' (#162) from feature/issue-161-telegram-notification-filters into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #162
2026-02-20 02:33:56 +09:00
agentson
4a59d7e66d feat: /notify command for runtime notification filter control (#161)
Some checks failed
CI / test (pull_request) Has been cancelled
Add /notify Telegram command for adjusting notification filters at runtime
without restarting the service:

  /notify                  → show current filter state
  /notify scenario off     → disable scenario match alerts
  /notify market off       → disable market open/close alerts
  /notify all off          → disable all (circuit_breaker always on)
  /notify trades on        → re-enable trade execution alerts

Changes:
- NotificationFilter: add KEYS class var, set_flag(), as_dict()
- TelegramClient: add set_notification(), filter_status()
- TelegramCommandHandler: add register_command_with_args() + args dispatch
- main.py: handle_notify() handler + register /notify command + /help update
- Tests: 12 new tests (set_flag, set_notification, register_command_with_args)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 02:33:03 +09:00
agentson
8dd625bfd1 feat: granular Telegram notification filters via .env (#161)
Some checks failed
CI / test (pull_request) Has been cancelled
Add NotificationFilter dataclass to TelegramClient allowing per-type
on/off control via .env variables. circuit_breaker always sends regardless.

New .env options (all default true):
- TELEGRAM_NOTIFY_TRADES
- TELEGRAM_NOTIFY_MARKET_OPEN_CLOSE
- TELEGRAM_NOTIFY_FAT_FINGER
- TELEGRAM_NOTIFY_SYSTEM_EVENTS
- TELEGRAM_NOTIFY_PLAYBOOK
- TELEGRAM_NOTIFY_SCENARIO_MATCH  (most frequent — set false to reduce noise)
- TELEGRAM_NOTIFY_ERRORS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 02:26:28 +09:00
b50977aa76 Merge pull request 'feat: improve dashboard UI with P&L chart and decisions log (#159)' (#160) from feature/issue-159-dashboard-ui-improvement into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #160
2026-02-20 02:20:12 +09:00
agentson
fbcd016e1a feat: improve dashboard UI with P&L chart and decisions log (#159)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add /api/pnl/history endpoint to app.py for daily P&L history charting
- Rewrite index.html as full SPA with Chart.js bar chart, summary cards,
  and decisions log table with market filter tabs and 30s auto-refresh
- Add test_pnl_history_all_markets and test_pnl_history_market_filter tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 02:15:34 +09:00
ce5773ba45 Merge pull request 'fix: domestic current price fetching and KRX tick unit rounding (#157)' (#158) from feature/issue-157-fix-domestic-price-and-tick into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #158
2026-02-19 16:25:59 +09:00
agentson
7834b89f10 fix: domestic current price fetching and KRX tick unit rounding (#157)
Some checks failed
CI / test (pull_request) Has been cancelled
**Problem 1 — Current price always 0**
get_orderbook() used inquire-asking-price-exp-ccn which has no stck_prpr
in output1 (only askp/bidp data). This caused every domestic BUY to be
skipped with "no affordable quantity (cash=..., price=0.00)".

**Problem 2 — KRX tick unit error on limit orders**
Limit order prices were passed unrounded, triggering 호가단위 오류 in VTS.
Also ORD_DVSN was wrongly set to "01" (시장가) for limit orders.

**Fix**
- Add kr_tick_unit(price) and kr_round_down(price) module-level helpers
  implementing KRX 7-tier price tick rules (1/5/10/50/100/500/1000원).
- Add get_current_price(stock_code) → (price, change_pct, foreigner_net)
  using FHKST01010100 / inquire-price API (works in VTS, returns correct
  stck_prpr, prdy_ctrt, frgn_ntby_qty).
- Fix send_order() ORD_DVSN: "00"=지정가, "01"=시장가 (was "01"/"06").
- Apply kr_round_down() to limit order price inside send_order().
- Replace both get_orderbook() calls in main.py with get_current_price().
- Update all 4 test_main.py mock sites to use get_current_price AsyncMock.

**Tests added** (25 new tests, all 646 pass)
- TestKrTickUnit: 13 parametrized boundary cases + 7 round-down cases
- TestGetCurrentPrice: correct fields, correct API path/TR_ID, HTTP error
- TestSendOrderTickRounding: tick rounding, ORD_DVSN 00/01

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 12:40:55 +09:00
e0d6c9f81d Merge pull request 'fix: correct TR_ID, path, and params for fetch_market_rankings (#155)' (#156) from feature/issue-155-fix-ranking-api into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #156
2026-02-19 11:00:50 +09:00
agentson
2e550f8b58 fix: correct TR_ID, path, and params for fetch_market_rankings (#155)
Some checks failed
CI / test (pull_request) Has been cancelled
Three bugs found by comparing against KIS official GitHub examples:

1. FID_COND_SCR_DIV_CODE: "20001" → "20171" (volume-rank screen code)
2. FID_TRGT_EXLS_CLS_CODE: "000000" (6-digit) → "0000000000" (10-digit)
3. fluctuation ranking:
   - TR_ID: "FHPST01710100" (invalid) → "FHPST01700000"
   - path: /quotations/volume-rank → /ranking/fluctuation
   - params: volume-rank params → lowercase fluctuation-specific params
     (fid_rank_sort_cls_code, fid_input_cnt_1, fid_prc_cls_code,
      fid_rsfl_rate1, fid_rsfl_rate2, etc.)

Note: VTS (paper trading) does not return data from ranking APIs regardless
of parameter correctness — this is a KIS policy restriction, not a code bug.
These fixes ensure correct behavior when switching to a live account.

Tests: TestFetchMarketRankings (3 tests) added to test_broker.py

Closes #155

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 10:25:38 +09:00
c76e2dfed5 Merge pull request 'fix: overseas order rt_cd check + limit price premium + paper cash fallback (#151)' (#152) from feature/issue-151-overseas-order-fixes into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #152
2026-02-19 06:01:54 +09:00
agentson
24fa22e77b fix: overseas order rt_cd check, limit price premium, paper cash fallback (#151)
Some checks failed
CI / test (pull_request) Has been cancelled
Three fixes for overseas stock trading failures:

1. Price API exchange code mapping:
   - get_overseas_price() now applies _PRICE_EXCHANGE_MAP (NASD→NAS, NYSE→NYS, AMEX→AMS)
   - Price API HHDFS00000300 requires short exchange codes same as ranking API

2. rt_cd check in send_overseas_order():
   - Log WARNING (not INFO) when rt_cd != "0" (e.g., "주문가능금액이 부족합니다")
   - Caller (main.py) checks rt_cd == "0" before calling log_trade()
   - Prevents DB from recording failed orders as successful trades

3. Limit order price premium for BUY:
   - BUY limit price = current_price * 1.005 (0.5% premium)
   - SELL limit price = current_price (no premium)
   - Improves fill probability: KIS VTS only accepts limit orders,
     and last price is typically at or below ask

4. PAPER_OVERSEAS_CASH fallback (config + main.py):
   - New setting: PAPER_OVERSEAS_CASH = 50000.0 (USD)
   - When VTS overseas balance API fails/returns 0, use this as simulated cash
   - Applied in both trading_cycle() and run_daily_session()

5. Candidate price fallback:
   - If price API returns 0, use scanner candidate price as fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 05:58:15 +09:00
cd1579058c Merge pull request 'fix: overseas order uses limit price, not hardcoded 0 (#149)' (#150) from feature/issue-149-overseas-limit-order-price into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #150
2026-02-19 05:50:31 +09:00
45b48fa7cd Merge pull request 'fix: overseas price API exchange code + VTS balance fallback (#147)' (#148) from feature/issue-147-overseas-price-balance-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #148
2026-02-19 05:49:38 +09:00
agentson
3952a5337b docs: add requirements log entry for overseas limit order fix (#149)
Some checks failed
CI / test (pull_request) Has been cancelled
2026-02-18 23:54:18 +09:00
agentson
ccc97ebaa9 fix: use current_price for overseas limit orders (KIS VTS rejects market orders) (#149)
Some checks failed
CI / test (pull_request) Has been cancelled
KIS VTS (paper trading) rejects overseas market orders with:
  "모의투자 주문처리가 안되었습니다(지정가만 가능한 상품입니다)"

Root cause: send_overseas_order() was called with price=0.0 (market order)
in both trading_cycle() and run_daily_session(), even though current_price
was already computed correctly by Fix #147 (exchange code mapping).

Fix: pass current_price as the limit order price in both call sites.
Domestic broker send_order() keeps price=0 (market orders are fine on KRX).

Adds regression test TestOverseasBalanceParsing::test_overseas_buy_order_uses_limit_price
verifying price=182.5 is passed, not 0.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 23:53:15 +09:00
agentson
3a54db8948 fix: price API exchange code mapping and VTS overseas balance fallback (#147)
Some checks failed
CI / test (pull_request) Has been cancelled
- Apply _PRICE_EXCHANGE_MAP in get_overseas_price() to send short codes
  (NASD→NAS, NYSE→NYS, AMEX→AMS) required by HHDFS00000300 price API
- Add PAPER_OVERSEAS_CASH config setting (default $50,000) for simulated
  USD balance when VTS overseas balance API returns 0 in paper mode
- Fall back to scan candidate price when live price API returns 0
- Both fixes together resolve "no affordable quantity (cash=0, price=0)"
  which was preventing all overseas trade execution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 23:48:14 +09:00
agentson
96e2ad4f1f fix: use smart rule-based fallback playbook when Gemini fails (issue #145)
Some checks failed
CI / test (pull_request) Has been cancelled
When gemini-2.5-flash quota is exhausted (20 RPD free tier), generate_playbook()
fell back to _defensive_playbook() which only had price_change_pct_below: -3.0 SELL
conditions — no BUY conditions — causing zero trades on US market despite scanner
finding strong momentum/oversold candidates.

Changes:
- Add _smart_fallback_playbook() that uses scanner signals to build BUY conditions:
  - momentum signal: BUY when volume_ratio_above=VOL_MULTIPLIER
  - oversold signal: BUY when rsi_below=RSI_OVERSOLD_THRESHOLD
  - always: SELL stop-loss at price_change_pct_below=-3.0
- Use _smart_fallback_playbook() instead of _defensive_playbook() on Gemini failure
- Add 10 new tests for _smart_fallback_playbook() covering momentum/oversold/empty cases
- Update existing test_gemini_failure_returns_defensive to match new behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 22:23:57 +09:00
c5a8982122 Merge pull request 'Fix: gemini_client.decide() ignores prompt_override (#143)' (#144) from feature/issue-143-fix-prompt-override into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #144
2026-02-18 02:05:50 +09:00
agentson
f7289606fc fix: use prompt_override in gemini_client.decide() for playbook generation
Some checks failed
CI / test (pull_request) Has been cancelled
decide() ignored market_data["prompt_override"], always building a generic
trade-decision prompt. This caused pre_market_planner playbook generation
to fail with JSONDecodeError on every market, falling back to defensive
playbooks. Now prompt_override takes priority over both optimization and
standard prompt building.

Closes #143

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:02:13 +09:00
0c5c90201f Merge pull request 'fix: correct KIS overseas ranking API TR_IDs, paths, and exchange codes' (#142) from feature/issue-141-fix-overseas-ranking-api into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #142
2026-02-18 01:13:07 +09:00
agentson
b484f0daff fix: align cooldown test with wait-and-retry behavior + boost overseas coverage
Some checks failed
CI / test (pull_request) Has been cancelled
- test_token_refresh_cooldown: updated to match the wait-then-retry
  behavior introduced in aeed881 (was expecting fail-fast ConnectionError)
- Added 22 tests for OverseasBroker: get_overseas_price, get_overseas_balance,
  send_overseas_order, _get_currency_code, _extract_ranking_rows
- src/broker/overseas.py coverage: 52% → 100%
- All 594 tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:12:09 +09:00
agentson
1288181e39 docs: add requirements log entry for overseas ranking API fix
Some checks failed
CI / test (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:04:42 +09:00
agentson
b625f41621 fix: correct KIS overseas ranking API TR_IDs, paths, and exchange codes
Some checks failed
CI / test (pull_request) Has been cancelled
The overseas ranking API was returning 404 for all exchanges because the
TR_IDs, API paths, and exchange codes were all incorrect. Updated to match
KIS official API documentation:
- TR_ID: HHDFS76290000 (updown-rate), HHDFS76270000 (volume-surge)
- Path: /uapi/overseas-stock/v1/ranking/{updown-rate,volume-surge}
- Exchange codes: NASD→NAS, NYSE→NYS, AMEX→AMS via ranking-specific mapping

Fixes #141

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:02:52 +09:00
77d3ba967c Merge pull request 'Fix overnight runner stability and token cooldown handling' (#139) from agentson/fix/137-run-overnight-python-tmux into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #139
2026-02-18 00:05:44 +09:00
agentson
aeed881d85 fix: wait on token refresh cooldown instead of failing fast
Some checks failed
CI / test (pull_request) Has been cancelled
2026-02-18 00:03:42 +09:00
agentson
d0bbdb5dc1 fix: harden overseas ranking fallback and scanner visibility 2026-02-17 23:39:20 +09:00
44339c52d7 Merge pull request 'Fix overnight runner Python selection and tmux window targeting' (#138) from agentson/fix/137-run-overnight-python-tmux into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #138
2026-02-17 23:25:11 +09:00
agentson
22ffdafacc chore: add overnight helper scripts
Some checks failed
CI / test (pull_request) Has been cancelled
- add morning report launcher\n- add overnight stop script\n- add watchdog health monitor script\n\nRefs #137
2026-02-17 23:24:15 +09:00
agentson
c49765e951 fix: make overnight runner use venv python and tmux-safe window target
Some checks failed
CI / test (pull_request) Has been cancelled
- prefer .venv/bin/python when APP_CMD is unset\n- pass DASHBOARD_PORT into launch command (default 8080)\n- target tmux window by name instead of fixed index\n\nRefs #137
2026-02-17 23:21:04 +09:00
64000b9967 Merge pull request 'feat: unify domestic scanner and sizing; update docs' (#136) from feat/overseas-ranking-current-state into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #136
2026-02-17 06:35:43 +09:00
40 changed files with 8330 additions and 251 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

@@ -94,6 +94,7 @@ Smart Scanner runs in `TRADE_MODE=realtime` only. Daily mode uses static watchli
- **[Testing](docs/testing.md)** — Test structure, coverage requirements, writing tests - **[Testing](docs/testing.md)** — Test structure, coverage requirements, writing tests
- **[Agent Policies](docs/agents.md)** — Prime directives, constraints, prohibited actions - **[Agent Policies](docs/agents.md)** — Prime directives, constraints, prohibited actions
- **[Requirements Log](docs/requirements-log.md)** — User requirements and feedback tracking - **[Requirements Log](docs/requirements-log.md)** — User requirements and feedback tracking
- **[Live Trading Checklist](docs/live-trading-checklist.md)** — 모의→실전 전환 체크리스트
## Core Principles ## Core Principles
@@ -170,7 +171,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

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

View File

@@ -0,0 +1,131 @@
# 실전 전환 체크리스트
모의 거래(paper)에서 실전(live)으로 전환하기 전에 아래 항목을 **순서대로** 모두 확인하세요.
---
## 1. 사전 조건
### 1-1. KIS OpenAPI 실전 계좌 준비
- [ ] 한국투자증권 계좌 개설 완료 (일반 위탁 계좌)
- [ ] OpenAPI 실전 사용 신청 (KIS 홈페이지 → Open API → 서비스 신청)
- [ ] 실전용 APP_KEY / APP_SECRET 발급 완료
- [ ] KIS_ACCOUNT_NO 형식 확인: `XXXXXXXX-XX` (8자리-2자리)
### 1-2. 리스크 파라미터 검토
- [ ] `CIRCUIT_BREAKER_PCT` 확인: 기본값 -3.0% (더 엄격하게 조정 권장)
- [ ] `FAT_FINGER_PCT` 확인: 기본값 30.0% (1회 주문 최대 잔고 대비 %)
- [ ] `CONFIDENCE_THRESHOLD` 확인: BEARISH ≥ 90, NEUTRAL ≥ 80, BULLISH ≥ 75
- [ ] 초기 투자금 결정 및 해외 주식 운용 한도 설정
### 1-3. 시스템 요건
- [ ] 커버리지 80% 이상 유지 확인: `pytest --cov=src`
- [ ] 타입 체크 통과: `mypy src/ --strict`
- [ ] Lint 통과: `ruff check src/ tests/`
---
## 2. 환경 설정
### 2-1. `.env` 파일 수정
```bash
# 1. KIS 실전 URL로 변경 (모의: openapivts 포트 29443)
KIS_BASE_URL=https://openapi.koreainvestment.com:9443
# 2. 실전 APP_KEY / APP_SECRET으로 교체
KIS_APP_KEY=<실전_APP_KEY>
KIS_APP_SECRET=<실전_APP_SECRET>
KIS_ACCOUNT_NO=<실전_계좌번호>
# 3. 모드를 live로 변경
MODE=live
# 4. PAPER_OVERSEAS_CASH 비활성화 (live 모드에선 무시되지만 명시적으로 0 설정)
PAPER_OVERSEAS_CASH=0
```
> ⚠️ `KIS_BASE_URL` 포트 주의:
> - **모의(VTS)**: `https://openapivts.koreainvestment.com:29443`
> - **실전**: `https://openapi.koreainvestment.com:9443`
### 2-2. TR_ID 자동 분기 확인
아래 TR_ID는 `MODE` 값에 따라 코드에서 **자동으로 선택**됩니다.
별도 설정 불필요하나, 문제 발생 시 아래 표를 참조하세요.
| 구분 | 모의 TR_ID | 실전 TR_ID |
|------|-----------|-----------|
| 국내 잔고 조회 | `VTTC8434R` | `TTTC8434R` |
| 국내 현금 매수 | `VTTC0012U` | `TTTC0012U` |
| 국내 현금 매도 | `VTTC0011U` | `TTTC0011U` |
| 해외 잔고 조회 | `VTTS3012R` | `TTTS3012R` |
| 해외 매수 | `VTTT1002U` | `TTTT1002U` |
| 해외 매도 | `VTTT1001U` | `TTTT1006U` |
> **출처**: `docs/한국투자증권_오픈API_전체문서_20260221_030000.xlsx` (공식 문서 기준)
---
## 3. 최종 확인
### 3-1. 실전 시작 전 점검
- [ ] DB 백업 완료: `data/trade_logs.db``data/backups/`
- [ ] Telegram 알림 설정 확인 (실전에서는 알림이 더욱 중요)
- [ ] 소액으로 첫 거래 진행 후 TR_ID/계좌 정상 동작 확인
### 3-2. 실행 명령
```bash
# 실전 모드로 실행
python -m src.main --mode=live
# 대시보드 함께 실행 (별도 터미널에서 모니터링)
python -m src.main --mode=live --dashboard
```
### 3-3. 실전 시작 직후 확인 사항
- [ ] 로그에 `MODE=live` 출력 확인
- [ ] 첫 잔고 조회 성공 (ConnectionError 없음)
- [ ] Telegram 알림 수신 확인 ("System started")
- [ ] 첫 주문 후 KIS 앱에서 체결 내역 확인
---
## 4. 비상 정지 방법
### 즉각 정지
```bash
# 터미널에서 Ctrl+C (정상 종료 트리거)
# 또는 Telegram 봇 명령:
/stop
```
### Circuit Breaker 발동 시
- CB가 발동되면 자동으로 거래 중단 및 Telegram 알림 전송
- CB 임계값: `CIRCUIT_BREAKER_PCT` (기본 -3.0%)
- **임계값은 엄격하게만 조정 가능** (더 낮은 음수 값으로만 변경)
---
## 5. 롤백 절차
실전 전환 후 문제 발생 시:
```bash
# 1. 즉시 .env에서 MODE=paper로 복원
# 2. 재시작
python -m src.main --mode=paper
# 3. DB에서 최근 거래 확인
sqlite3 data/trade_logs.db "SELECT * FROM trades ORDER BY id DESC LIMIT 20;"
```
---
## 관련 문서
- [시스템 아키텍처](architecture.md)
- [워크플로우 가이드](workflow.md)
- [재해 복구](disaster_recovery.md)
- [Agent 제약 조건](agents.md)

View File

@@ -7,6 +7,32 @@
--- ---
## 2026-02-21
### 거래 상태 확인 중 발견된 버그 (#187)
- 거래 상태 점검 요청 → SELL 주문(손절/익절)이 Fat Finger에 막혀 전혀 실행 안 됨 발견
- **#187 (Critical)**: SELL 주문에서 Fat Finger 오탐 — `order_amount/total_cash > 30%`가 SELL에도 적용되어 대형 포지션 매도 불가
- JELD stop-loss -6.20% → 차단, RXT take-profit +46.13% → 차단
- 수정: SELL은 `check_circuit_breaker`만 호출, `validate_order`(Fat Finger 포함) 미호출
---
## 2026-02-20
### 지속적 모니터링 및 개선점 도출 (이슈 #178~#182)
- Dashboard 포함해서 실행하며 간헐적 문제 모니터링 및 개선점 자동 도출 요청
- 모니터링 결과 발견된 이슈 목록:
- **#178**: uvicorn 미설치 → dashboard 미작동 + 오해의 소지 있는 시작 로그 → uvicorn 설치 완료
- **#179 (Critical)**: 잔액 부족 주문 실패 후 매 사이클마다 무한 재시도 (MLECW 20분 이상 반복)
- **#180**: 다중 인스턴스 실행 시 Telegram 409 충돌
- **#181**: implied_rsi 공식 포화 문제 (change_rate≥12.5% → RSI=100)
- **#182 (Critical)**: 보유 종목이 SmartScanner 변동성 필터에 걸려 SELL 신호 미생성 → SELL 체결 0건, 잔고 소진
- 요구사항: 모니터링 자동화 및 주기적 개선점 리포트 도출
---
## 2026-02-05 ## 2026-02-05
### API 효율화 ### API 효율화
@@ -165,3 +191,104 @@
**효과:** **효과:**
- 국내/해외 스캐너 기준이 변동성 중심으로 일관화 - 국내/해외 스캐너 기준이 변동성 중심으로 일관화
- 고변동 구간에서 자동 익스포저 축소, 저변동 구간에서 과소진입 완화 - 고변동 구간에서 자동 익스포저 축소, 저변동 구간에서 과소진입 완화
## 2026-02-18
### KIS 해외 랭킹 API 404 에러 수정
**배경:**
- KIS 해외주식 랭킹 API(`fetch_overseas_rankings`)가 모든 거래소에서 HTTP 404를 반환
- Smart Scanner가 해외 시장 후보 종목을 찾지 못해 거래가 전혀 실행되지 않음
**근본 원인:**
- TR_ID, API 경로, 거래소 코드가 모두 KIS 공식 문서와 불일치
**구현 결과:**
- `src/config.py`: TR_ID/Path 기본값을 KIS 공식 스펙으로 수정
- `src/broker/overseas.py`: 랭킹 API 전용 거래소 코드 매핑 추가 (NASD→NAS, NYSE→NYS, AMEX→AMS), 올바른 API 파라미터 사용
- `tests/test_overseas_broker.py`: 19개 단위 테스트 추가
**효과:**
- 해외 시장 랭킹 스캔이 정상 동작하여 Smart Scanner가 후보 종목 탐지 가능
### Gemini prompt_override 미적용 버그 수정
**배경:**
- `run_overnight` 실행 시 모든 시장에서 Playbook 생성 실패 (`JSONDecodeError`)
- defensive playbook으로 폴백되어 모든 종목이 HOLD 처리
**근본 원인:**
- `pre_market_planner.py``market_data["prompt_override"]`에 Playbook 전용 프롬프트를 넣어 `gemini.decide()` 호출
- `gemini_client.py``decide()` 메서드가 `prompt_override` 키를 전혀 확인하지 않고 항상 일반 트레이드 결정 프롬프트 생성
- Gemini가 Playbook JSON 대신 일반 트레이드 결정을 반환하여 파싱 실패
**구현 결과:**
- `src/brain/gemini_client.py`: `decide()` 메서드에서 `prompt_override` 우선 사용 로직 추가
- `tests/test_brain.py`: 3개 테스트 추가 (override 전달, optimization 우회, 미지정 시 기존 동작 유지)
**이슈/PR:** #143
### 미국장 거래 미실행 근본 원인 분석 및 수정 (자율 실행 세션)
**배경:**
- 사용자 요청: "미국장 열면 프로그램 돌려서 거래 한 번도 못 한 거 꼭 원인 찾아서 해결해줘"
- 프로그램을 미국장 개장(9:30 AM EST) 전부터 실행하여 실시간 로그를 분석
**발견된 근본 원인 #1: Defensive Playbook — BUY 조건 없음**
- Gemini free tier (20 RPD) 소진 → `generate_playbook()` 실패 → `_defensive_playbook()` 폴백
- Defensive playbook은 `price_change_pct_below: -3.0 → SELL` 조건만 존재, BUY 조건 없음
- ScenarioEngine이 항상 HOLD 반환 → 거래 0건
**수정 #1 (PR #146, Issue #145):**
- `src/strategy/pre_market_planner.py`: `_smart_fallback_playbook()` 메서드 추가
- 스캐너 signal 기반 BUY 조건 생성: `momentum → volume_ratio_above`, `oversold → rsi_below`
- 기존 defensive stop-loss SELL 조건 유지
- Gemini 실패 시 defensive → smart fallback으로 전환
- 테스트 10개 추가
**발견된 근본 원인 #2: 가격 API 거래소 코드 불일치 + VTS 잔고 API 오류**
실제 로그:
```
Scenario matched for MRNX: BUY (confidence=80) ✓
Decision for EWUS (NYSE American): BUY (confidence=80) ✓
Skip BUY APLZ (NYSE American): no affordable quantity (cash=0.00, price=0.00) ✗
```
- `get_overseas_price()`: `NASD`/`NYSE`/`AMEX` 전송 → API가 `NAS`/`NYS`/`AMS` 기대 → 빈 응답 → `price=0`
- `VTTS3012R` 잔고 API: "ERROR : INPUT INVALID_CHECK_ACNO" → `total_cash=0`
- 결과: `_determine_order_quantity()` 가 0 반환 → 주문 건너뜀
**수정 #2 (PR #148, Issue #147):**
- `src/broker/overseas.py`: `_PRICE_EXCHANGE_MAP = _RANKING_EXCHANGE_MAP` 추가, 가격 API에 매핑 적용
- `src/config.py`: `PAPER_OVERSEAS_CASH: float = Field(default=50000.0)` — paper 모드 시뮬레이션 잔고
- `src/main.py`: 잔고 0일 때 PAPER_OVERSEAS_CASH 폴백, 가격 0일 때 candidate.price 폴백
- 테스트 8개 추가
**효과:**
- BUY 결정 → 실제 주문 전송까지의 파이프라인이 완전히 동작
- Paper 모드에서 KIS VTS 해외 잔고 API 오류에 관계없이 시뮬레이션 거래 가능
**이슈/PR:** #145, #146, #147, #148
### 해외주식 시장가 주문 거부 수정 (Fix #3, 연속 발견)
**배경:**
- Fix #147 적용 후 주문 전송 시작 → KIS VTS가 거부: "지정가만 가능한 상품입니다"
**근본 원인:**
- `trading_cycle()`, `run_daily_session()` 양쪽에서 `send_overseas_order(price=0.0)` 하드코딩
- `price=0``ORD_DVSN="01"` (시장가) 전송 → KIS VTS 거부
- Fix #147에서 이미 `current_price`를 올바르게 계산했으나 주문 시 미사용
**구현 결과:**
- `src/main.py`: 두 곳에서 `price=0.0``price=current_price`/`price=stock_data["current_price"]`
- `tests/test_main.py`: 회귀 테스트 `test_overseas_buy_order_uses_limit_price` 추가
**최종 확인 로그:**
```
Order result: 모의투자 매수주문이 완료 되었습니다. ✓
```
**이슈/PR:** #149, #150

54
scripts/morning_report.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Morning summary for overnight run logs.
set -euo pipefail
LOG_DIR="${LOG_DIR:-data/overnight}"
if [ ! -d "$LOG_DIR" ]; then
echo "로그 디렉터리가 없습니다: $LOG_DIR"
exit 1
fi
latest_run="$(ls -1t "$LOG_DIR"/run_*.log 2>/dev/null | head -n 1 || true)"
latest_watchdog="$(ls -1t "$LOG_DIR"/watchdog_*.log 2>/dev/null | head -n 1 || true)"
if [ -z "$latest_run" ]; then
echo "run 로그가 없습니다: $LOG_DIR/run_*.log"
exit 1
fi
echo "Overnight report"
echo "- run log: $latest_run"
if [ -n "$latest_watchdog" ]; then
echo "- watchdog log: $latest_watchdog"
fi
start_line="$(head -n 1 "$latest_run" || true)"
end_line="$(tail -n 1 "$latest_run" || true)"
info_count="$(rg -c '"level": "INFO"' "$latest_run" || true)"
warn_count="$(rg -c '"level": "WARNING"' "$latest_run" || true)"
error_count="$(rg -c '"level": "ERROR"' "$latest_run" || true)"
critical_count="$(rg -c '"level": "CRITICAL"' "$latest_run" || true)"
traceback_count="$(rg -c 'Traceback' "$latest_run" || true)"
echo "- start: ${start_line:-N/A}"
echo "- end: ${end_line:-N/A}"
echo "- INFO: ${info_count:-0}"
echo "- WARNING: ${warn_count:-0}"
echo "- ERROR: ${error_count:-0}"
echo "- CRITICAL: ${critical_count:-0}"
echo "- Traceback: ${traceback_count:-0}"
if [ -n "$latest_watchdog" ]; then
watchdog_errors="$(rg -c '\[ERROR\]' "$latest_watchdog" || true)"
echo "- watchdog ERROR: ${watchdog_errors:-0}"
echo ""
echo "최근 watchdog 로그:"
tail -n 5 "$latest_watchdog" || true
fi
echo ""
echo "최근 앱 로그:"
tail -n 20 "$latest_run" || true

87
scripts/run_overnight.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Start The Ouroboros overnight with logs and watchdog.
set -euo pipefail
LOG_DIR="${LOG_DIR:-data/overnight}"
CHECK_INTERVAL="${CHECK_INTERVAL:-30}"
TMUX_AUTO="${TMUX_AUTO:-true}"
TMUX_ATTACH="${TMUX_ATTACH:-true}"
TMUX_SESSION_PREFIX="${TMUX_SESSION_PREFIX:-ouroboros_overnight}"
if [ -z "${APP_CMD:-}" ]; then
if [ -x ".venv/bin/python" ]; then
PYTHON_BIN=".venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="python3"
elif command -v python >/dev/null 2>&1; then
PYTHON_BIN="python"
else
echo ".venv/bin/python 또는 python3/python 실행 파일을 찾을 수 없습니다."
exit 1
fi
dashboard_port="${DASHBOARD_PORT:-8080}"
APP_CMD="DASHBOARD_PORT=$dashboard_port $PYTHON_BIN -m src.main --mode=paper --dashboard"
fi
mkdir -p "$LOG_DIR"
timestamp="$(date +"%Y%m%d_%H%M%S")"
RUN_LOG="$LOG_DIR/run_${timestamp}.log"
WATCHDOG_LOG="$LOG_DIR/watchdog_${timestamp}.log"
PID_FILE="$LOG_DIR/app.pid"
WATCHDOG_PID_FILE="$LOG_DIR/watchdog.pid"
if [ -f "$PID_FILE" ]; then
old_pid="$(cat "$PID_FILE" || true)"
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
echo "앱이 이미 실행 중입니다. pid=$old_pid"
exit 1
fi
fi
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] starting: $APP_CMD" | tee -a "$RUN_LOG"
nohup bash -lc "$APP_CMD" >>"$RUN_LOG" 2>&1 &
app_pid=$!
echo "$app_pid" > "$PID_FILE"
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] app pid=$app_pid" | tee -a "$RUN_LOG"
nohup env PID_FILE="$PID_FILE" LOG_FILE="$WATCHDOG_LOG" CHECK_INTERVAL="$CHECK_INTERVAL" \
bash scripts/watchdog.sh >/dev/null 2>&1 &
watchdog_pid=$!
echo "$watchdog_pid" > "$WATCHDOG_PID_FILE"
cat <<EOF
시작 완료
- app pid: $app_pid
- watchdog pid: $watchdog_pid
- app log: $RUN_LOG
- watchdog log: $WATCHDOG_LOG
실시간 확인:
tail -f "$RUN_LOG"
tail -f "$WATCHDOG_LOG"
EOF
if [ "$TMUX_AUTO" = "true" ]; then
if ! command -v tmux >/dev/null 2>&1; then
echo "tmux를 찾지 못해 자동 세션 생성은 건너뜁니다."
exit 0
fi
session_name="${TMUX_SESSION_PREFIX}_${timestamp}"
window_name="overnight"
tmux new-session -d -s "$session_name" -n "$window_name" "tail -f '$RUN_LOG'"
tmux split-window -t "${session_name}:${window_name}" -v "tail -f '$WATCHDOG_LOG'"
tmux select-layout -t "${session_name}:${window_name}" even-vertical
echo "tmux session 생성: $session_name"
echo "수동 접속: tmux attach -t $session_name"
if [ -z "${TMUX:-}" ] && [ "$TMUX_ATTACH" = "true" ]; then
tmux attach -t "$session_name"
fi
fi

76
scripts/stop_overnight.sh Executable file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Stop The Ouroboros overnight app/watchdog/tmux session.
set -euo pipefail
LOG_DIR="${LOG_DIR:-data/overnight}"
PID_FILE="$LOG_DIR/app.pid"
WATCHDOG_PID_FILE="$LOG_DIR/watchdog.pid"
TMUX_SESSION_PREFIX="${TMUX_SESSION_PREFIX:-ouroboros_overnight}"
KILL_TIMEOUT="${KILL_TIMEOUT:-5}"
stop_pid() {
local name="$1"
local pid="$2"
if [ -z "$pid" ]; then
echo "$name PID가 비어 있습니다."
return 1
fi
if ! kill -0 "$pid" 2>/dev/null; then
echo "$name 프로세스가 이미 종료됨 (pid=$pid)"
return 0
fi
kill "$pid" 2>/dev/null || true
for _ in $(seq 1 "$KILL_TIMEOUT"); do
if ! kill -0 "$pid" 2>/dev/null; then
echo "$name 종료됨 (pid=$pid)"
return 0
fi
sleep 1
done
kill -9 "$pid" 2>/dev/null || true
if ! kill -0 "$pid" 2>/dev/null; then
echo "$name 강제 종료됨 (pid=$pid)"
return 0
fi
echo "$name 종료 실패 (pid=$pid)"
return 1
}
status=0
if [ -f "$WATCHDOG_PID_FILE" ]; then
watchdog_pid="$(cat "$WATCHDOG_PID_FILE" || true)"
stop_pid "watchdog" "$watchdog_pid" || status=1
rm -f "$WATCHDOG_PID_FILE"
else
echo "watchdog pid 파일 없음: $WATCHDOG_PID_FILE"
fi
if [ -f "$PID_FILE" ]; then
app_pid="$(cat "$PID_FILE" || true)"
stop_pid "app" "$app_pid" || status=1
rm -f "$PID_FILE"
else
echo "app pid 파일 없음: $PID_FILE"
fi
if command -v tmux >/dev/null 2>&1; then
sessions="$(tmux ls 2>/dev/null | awk -F: -v p="$TMUX_SESSION_PREFIX" '$1 ~ "^" p "_" {print $1}')"
if [ -n "$sessions" ]; then
while IFS= read -r s; do
[ -z "$s" ] && continue
tmux kill-session -t "$s" 2>/dev/null || true
echo "tmux 세션 종료: $s"
done <<< "$sessions"
else
echo "종료할 tmux 세션 없음 (prefix=${TMUX_SESSION_PREFIX}_)"
fi
fi
exit "$status"

42
scripts/watchdog.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Simple watchdog for The Ouroboros process.
set -euo pipefail
PID_FILE="${PID_FILE:-data/overnight/app.pid}"
LOG_FILE="${LOG_FILE:-data/overnight/watchdog.log}"
CHECK_INTERVAL="${CHECK_INTERVAL:-30}"
STATUS_EVERY="${STATUS_EVERY:-10}"
mkdir -p "$(dirname "$LOG_FILE")"
log() {
printf '%s %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$1" | tee -a "$LOG_FILE"
}
if [ ! -f "$PID_FILE" ]; then
log "[ERROR] pid file not found: $PID_FILE"
exit 1
fi
PID="$(cat "$PID_FILE")"
if [ -z "$PID" ]; then
log "[ERROR] pid file is empty: $PID_FILE"
exit 1
fi
log "[INFO] watchdog started (pid=$PID, interval=${CHECK_INTERVAL}s)"
count=0
while true; do
if kill -0 "$PID" 2>/dev/null; then
count=$((count + 1))
if [ $((count % STATUS_EVERY)) -eq 0 ]; then
log "[INFO] process alive (pid=$PID)"
fi
else
log "[ERROR] process stopped (pid=$PID)"
exit 1
fi
sleep "$CHECK_INTERVAL"
done

View File

@@ -175,7 +175,7 @@ class SmartVolatilityScanner:
liquidity_score = volume_rank_bonus.get(stock_code, 0.0) liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
score = min(100.0, volatility_score + liquidity_score) score = min(100.0, volatility_score + liquidity_score)
signal = "momentum" if change_rate >= 0 else "oversold" signal = "momentum" if change_rate >= 0 else "oversold"
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0))) implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
candidates.append( candidates.append(
ScanCandidate( ScanCandidate(
@@ -282,7 +282,7 @@ class SmartVolatilityScanner:
liquidity_score = volume_rank_bonus.get(stock_code, 0.0) liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
score = min(100.0, volatility_score + liquidity_score) score = min(100.0, volatility_score + liquidity_score)
signal = "momentum" if change_rate >= 0 else "oversold" signal = "momentum" if change_rate >= 0 else "oversold"
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0))) implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
candidates.append( candidates.append(
ScanCandidate( ScanCandidate(
stock_code=stock_code, stock_code=stock_code,
@@ -315,6 +315,11 @@ class SmartVolatilityScanner:
logger.info("Overseas scanner: no symbol universe for %s", market.name) logger.info("Overseas scanner: no symbol universe for %s", market.name)
return [] return []
logger.info(
"Overseas scanner: scanning %d fallback symbols for %s",
len(symbols),
market.name,
)
candidates: list[ScanCandidate] = [] candidates: list[ScanCandidate] = []
for stock_code in symbols: for stock_code in symbols:
try: try:
@@ -333,7 +338,7 @@ class SmartVolatilityScanner:
score = min(volatility_pct / 10.0, 1.0) * 100.0 score = min(volatility_pct / 10.0, 1.0) * 100.0
signal = "momentum" if change_rate >= 0 else "oversold" signal = "momentum" if change_rate >= 0 else "oversold"
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0))) implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
candidates.append( candidates.append(
ScanCandidate( ScanCandidate(
stock_code=stock_code, stock_code=stock_code,
@@ -350,6 +355,11 @@ class SmartVolatilityScanner:
logger.warning("Failed to analyze overseas %s: %s", stock_code, exc) logger.warning("Failed to analyze overseas %s: %s", stock_code, exc)
except Exception as exc: except Exception as exc:
logger.error("Unexpected error analyzing overseas %s: %s", stock_code, exc) logger.error("Unexpected error analyzing overseas %s: %s", stock_code, exc)
logger.info(
"Overseas symbol fallback scan found %d candidates for %s",
len(candidates),
market.name,
)
return candidates return candidates
def get_stock_codes(self, candidates: list[ScanCandidate]) -> list[str]: def get_stock_codes(self, candidates: list[ScanCandidate]) -> list[str]:

View File

@@ -410,8 +410,10 @@ class GeminiClient:
cached=True, cached=True,
) )
# Build optimized prompt # Build prompt (prompt_override takes priority for callers like pre_market_planner)
if self._enable_optimization: if "prompt_override" in market_data:
prompt = market_data["prompt_override"]
elif self._enable_optimization:
prompt = self._optimizer.build_compressed_prompt(market_data) prompt = self._optimizer.build_compressed_prompt(market_data)
else: else:
prompt = await self.build_prompt(market_data, news_sentiment) prompt = await self.build_prompt(market_data, news_sentiment)

View File

@@ -20,6 +20,39 @@ _KIS_VTS_HOST = "openapivts.koreainvestment.com"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def kr_tick_unit(price: float) -> int:
"""Return KRX tick size for the given price level.
KRX price tick rules (domestic stocks):
price < 2,000 → 1원
2,000 ≤ price < 5,000 → 5원
5,000 ≤ price < 20,000 → 10원
20,000 ≤ price < 50,000 → 50원
50,000 ≤ price < 200,000 → 100원
200,000 ≤ price < 500,000 → 500원
500,000 ≤ price → 1,000원
"""
if price < 2_000:
return 1
if price < 5_000:
return 5
if price < 20_000:
return 10
if price < 50_000:
return 50
if price < 200_000:
return 100
if price < 500_000:
return 500
return 1_000
def kr_round_down(price: float) -> int:
"""Round *down* price to the nearest KRX tick unit."""
tick = kr_tick_unit(price)
return int(price // tick * tick)
class LeakyBucket: class LeakyBucket:
"""Simple leaky-bucket rate limiter for async code.""" """Simple leaky-bucket rate limiter for async code."""
@@ -104,12 +137,14 @@ class KISBroker:
time_since_last_attempt = now - self._last_refresh_attempt time_since_last_attempt = now - self._last_refresh_attempt
if time_since_last_attempt < self._refresh_cooldown: if time_since_last_attempt < self._refresh_cooldown:
remaining = self._refresh_cooldown - time_since_last_attempt remaining = self._refresh_cooldown - time_since_last_attempt
error_msg = ( # Do not fail fast here. If token is unavailable, upstream calls
f"Token refresh on cooldown. " # will all fail for up to a minute and scanning returns no trades.
f"Retry in {remaining:.1f}s (KIS allows 1/minute)" logger.warning(
"Token refresh on cooldown. Waiting %.1fs before retry (KIS allows 1/minute)",
remaining,
) )
logger.warning(error_msg) await asyncio.sleep(remaining)
raise ConnectionError(error_msg) now = asyncio.get_event_loop().time()
logger.info("Refreshing KIS access token") logger.info("Refreshing KIS access token")
self._last_refresh_attempt = now self._last_refresh_attempt = now
@@ -196,12 +231,64 @@ class KISBroker:
except (TimeoutError, aiohttp.ClientError) as exc: except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc
async def get_current_price(
self, stock_code: str
) -> tuple[float, float, float]:
"""Fetch current price data for a domestic stock.
Uses the ``inquire-price`` API (FHKST01010100), which works in both
real and VTS environments and returns the actual last-traded price.
Returns:
(current_price, prdy_ctrt, frgn_ntby_qty)
- current_price: Last traded price in KRW.
- prdy_ctrt: Day change rate (%).
- frgn_ntby_qty: Foreigner net buy quantity.
"""
await self._rate_limiter.acquire()
session = self._get_session()
headers = await self._auth_headers("FHKST01010100")
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
}
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-price"
def _f(val: str | None) -> float:
try:
return float(val or "0")
except ValueError:
return 0.0
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_current_price failed ({resp.status}): {text}"
)
data = await resp.json()
out = data.get("output", {})
return (
_f(out.get("stck_prpr")),
_f(out.get("prdy_ctrt")),
_f(out.get("frgn_ntby_qty")),
)
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching current price: {exc}"
) from exc
async def get_balance(self) -> dict[str, Any]: async def get_balance(self) -> dict[str, Any]:
"""Fetch current account balance and holdings.""" """Fetch current account balance and holdings."""
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,
@@ -246,14 +333,30 @@ 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.
# ORD_DVSN: "00"=지정가, "01"=시장가
if price > 0:
ord_dvsn = "00" # 지정가
ord_price = kr_round_down(price)
else:
ord_dvsn = "01" # 시장가
ord_price = 0
body = { body = {
"CANO": self._account_no, "CANO": self._account_no,
"ACNT_PRDT_CD": self._product_cd, "ACNT_PRDT_CD": self._product_cd,
"PDNO": stock_code, "PDNO": stock_code,
"ORD_DVSN": "01" if price > 0 else "06", # 01=지정가, 06=시장가 "ORD_DVSN": ord_dvsn,
"ORD_QTY": str(quantity), "ORD_QTY": str(quantity),
"ORD_UNPR": str(price), "ORD_UNPR": str(ord_price),
} }
hash_key = await self._get_hash_key(body) hash_key = await self._get_hash_key(body)
@@ -302,26 +405,46 @@ class KISBroker:
await self._rate_limiter.acquire() await self._rate_limiter.acquire()
session = self._get_session() session = self._get_session()
# TR_ID for volume ranking if ranking_type == "volume":
tr_id = "FHPST01710000" if ranking_type == "volume" else "FHPST01710100" # 거래량순위: FHPST01710000 / /quotations/volume-rank
tr_id = "FHPST01710000"
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
params: dict[str, str] = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_COND_SCR_DIV_CODE": "20171",
"FID_INPUT_ISCD": "0000",
"FID_DIV_CLS_CODE": "0",
"FID_BLNG_CLS_CODE": "0",
"FID_TRGT_CLS_CODE": "111111111",
"FID_TRGT_EXLS_CLS_CODE": "0000000000",
"FID_INPUT_PRICE_1": "0",
"FID_INPUT_PRICE_2": "0",
"FID_VOL_CNT": "0",
"FID_INPUT_DATE_1": "",
}
else:
# 등락률순위: FHPST01700000 / /ranking/fluctuation (소문자 파라미터)
tr_id = "FHPST01700000"
url = f"{self._base_url}/uapi/domestic-stock/v1/ranking/fluctuation"
params = {
"fid_cond_mrkt_div_code": "J",
"fid_cond_scr_div_code": "20170",
"fid_input_iscd": "0000",
"fid_rank_sort_cls_code": "0000",
"fid_input_cnt_1": str(limit),
"fid_prc_cls_code": "0",
"fid_input_price_1": "0",
"fid_input_price_2": "0",
"fid_vol_cnt": "0",
"fid_trgt_cls_code": "0",
"fid_trgt_exls_cls_code": "0",
"fid_div_cls_code": "0",
"fid_rsfl_rate1": "0",
"fid_rsfl_rate2": "0",
}
headers = await self._auth_headers(tr_id) headers = await self._auth_headers(tr_id)
params = {
"FID_COND_MRKT_DIV_CODE": "J", # Stock/ETF/ETN
"FID_COND_SCR_DIV_CODE": "20001", # Volume surge
"FID_INPUT_ISCD": "0000", # All stocks
"FID_DIV_CLS_CODE": "0", # All types
"FID_BLNG_CLS_CODE": "0",
"FID_TRGT_CLS_CODE": "111111111",
"FID_TRGT_EXLS_CLS_CODE": "000000",
"FID_INPUT_PRICE_1": "0",
"FID_INPUT_PRICE_2": "0",
"FID_VOL_CNT": "0",
"FID_INPUT_DATE_1": "",
}
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
try: try:
async with session.get(url, headers=headers, params=params) as resp: async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200: if resp.status != 200:

View File

@@ -12,6 +12,24 @@ from src.broker.kis_api import KISBroker
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Ranking API uses different exchange codes than order/quote APIs.
_RANKING_EXCHANGE_MAP: dict[str, str] = {
"NASD": "NAS",
"NYSE": "NYS",
"AMEX": "AMS",
"SEHK": "HKS",
"SHAA": "SHS",
"SZAA": "SZS",
"HSX": "HSX",
"HNX": "HNX",
"TSE": "TSE",
}
# Price inquiry API (HHDFS00000300) uses the same short exchange codes as rankings.
# NASD → NAS, NYSE → NYS, AMEX → AMS (confirmed: AMEX returns empty, AMS returns price).
_PRICE_EXCHANGE_MAP: dict[str, str] = _RANKING_EXCHANGE_MAP
class OverseasBroker: class OverseasBroker:
"""KIS Overseas Stock API wrapper that reuses KISBroker infrastructure.""" """KIS Overseas Stock API wrapper that reuses KISBroker infrastructure."""
@@ -44,9 +62,11 @@ class OverseasBroker:
session = self._broker._get_session() session = self._broker._get_session()
headers = await self._broker._auth_headers("HHDFS00000300") headers = await self._broker._auth_headers("HHDFS00000300")
# Map internal exchange codes to the short form expected by the price API.
price_excd = _PRICE_EXCHANGE_MAP.get(exchange_code, exchange_code)
params = { params = {
"AUTH": "", "AUTH": "",
"EXCD": exchange_code, "EXCD": price_excd,
"SYMB": stock_code, "SYMB": stock_code,
} }
url = f"{self._broker._base_url}/uapi/overseas-price/v1/quotations/price" url = f"{self._broker._base_url}/uapi/overseas-price/v1/quotations/price"
@@ -70,7 +90,7 @@ class OverseasBroker:
ranking_type: str = "fluctuation", ranking_type: str = "fluctuation",
limit: int = 30, limit: int = 30,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Fetch overseas rankings (price change or volume amount). """Fetch overseas rankings (price change or volume surge).
Ranking API specs may differ by account/product. Endpoint paths and Ranking API specs may differ by account/product. Endpoint paths and
TR_IDs are configurable via settings and can be overridden in .env. TR_IDs are configurable via settings and can be overridden in .env.
@@ -81,47 +101,63 @@ class OverseasBroker:
await self._broker._rate_limiter.acquire() await self._broker._rate_limiter.acquire()
session = self._broker._get_session() session = self._broker._get_session()
ranking_excd = _RANKING_EXCHANGE_MAP.get(exchange_code, exchange_code)
if ranking_type == "volume": if ranking_type == "volume":
tr_id = self._broker._settings.OVERSEAS_RANKING_VOLUME_TR_ID tr_id = self._broker._settings.OVERSEAS_RANKING_VOLUME_TR_ID
path = self._broker._settings.OVERSEAS_RANKING_VOLUME_PATH path = self._broker._settings.OVERSEAS_RANKING_VOLUME_PATH
params: dict[str, str] = {
"AUTH": "",
"EXCD": ranking_excd,
"MIXN": "0",
"VOL_RANG": "0",
}
else: else:
tr_id = self._broker._settings.OVERSEAS_RANKING_FLUCT_TR_ID tr_id = self._broker._settings.OVERSEAS_RANKING_FLUCT_TR_ID
path = self._broker._settings.OVERSEAS_RANKING_FLUCT_PATH path = self._broker._settings.OVERSEAS_RANKING_FLUCT_PATH
params = {
"AUTH": "",
"EXCD": ranking_excd,
"NDAY": "0",
"GUBN": "1",
"VOL_RANG": "0",
}
headers = await self._broker._auth_headers(tr_id) headers = await self._broker._auth_headers(tr_id)
url = f"{self._broker._base_url}{path}" url = f"{self._broker._base_url}{path}"
# Try common param variants used by KIS overseas quotation APIs. try:
param_variants = [ async with session.get(url, headers=headers, params=params) as resp:
{"AUTH": "", "EXCD": exchange_code, "NREC": str(max(limit, 30))}, if resp.status != 200:
{"AUTH": "", "OVRS_EXCG_CD": exchange_code, "NREC": str(max(limit, 30))},
{"AUTH": "", "EXCD": exchange_code},
{"AUTH": "", "OVRS_EXCG_CD": exchange_code},
]
last_error: str | None = None
for params in param_variants:
try:
async with session.get(url, headers=headers, params=params) as resp:
text = await resp.text() text = await resp.text()
if resp.status != 200: if resp.status == 404:
last_error = f"HTTP {resp.status}: {text}" logger.warning(
continue "Overseas ranking endpoint unavailable (404) for %s/%s; "
"using symbol fallback scan",
exchange_code,
ranking_type,
)
return []
raise ConnectionError(
f"fetch_overseas_rankings failed ({resp.status}): {text}"
)
data = await resp.json() data = await resp.json()
rows = self._extract_ranking_rows(data) rows = self._extract_ranking_rows(data)
if rows: if rows:
return rows[:limit] return rows[:limit]
# keep trying another param variant if response has no usable rows logger.debug(
last_error = f"empty output (keys={list(data.keys())})" "Overseas ranking returned empty for %s/%s (keys=%s)",
except (TimeoutError, aiohttp.ClientError) as exc: exchange_code,
last_error = str(exc) ranking_type,
continue list(data.keys()),
)
raise ConnectionError( return []
f"fetch_overseas_rankings failed for {exchange_code}/{ranking_type}: {last_error}" except (TimeoutError, aiohttp.ClientError) as exc:
) raise ConnectionError(
f"Network error fetching overseas rankings: {exc}"
) from exc
async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]: async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]:
""" """
@@ -139,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,
@@ -193,8 +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
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1006U" # Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 주문' 시트
if self._broker._settings.MODE == "live":
tr_id = "TTTT1002U" if order_type == "BUY" else "TTTT1006U"
else:
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1001U"
body = { body = {
"CANO": self._broker._account_no, "CANO": self._broker._account_no,
@@ -221,14 +265,27 @@ class OverseasBroker:
f"send_overseas_order failed ({resp.status}): {text}" f"send_overseas_order failed ({resp.status}): {text}"
) )
data = await resp.json() data = await resp.json()
logger.info( rt_cd = data.get("rt_cd", "")
"Overseas order submitted", msg1 = data.get("msg1", "")
extra={ if rt_cd == "0":
"exchange": exchange_code, logger.info(
"stock_code": stock_code, "Overseas order submitted",
"action": order_type, extra={
}, "exchange": exchange_code,
) "stock_code": stock_code,
"action": order_type,
},
)
else:
logger.warning(
"Overseas order rejected (rt_cd=%s): %s [%s %s %s qty=%d]",
rt_cd,
msg1,
order_type,
stock_code,
exchange_code,
quantity,
)
return data return data
except (TimeoutError, aiohttp.ClientError) as exc: except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError( raise ConnectionError(

View File

@@ -13,11 +13,11 @@ 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
GEMINI_MODEL: str = "gemini-pro" GEMINI_MODEL: str = "gemini-2.0-flash"
# External Data APIs (optional — for data-driven decisions) # External Data APIs (optional — for data-driven decisions)
NEWS_API_KEY: str | None = None NEWS_API_KEY: str | None = None
@@ -55,6 +55,11 @@ class Settings(BaseSettings):
# Trading mode # Trading mode
MODE: str = Field(default="paper", pattern="^(paper|live)$") MODE: str = Field(default="paper", pattern="^(paper|live)$")
# Simulated USD cash for VTS (paper) overseas trading.
# KIS VTS overseas balance API returns errors for most accounts.
# This value is used as a fallback when the balance API returns 0 in paper mode.
PAPER_OVERSEAS_CASH: float = Field(default=50000.0, ge=0.0)
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls) # Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$") TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$")
DAILY_SESSIONS: int = Field(default=4, ge=1, le=10) DAILY_SESSIONS: int = Field(default=4, ge=1, le=10)
@@ -88,16 +93,26 @@ class Settings(BaseSettings):
TELEGRAM_COMMANDS_ENABLED: bool = True TELEGRAM_COMMANDS_ENABLED: bool = True
TELEGRAM_POLLING_INTERVAL: float = 1.0 # seconds TELEGRAM_POLLING_INTERVAL: float = 1.0 # seconds
# Telegram notification type filters (granular control)
# circuit_breaker is always sent regardless — safety-critical
TELEGRAM_NOTIFY_TRADES: bool = True # BUY/SELL execution alerts
TELEGRAM_NOTIFY_MARKET_OPEN_CLOSE: bool = True # Market open/close alerts
TELEGRAM_NOTIFY_FAT_FINGER: bool = True # Fat-finger rejection alerts
TELEGRAM_NOTIFY_SYSTEM_EVENTS: bool = True # System start/shutdown alerts
TELEGRAM_NOTIFY_PLAYBOOK: bool = True # Playbook generated/failed alerts
TELEGRAM_NOTIFY_SCENARIO_MATCH: bool = True # Scenario matched alerts (most frequent)
TELEGRAM_NOTIFY_ERRORS: bool = True # Error alerts
# Overseas ranking API (KIS endpoint/TR_ID may vary by account/product) # Overseas ranking API (KIS endpoint/TR_ID may vary by account/product)
# Override these from .env if your account uses different specs. # Override these from .env if your account uses different specs.
OVERSEAS_RANKING_ENABLED: bool = True OVERSEAS_RANKING_ENABLED: bool = True
OVERSEAS_RANKING_FLUCT_TR_ID: str = "HHDFS76200100" OVERSEAS_RANKING_FLUCT_TR_ID: str = "HHDFS76290000"
OVERSEAS_RANKING_VOLUME_TR_ID: str = "HHDFS76200200" OVERSEAS_RANKING_VOLUME_TR_ID: str = "HHDFS76270000"
OVERSEAS_RANKING_FLUCT_PATH: str = ( OVERSEAS_RANKING_FLUCT_PATH: str = (
"/uapi/overseas-price/v1/quotations/inquire-updown-rank" "/uapi/overseas-stock/v1/ranking/updown-rate"
) )
OVERSEAS_RANKING_VOLUME_PATH: str = ( OVERSEAS_RANKING_VOLUME_PATH: str = (
"/uapi/overseas-price/v1/quotations/inquire-volume-rank" "/uapi/overseas-stock/v1/ranking/volume-surge"
) )
# Dashboard (optional) # Dashboard (optional)

View File

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

View File

@@ -1,9 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The Ouroboros Dashboard</title> <title>The Ouroboros Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style> <style>
:root { :root {
--bg: #0b1724; --bg: #0b1724;
@@ -11,51 +12,760 @@
--fg: #e6eef7; --fg: #e6eef7;
--muted: #9fb3c8; --muted: #9fb3c8;
--accent: #3cb371; --accent: #3cb371;
--red: #e05555;
--warn: #e8a040;
--border: #28455f;
} }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: radial-gradient(circle at top left, #173b58, var(--bg)); background: radial-gradient(circle at top left, #173b58, var(--bg));
color: var(--fg); color: var(--fg);
min-height: 100vh;
font-size: 13px;
} }
.wrap { .wrap { max-width: 1100px; margin: 0 auto; padding: 20px 16px; }
max-width: 900px;
margin: 48px auto; /* Header */
padding: 0 16px; header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
} }
header h1 { font-size: 18px; color: var(--accent); letter-spacing: 0.5px; }
.header-right { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: 12px; }
.refresh-btn {
background: none; border: 1px solid var(--border); color: var(--muted);
padding: 4px 10px; border-radius: 6px; cursor: pointer; font-family: inherit;
font-size: 12px; transition: border-color 0.2s;
}
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
/* CB Gauge */
.cb-gauge-wrap {
display: flex; align-items: center; gap: 8px;
font-size: 11px; color: var(--muted);
}
.cb-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.cb-dot.ok { background: var(--accent); }
.cb-dot.warning { background: var(--warn); animation: pulse-warn 1.2s ease-in-out infinite; }
.cb-dot.tripped { background: var(--red); animation: pulse-warn 0.6s ease-in-out infinite; }
.cb-dot.unknown { background: var(--border); }
@keyframes pulse-warn {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.cb-bar-wrap { width: 64px; height: 5px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
.cb-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s, background 0.4s; }
/* Summary cards */
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
@media (max-width: 700px) { .cards { grid-template-columns: repeat(2, 1fr); } }
.card { .card {
background: color-mix(in oklab, var(--panel), black 12%); background: var(--panel);
border: 1px solid #28455f; border: 1px solid var(--border);
border-radius: 12px; border-radius: 10px;
padding: 20px; padding: 16px;
} }
h1 { .card-label { color: var(--muted); font-size: 11px; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
margin-top: 0; .card-value { font-size: 22px; font-weight: 700; }
.card-sub { color: var(--muted); font-size: 11px; margin-top: 4px; }
.positive { color: var(--accent); }
.negative { color: var(--red); }
.neutral { color: var(--fg); }
/* Chart panel */
.chart-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 20px;
} }
code { .panel-header {
color: var(--accent); display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
} }
li { .panel-title { font-size: 13px; color: var(--muted); font-weight: 600; }
margin: 6px 0; .chart-container { position: relative; height: 180px; }
color: var(--muted); .chart-error { color: var(--muted); text-align: center; padding: 40px 0; font-size: 12px; }
/* Days selector */
.days-selector { display: flex; gap: 4px; }
.day-btn {
background: none; border: 1px solid var(--border); color: var(--muted);
padding: 3px 8px; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 11px;
} }
.day-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(60, 179, 113, 0.08); }
/* Decisions panel */
.decisions-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
}
.market-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
.tab-btn {
background: none; border: 1px solid var(--border); color: var(--muted);
padding: 4px 10px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 11px;
}
.tab-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(60, 179, 113, 0.08); }
.decisions-table { width: 100%; border-collapse: collapse; margin-top: 14px; }
.decisions-table th {
text-align: left; color: var(--muted); font-size: 11px; font-weight: 600;
padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap;
}
.decisions-table td {
padding: 8px 8px; border-bottom: 1px solid rgba(40, 69, 95, 0.5);
vertical-align: middle; white-space: nowrap;
}
.decisions-table tr:last-child td { border-bottom: none; }
.decisions-table tr:hover td { background: rgba(255,255,255,0.02); }
.badge {
display: inline-block; padding: 2px 7px; border-radius: 4px;
font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
}
.badge-buy { background: rgba(60, 179, 113, 0.15); color: var(--accent); }
.badge-sell { background: rgba(224, 85, 85, 0.15); color: var(--red); }
.badge-hold { background: rgba(159, 179, 200, 0.12); color: var(--muted); }
.conf-bar-wrap { display: flex; align-items: center; gap: 6px; min-width: 90px; }
.conf-bar { flex: 1; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
.conf-fill { height: 100%; border-radius: 3px; background: var(--accent); transition: width 0.3s; }
.conf-val { color: var(--muted); font-size: 11px; min-width: 26px; text-align: right; }
.rationale-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
.empty-row td { text-align: center; color: var(--muted); padding: 24px; }
/* Positions panel */
.positions-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 20px;
}
.positions-table { width: 100%; border-collapse: collapse; margin-top: 14px; }
.positions-table th {
text-align: left; color: var(--muted); font-size: 11px; font-weight: 600;
padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap;
}
.positions-table td {
padding: 8px 8px; border-bottom: 1px solid rgba(40, 69, 95, 0.5);
vertical-align: middle; white-space: nowrap;
}
.positions-table tr:last-child td { border-bottom: none; }
.positions-table tr:hover td { background: rgba(255,255,255,0.02); }
.pos-empty { color: var(--muted); text-align: center; padding: 20px 0; font-size: 12px; }
.pos-count {
display: inline-block; background: rgba(60, 179, 113, 0.12);
color: var(--accent); font-size: 11px; font-weight: 700;
padding: 2px 8px; border-radius: 10px; margin-left: 8px;
}
/* Spinner */
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Generic panel */
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-top: 20px;
}
/* Playbook panel - details/summary accordion */
.playbook-panel details { border: 1px solid var(--border); border-radius: 4px; margin-bottom: 6px; }
.playbook-panel summary { padding: 8px 12px; cursor: pointer; font-weight: 600; background: var(--bg); color: var(--fg); }
.playbook-panel summary:hover { color: var(--accent); }
.playbook-panel pre { margin: 0; padding: 12px; background: var(--bg); overflow-x: auto;
font-size: 11px; color: #a0c4ff; white-space: pre-wrap; }
/* Scorecard KPI card grid */
.scorecard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
.kpi-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px; text-align: center; }
.kpi-card .kpi-label { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
.kpi-card .kpi-value { font-size: 20px; font-weight: 700; color: var(--fg); }
/* Scenarios table */
.scenarios-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.scenarios-table th { background: var(--bg); padding: 8px; text-align: left; border-bottom: 1px solid var(--border);
color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; }
.scenarios-table td { padding: 7px 8px; border-bottom: 1px solid rgba(40,69,95,0.5); }
.scenarios-table tr:hover td { background: rgba(255,255,255,0.02); }
/* Context table */
.context-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.context-table th { background: var(--bg); padding: 8px; text-align: left; border-bottom: 1px solid var(--border);
color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; }
.context-table td { padding: 6px 8px; border-bottom: 1px solid rgba(40,69,95,0.5); vertical-align: top; }
.context-value { max-height: 60px; overflow-y: auto; color: #a0c4ff; word-break: break-all; }
/* Common panel select controls */
.panel-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.panel-controls select, .panel-controls input[type="number"] {
background: var(--bg); color: var(--fg); border: 1px solid var(--border);
border-radius: 4px; padding: 4px 8px; font-size: 13px; font-family: inherit;
}
.panel-date { color: var(--muted); font-size: 12px; }
.empty-msg { color: var(--muted); text-align: center; padding: 20px 0; font-size: 12px; }
</style> </style>
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<div class="card"> <!-- Header -->
<h1>The Ouroboros Dashboard API</h1> <header>
<p>Use the following endpoints:</p> <h1>&#x1F40D; The Ouroboros</h1>
<ul> <div class="header-right">
<li><code>/api/status</code></li> <div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker">
<li><code>/api/playbook/{date}?market=KR</code></li> <span class="cb-dot unknown" id="cb-dot"></span>
<li><code>/api/scorecard/{date}?market=KR</code></li> <span id="cb-label">CB --</span>
<li><code>/api/performance?market=all</code></li> <div class="cb-bar-wrap">
<li><code>/api/context/{layer}</code></li> <div class="cb-bar-fill" id="cb-bar" style="width:0%;background:var(--accent)"></div>
<li><code>/api/decisions?market=KR</code></li> </div>
<li><code>/api/scenarios/active?market=US</code></li> </div>
</ul> <span id="last-updated">--</span>
<button class="refresh-btn" onclick="refreshAll()">&#x21BA; 새로고침</button>
</div>
</header>
<!-- Summary cards -->
<div class="cards">
<div class="card">
<div class="card-label">오늘 거래</div>
<div class="card-value neutral" id="card-trades">--</div>
<div class="card-sub" id="card-trades-sub">거래 건수</div>
</div>
<div class="card">
<div class="card-label">오늘 P&amp;L</div>
<div class="card-value" id="card-pnl">--</div>
<div class="card-sub" id="card-pnl-sub">실현 손익</div>
</div>
<div class="card">
<div class="card-label">승률</div>
<div class="card-value neutral" id="card-winrate">--</div>
<div class="card-sub">전체 누적</div>
</div>
<div class="card">
<div class="card-label">누적 거래</div>
<div class="card-value neutral" id="card-total">--</div>
<div class="card-sub">전체 기간</div>
</div>
</div>
<!-- Open Positions -->
<div class="positions-panel">
<div class="panel-header">
<span class="panel-title">
현재 보유 포지션
<span class="pos-count" id="positions-count">0</span>
</span>
</div>
<table class="positions-table">
<thead>
<tr>
<th>종목</th>
<th>시장</th>
<th>수량</th>
<th>진입가</th>
<th>보유 시간</th>
</tr>
</thead>
<tbody id="positions-body">
<tr><td colspan="5" class="pos-empty"><span class="spinner"></span></td></tr>
</tbody>
</table>
</div>
<!-- P&L Chart -->
<div class="chart-panel">
<div class="panel-header">
<span class="panel-title">P&amp;L 추이</span>
<div class="days-selector">
<button class="day-btn active" data-days="7" onclick="selectDays(this)">7일</button>
<button class="day-btn" data-days="30" onclick="selectDays(this)">30일</button>
<button class="day-btn" data-days="90" onclick="selectDays(this)">90일</button>
</div>
</div>
<div class="chart-container">
<canvas id="pnl-chart"></canvas>
<div class="chart-error" id="chart-error" style="display:none">데이터 없음</div>
</div>
</div>
<!-- Decisions log -->
<div class="decisions-panel">
<div class="panel-header">
<span class="panel-title">최근 결정 로그</span>
<div class="market-tabs" id="market-tabs">
<button class="tab-btn active" data-market="KR" onclick="selectMarket(this)">KR</button>
<button class="tab-btn" data-market="US_NASDAQ" onclick="selectMarket(this)">US_NASDAQ</button>
<button class="tab-btn" data-market="US_NYSE" onclick="selectMarket(this)">US_NYSE</button>
<button class="tab-btn" data-market="JP" onclick="selectMarket(this)">JP</button>
<button class="tab-btn" data-market="HK" onclick="selectMarket(this)">HK</button>
</div>
</div>
<table class="decisions-table">
<thead>
<tr>
<th>시각</th>
<th>종목</th>
<th>액션</th>
<th>신뢰도</th>
<th>사유</th>
</tr>
</thead>
<tbody id="decisions-body">
<tr class="empty-row"><td colspan="5"><span class="spinner"></span></td></tr>
</tbody>
</table>
</div>
<!-- playbook panel -->
<div class="panel playbook-panel">
<div class="panel-header">
<span class="panel-title">&#x1F4CB; 프리마켓 플레이북</span>
<div class="panel-controls">
<select id="pb-market-select" onchange="fetchPlaybook()">
<option value="KR">KR</option>
<option value="US_NASDAQ">US_NASDAQ</option>
<option value="US_NYSE">US_NYSE</option>
</select>
<span id="pb-date" class="panel-date"></span>
</div>
</div>
<div id="playbook-content"><p class="empty-msg">데이터 없음</p></div>
</div>
<!-- scorecard panel -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">&#x1F4CA; 일간 스코어카드</span>
<div class="panel-controls">
<select id="sc-market-select" onchange="fetchScorecard()">
<option value="KR">KR</option>
<option value="US_NASDAQ">US_NASDAQ</option>
</select>
<span id="sc-date" class="panel-date"></span>
</div>
</div>
<div id="scorecard-grid" class="scorecard-grid"><p class="empty-msg">데이터 없음</p></div>
</div>
<!-- scenarios panel -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">&#x1F3AF; 활성 시나리오 매칭</span>
<div class="panel-controls">
<select id="scen-market-select" onchange="fetchScenarios()">
<option value="KR">KR</option>
<option value="US_NASDAQ">US_NASDAQ</option>
</select>
</div>
</div>
<div id="scenarios-content"><p class="empty-msg">데이터 없음</p></div>
</div>
<!-- context layer panel -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">&#x1F9E0; 컨텍스트 트리</span>
<div class="panel-controls">
<select id="ctx-layer-select" onchange="fetchContext()">
<option value="L7_REALTIME">L7_REALTIME</option>
<option value="L6_DAILY">L6_DAILY</option>
<option value="L5_WEEKLY">L5_WEEKLY</option>
<option value="L4_MONTHLY">L4_MONTHLY</option>
<option value="L3_QUARTERLY">L3_QUARTERLY</option>
<option value="L2_YEARLY">L2_YEARLY</option>
<option value="L1_LIFETIME">L1_LIFETIME</option>
</select>
<input id="ctx-limit" type="number" value="20" min="1" max="200"
style="width:60px;" onchange="fetchContext()">
</div>
</div>
<div id="context-content"><p class="empty-msg">데이터 없음</p></div>
</div> </div>
</div> </div>
<script>
let pnlChart = null;
let currentDays = 7;
let currentMarket = 'KR';
function fmt(dt) {
try {
const d = new Date(dt);
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false });
} catch { return dt || '--'; }
}
function fmtPnl(v) {
if (v === null || v === undefined) return '--';
const n = parseFloat(v);
const cls = n > 0 ? 'positive' : n < 0 ? 'negative' : 'neutral';
const sign = n > 0 ? '+' : '';
return `<span class="${cls}">${sign}${n.toFixed(2)}</span>`;
}
function badge(action) {
const a = (action || '').toUpperCase();
const cls = a === 'BUY' ? 'badge-buy' : a === 'SELL' ? 'badge-sell' : 'badge-hold';
return `<span class="badge ${cls}">${a}</span>`;
}
function confBar(conf) {
const pct = Math.min(Math.max(conf || 0, 0), 100);
return `<div class="conf-bar-wrap">
<div class="conf-bar"><div class="conf-fill" style="width:${pct}%"></div></div>
<span class="conf-val">${pct}</span>
</div>`;
}
function fmtPrice(v, market) {
if (v === null || v === undefined) return '--';
const n = parseFloat(v);
const sym = market === 'KR' ? '₩' : market === 'JP' ? '¥' : market === 'HK' ? 'HK$' : '$';
return sym + n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 4 });
}
async function fetchPositions() {
const tbody = document.getElementById('positions-body');
const countEl = document.getElementById('positions-count');
try {
const r = await fetch('/api/positions');
if (!r.ok) throw new Error('fetch failed');
const d = await r.json();
countEl.textContent = d.count ?? 0;
if (!d.positions || d.positions.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="pos-empty">현재 보유 중인 포지션 없음</td></tr>';
return;
}
tbody.innerHTML = d.positions.map(p => `
<tr>
<td><strong>${p.stock_code || '--'}</strong></td>
<td><span style="color:var(--muted);font-size:11px">${p.market || '--'}</span></td>
<td>${p.quantity ?? '--'}</td>
<td>${fmtPrice(p.entry_price, p.market)}</td>
<td style="color:var(--muted);font-size:11px">${p.held || '--'}</td>
</tr>
`).join('');
} catch {
tbody.innerHTML = '<tr><td colspan="5" class="pos-empty">데이터 로드 실패</td></tr>';
}
}
function renderCbGauge(cb) {
if (!cb) return;
const dot = document.getElementById('cb-dot');
const label = document.getElementById('cb-label');
const bar = document.getElementById('cb-bar');
const status = cb.status || 'unknown';
const threshold = cb.threshold_pct ?? -3.0;
const current = cb.current_pnl_pct;
// dot color
dot.className = `cb-dot ${status}`;
// label
if (current !== null && current !== undefined) {
const sign = current > 0 ? '+' : '';
label.textContent = `CB ${sign}${current.toFixed(2)}%`;
} else {
label.textContent = 'CB --';
}
// bar: fill = how much of the threshold has been consumed (0%=safe, 100%=tripped)
const colorMap = { ok: 'var(--accent)', warning: 'var(--warn)', tripped: 'var(--red)', unknown: 'var(--border)' };
bar.style.background = colorMap[status] || 'var(--border)';
if (current !== null && current !== undefined && threshold < 0) {
const fillPct = Math.min(Math.max((current / threshold) * 100, 0), 100);
bar.style.width = `${fillPct}%`;
} else {
bar.style.width = '0%';
}
}
async function fetchStatus() {
try {
const r = await fetch('/api/status');
if (!r.ok) return;
const d = await r.json();
const t = d.totals || {};
document.getElementById('card-trades').textContent = t.trade_count ?? '--';
const pnlEl = document.getElementById('card-pnl');
const pnlV = t.total_pnl;
if (pnlV !== undefined) {
const n = parseFloat(pnlV);
const sign = n > 0 ? '+' : '';
pnlEl.textContent = `${sign}${n.toFixed(2)}`;
pnlEl.className = `card-value ${n > 0 ? 'positive' : n < 0 ? 'negative' : 'neutral'}`;
}
document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}`;
renderCbGauge(d.circuit_breaker);
} catch {}
}
async function fetchPerformance() {
try {
const r = await fetch('/api/performance?market=all');
if (!r.ok) return;
const d = await r.json();
const c = d.combined || {};
document.getElementById('card-winrate').textContent = c.win_rate !== undefined ? `${c.win_rate}%` : '--';
document.getElementById('card-total').textContent = c.total_trades ?? '--';
} catch {}
}
async function fetchPnlHistory(days) {
try {
const r = await fetch(`/api/pnl/history?days=${days}`);
if (!r.ok) throw new Error('fetch failed');
const d = await r.json();
renderChart(d);
} catch {
document.getElementById('chart-error').style.display = 'block';
}
}
function renderChart(data) {
const errEl = document.getElementById('chart-error');
if (!data.labels || data.labels.length === 0) {
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
const colors = data.pnl.map(v => v >= 0 ? 'rgba(60,179,113,0.75)' : 'rgba(224,85,85,0.75)');
const borderColors = data.pnl.map(v => v >= 0 ? '#3cb371' : '#e05555');
if (pnlChart) { pnlChart.destroy(); pnlChart = null; }
const ctx = document.getElementById('pnl-chart').getContext('2d');
pnlChart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Daily P&L',
data: data.pnl,
backgroundColor: colors,
borderColor: borderColors,
borderWidth: 1,
borderRadius: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => {
const v = ctx.parsed.y;
const sign = v >= 0 ? '+' : '';
const trades = data.trades[ctx.dataIndex];
return [`P&L: ${sign}${v.toFixed(2)}`, `거래: ${trades}`];
}
}
}
},
scales: {
x: {
ticks: { color: '#9fb3c8', font: { size: 10 }, maxRotation: 0 },
grid: { color: 'rgba(40,69,95,0.4)' }
},
y: {
ticks: { color: '#9fb3c8', font: { size: 10 } },
grid: { color: 'rgba(40,69,95,0.4)' }
}
}
}
});
}
async function fetchDecisions(market) {
const tbody = document.getElementById('decisions-body');
tbody.innerHTML = '<tr class="empty-row"><td colspan="5"><span class="spinner"></span></td></tr>';
try {
const r = await fetch(`/api/decisions?market=${market}&limit=50`);
if (!r.ok) throw new Error('fetch failed');
const d = await r.json();
if (!d.decisions || d.decisions.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">결정 로그 없음</td></tr>';
return;
}
tbody.innerHTML = d.decisions.map(dec => `
<tr>
<td>${fmt(dec.timestamp)}</td>
<td>${dec.stock_code || '--'}</td>
<td>${badge(dec.action)}</td>
<td>${confBar(dec.confidence)}</td>
<td class="rationale-cell" title="${(dec.rationale || '').replace(/"/g, '&quot;')}">${dec.rationale || '--'}</td>
</tr>
`).join('');
} catch {
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">데이터 로드 실패</td></tr>';
}
}
function selectDays(btn) {
document.querySelectorAll('.day-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentDays = parseInt(btn.dataset.days, 10);
fetchPnlHistory(currentDays);
}
function selectMarket(btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentMarket = btn.dataset.market;
fetchDecisions(currentMarket);
}
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function fetchJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
async function fetchPlaybook() {
const market = document.getElementById('pb-market-select').value;
const date = todayStr();
document.getElementById('pb-date').textContent = date;
const el = document.getElementById('playbook-content');
try {
const data = await fetchJSON(`/api/playbook/${date}?market=${market}`);
const stocks = data.stock_playbooks ?? [];
if (stocks.length === 0) {
el.innerHTML = '<p class="empty-msg">오늘 플레이북 없음</p>';
return;
}
el.innerHTML = stocks.map(sp =>
`<details><summary>${esc(sp.stock_code ?? '?')}${esc(sp.signal ?? '')}</summary>` +
`<pre>${esc(JSON.stringify(sp, null, 2))}</pre></details>`
).join('');
} catch {
el.innerHTML = '<p class="empty-msg">플레이북 없음 (오늘 미생성 또는 API 오류)</p>';
}
}
async function fetchScorecard() {
const market = document.getElementById('sc-market-select').value;
const date = todayStr();
document.getElementById('sc-date').textContent = date;
const el = document.getElementById('scorecard-grid');
try {
const data = await fetchJSON(`/api/scorecard/${date}?market=${market}`);
const sc = data.scorecard ?? {};
const entries = Object.entries(sc);
if (entries.length === 0) {
el.innerHTML = '<p class="empty-msg">스코어카드 없음</p>';
return;
}
el.className = 'scorecard-grid';
el.innerHTML = entries.map(([k, v]) => `
<div class="kpi-card">
<div class="kpi-label">${esc(k)}</div>
<div class="kpi-value">${typeof v === 'number' ? v.toFixed(2) : esc(String(v))}</div>
</div>`).join('');
} catch {
el.innerHTML = '<p class="empty-msg">스코어카드 없음 (오늘 미생성 또는 API 오류)</p>';
}
}
async function fetchScenarios() {
const market = document.getElementById('scen-market-select').value;
const date = todayStr();
const el = document.getElementById('scenarios-content');
try {
const data = await fetchJSON(`/api/scenarios/active?market=${market}&date_str=${date}&limit=50`);
const matches = data.matches ?? [];
if (matches.length === 0) {
el.innerHTML = '<p class="empty-msg">활성 시나리오 없음</p>';
return;
}
el.innerHTML = `<table class="scenarios-table">
<thead><tr><th>종목</th><th>신호</th><th>신뢰도</th><th>매칭 조건</th></tr></thead>
<tbody>${matches.map(m => `
<tr>
<td>${esc(m.stock_code)}</td>
<td>${esc(m.signal ?? '-')}</td>
<td>${esc(m.confidence ?? '-')}</td>
<td><code style="font-size:11px">${esc(JSON.stringify(m.scenario_match ?? {}))}</code></td>
</tr>`).join('')}
</tbody></table>`;
} catch {
el.innerHTML = '<p class="empty-msg">데이터 없음</p>';
}
}
async function fetchContext() {
const layer = document.getElementById('ctx-layer-select').value;
const limit = Math.min(Math.max(parseInt(document.getElementById('ctx-limit').value, 10) || 20, 1), 200);
const el = document.getElementById('context-content');
try {
const data = await fetchJSON(`/api/context/${layer}?limit=${limit}`);
const entries = data.entries ?? [];
if (entries.length === 0) {
el.innerHTML = '<p class="empty-msg">컨텍스트 없음</p>';
return;
}
el.innerHTML = `<table class="context-table">
<thead><tr><th>timeframe</th><th>key</th><th>value</th><th>updated</th></tr></thead>
<tbody>${entries.map(e => `
<tr>
<td>${esc(e.timeframe)}</td>
<td>${esc(e.key)}</td>
<td><div class="context-value">${esc(JSON.stringify(e.value ?? e.raw_value))}</div></td>
<td style="font-size:11px;color:var(--muted)">${esc((e.updated_at ?? '').slice(0, 16))}</td>
</tr>`).join('')}
</tbody></table>`;
} catch {
el.innerHTML = '<p class="empty-msg">데이터 없음</p>';
}
}
async function refreshAll() {
document.getElementById('last-updated').textContent = '업데이트 중...';
await Promise.all([
fetchStatus(),
fetchPerformance(),
fetchPositions(),
fetchPnlHistory(currentDays),
fetchDecisions(currentMarket),
fetchPlaybook(),
fetchScorecard(),
fetchScenarios(),
fetchContext(),
]);
const now = new Date();
const timeStr = now.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
document.getElementById('last-updated').textContent = `마지막 업데이트: ${timeStr}`;
}
// Initial load
refreshAll();
// Auto-refresh every 30 seconds
setInterval(refreshAll, 30000);
</script>
</body> </body>
</html> </html>

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,9 @@ import asyncio
import logging import logging
import time import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass, fields
from enum import Enum from enum import Enum
from typing import ClassVar
import aiohttp import aiohttp
@@ -58,6 +59,45 @@ class LeakyBucket:
self._tokens -= 1.0 self._tokens -= 1.0
@dataclass
class NotificationFilter:
"""Granular on/off flags for each notification type.
circuit_breaker is intentionally omitted — it is always sent regardless.
"""
# Maps user-facing command keys to dataclass field names
KEYS: ClassVar[dict[str, str]] = {
"trades": "trades",
"market": "market_open_close",
"fatfinger": "fat_finger",
"system": "system_events",
"playbook": "playbook",
"scenario": "scenario_match",
"errors": "errors",
}
trades: bool = True
market_open_close: bool = True
fat_finger: bool = True
system_events: bool = True
playbook: bool = True
scenario_match: bool = True
errors: bool = True
def set_flag(self, key: str, value: bool) -> bool:
"""Set a filter flag by user-facing key. Returns False if key is unknown."""
field = self.KEYS.get(key.lower())
if field is None:
return False
setattr(self, field, value)
return True
def as_dict(self) -> dict[str, bool]:
"""Return {user_key: current_value} for display."""
return {k: getattr(self, field) for k, field in self.KEYS.items()}
@dataclass @dataclass
class NotificationMessage: class NotificationMessage:
"""Internal notification message structure.""" """Internal notification message structure."""
@@ -79,6 +119,7 @@ class TelegramClient:
chat_id: str | None = None, chat_id: str | None = None,
enabled: bool = True, enabled: bool = True,
rate_limit: float = DEFAULT_RATE, rate_limit: float = DEFAULT_RATE,
notification_filter: NotificationFilter | None = None,
) -> None: ) -> None:
""" """
Initialize Telegram client. Initialize Telegram client.
@@ -88,12 +129,14 @@ class TelegramClient:
chat_id: Target chat ID (user or group) chat_id: Target chat ID (user or group)
enabled: Enable/disable notifications globally enabled: Enable/disable notifications globally
rate_limit: Maximum messages per second rate_limit: Maximum messages per second
notification_filter: Granular per-type on/off flags
""" """
self._bot_token = bot_token self._bot_token = bot_token
self._chat_id = chat_id self._chat_id = chat_id
self._enabled = enabled self._enabled = enabled
self._rate_limiter = LeakyBucket(rate=rate_limit) self._rate_limiter = LeakyBucket(rate=rate_limit)
self._session: aiohttp.ClientSession | None = None self._session: aiohttp.ClientSession | None = None
self._filter = notification_filter if notification_filter is not None else NotificationFilter()
if not enabled: if not enabled:
logger.info("Telegram notifications disabled via configuration") logger.info("Telegram notifications disabled via configuration")
@@ -118,6 +161,26 @@ class TelegramClient:
if self._session is not None and not self._session.closed: if self._session is not None and not self._session.closed:
await self._session.close() await self._session.close()
def set_notification(self, key: str, value: bool) -> bool:
"""Toggle a notification type by user-facing key at runtime.
Args:
key: User-facing key (e.g. "scenario", "market", "all")
value: True to enable, False to disable
Returns:
True if key was valid, False if unknown.
"""
if key == "all":
for k in NotificationFilter.KEYS:
self._filter.set_flag(k, value)
return True
return self._filter.set_flag(key, value)
def filter_status(self) -> dict[str, bool]:
"""Return current per-type filter state keyed by user-facing names."""
return self._filter.as_dict()
async def send_message(self, text: str, parse_mode: str = "HTML") -> bool: async def send_message(self, text: str, parse_mode: str = "HTML") -> bool:
""" """
Send a generic text message to Telegram. Send a generic text message to Telegram.
@@ -193,6 +256,8 @@ class TelegramClient:
price: Execution price price: Execution price
confidence: AI confidence level (0-100) confidence: AI confidence level (0-100)
""" """
if not self._filter.trades:
return
emoji = "🟢" if action == "BUY" else "🔴" emoji = "🟢" if action == "BUY" else "🔴"
message = ( message = (
f"<b>{emoji} {action}</b>\n" f"<b>{emoji} {action}</b>\n"
@@ -212,6 +277,8 @@ class TelegramClient:
Args: Args:
market_name: Name of the market (e.g., "Korea", "United States") market_name: Name of the market (e.g., "Korea", "United States")
""" """
if not self._filter.market_open_close:
return
message = f"<b>Market Open</b>\n{market_name} trading session started" message = f"<b>Market Open</b>\n{market_name} trading session started"
await self._send_notification( await self._send_notification(
NotificationMessage(priority=NotificationPriority.LOW, message=message) NotificationMessage(priority=NotificationPriority.LOW, message=message)
@@ -225,6 +292,8 @@ class TelegramClient:
market_name: Name of the market market_name: Name of the market
pnl_pct: Final P&L percentage for the session pnl_pct: Final P&L percentage for the session
""" """
if not self._filter.market_open_close:
return
pnl_sign = "+" if pnl_pct >= 0 else "" pnl_sign = "+" if pnl_pct >= 0 else ""
pnl_emoji = "📈" if pnl_pct >= 0 else "📉" pnl_emoji = "📈" if pnl_pct >= 0 else "📉"
message = ( message = (
@@ -271,6 +340,8 @@ class TelegramClient:
total_cash: Total available cash total_cash: Total available cash
max_pct: Maximum allowed percentage max_pct: Maximum allowed percentage
""" """
if not self._filter.fat_finger:
return
attempted_pct = (order_amount / total_cash) * 100 if total_cash > 0 else 0 attempted_pct = (order_amount / total_cash) * 100 if total_cash > 0 else 0
message = ( message = (
f"<b>Fat-Finger Protection</b>\n" f"<b>Fat-Finger Protection</b>\n"
@@ -293,6 +364,8 @@ class TelegramClient:
mode: Trading mode ("paper" or "live") mode: Trading mode ("paper" or "live")
enabled_markets: List of enabled market codes enabled_markets: List of enabled market codes
""" """
if not self._filter.system_events:
return
mode_emoji = "📝" if mode == "paper" else "💰" mode_emoji = "📝" if mode == "paper" else "💰"
markets_str = ", ".join(enabled_markets) markets_str = ", ".join(enabled_markets)
message = ( message = (
@@ -320,6 +393,8 @@ class TelegramClient:
scenario_count: Total number of scenarios scenario_count: Total number of scenarios
token_count: Gemini token usage for the playbook token_count: Gemini token usage for the playbook
""" """
if not self._filter.playbook:
return
message = ( message = (
f"<b>Playbook Generated</b>\n" f"<b>Playbook Generated</b>\n"
f"Market: {market}\n" f"Market: {market}\n"
@@ -347,6 +422,8 @@ class TelegramClient:
condition_summary: Short summary of the matched condition condition_summary: Short summary of the matched condition
confidence: Scenario confidence (0-100) confidence: Scenario confidence (0-100)
""" """
if not self._filter.scenario_match:
return
message = ( message = (
f"<b>Scenario Matched</b>\n" f"<b>Scenario Matched</b>\n"
f"Symbol: <code>{stock_code}</code>\n" f"Symbol: <code>{stock_code}</code>\n"
@@ -366,6 +443,8 @@ class TelegramClient:
market: Market code (e.g., "KR", "US") market: Market code (e.g., "KR", "US")
reason: Failure reason summary reason: Failure reason summary
""" """
if not self._filter.playbook:
return
message = ( message = (
f"<b>Playbook Failed</b>\n" f"<b>Playbook Failed</b>\n"
f"Market: {market}\n" f"Market: {market}\n"
@@ -382,6 +461,8 @@ class TelegramClient:
Args: Args:
reason: Reason for shutdown (e.g., "Normal shutdown", "Circuit breaker") reason: Reason for shutdown (e.g., "Normal shutdown", "Circuit breaker")
""" """
if not self._filter.system_events:
return
message = f"<b>System Shutdown</b>\n{reason}" message = f"<b>System Shutdown</b>\n{reason}"
priority = ( priority = (
NotificationPriority.CRITICAL NotificationPriority.CRITICAL
@@ -403,6 +484,8 @@ class TelegramClient:
error_msg: Error message error_msg: Error message
context: Error context (e.g., stock code, market) context: Error context (e.g., stock code, market)
""" """
if not self._filter.errors:
return
message = ( message = (
f"<b>Error: {error_type}</b>\n" f"<b>Error: {error_type}</b>\n"
f"Context: {context}\n" f"Context: {context}\n"
@@ -429,6 +512,7 @@ class TelegramCommandHandler:
self._client = client self._client = client
self._polling_interval = polling_interval self._polling_interval = polling_interval
self._commands: dict[str, Callable[[], Awaitable[None]]] = {} self._commands: dict[str, Callable[[], Awaitable[None]]] = {}
self._commands_with_args: dict[str, Callable[[list[str]], Awaitable[None]]] = {}
self._last_update_id = 0 self._last_update_id = 0
self._polling_task: asyncio.Task[None] | None = None self._polling_task: asyncio.Task[None] | None = None
self._running = False self._running = False
@@ -437,7 +521,7 @@ class TelegramCommandHandler:
self, command: str, handler: Callable[[], Awaitable[None]] self, command: str, handler: Callable[[], Awaitable[None]]
) -> None: ) -> None:
""" """
Register a command handler. Register a command handler (no arguments).
Args: Args:
command: Command name (without leading slash, e.g., "start") command: Command name (without leading slash, e.g., "start")
@@ -446,6 +530,19 @@ class TelegramCommandHandler:
self._commands[command] = handler self._commands[command] = handler
logger.debug("Registered command handler: /%s", command) logger.debug("Registered command handler: /%s", command)
def register_command_with_args(
self, command: str, handler: Callable[[list[str]], Awaitable[None]]
) -> None:
"""
Register a command handler that receives trailing arguments.
Args:
command: Command name (without leading slash, e.g., "notify")
handler: Async function receiving list of argument tokens
"""
self._commands_with_args[command] = handler
logger.debug("Registered command handler (with args): /%s", command)
async def start_polling(self) -> None: async def start_polling(self) -> None:
"""Start long polling for commands.""" """Start long polling for commands."""
if self._running: if self._running:
@@ -507,9 +604,19 @@ class TelegramCommandHandler:
async with session.post(url, json=payload) as resp: async with session.post(url, json=payload) as resp:
if resp.status != 200: if resp.status != 200:
error_text = await resp.text() error_text = await resp.text()
logger.error( if resp.status == 409:
"getUpdates API error (status=%d): %s", resp.status, error_text # Another bot instance is already polling — stop this poller entirely.
) # Retrying would keep conflicting with the other instance.
self._running = False
logger.warning(
"Telegram conflict (409): another instance is already polling. "
"Disabling Telegram commands for this process. "
"Ensure only one instance of The Ouroboros is running at a time.",
)
else:
logger.error(
"getUpdates API error (status=%d): %s", resp.status, error_text
)
return [] return []
data = await resp.json() data = await resp.json()
@@ -566,11 +673,14 @@ class TelegramCommandHandler:
# Remove @botname suffix if present (for group chats) # Remove @botname suffix if present (for group chats)
command_name = command_parts[0].split("@")[0] command_name = command_parts[0].split("@")[0]
# Execute handler # Execute handler (args-aware handlers take priority)
handler = self._commands.get(command_name) args_handler = self._commands_with_args.get(command_name)
if handler: if args_handler:
logger.info("Executing command: /%s %s", command_name, command_parts[1:])
await args_handler(command_parts[1:])
elif command_name in self._commands:
logger.info("Executing command: /%s", command_name) logger.info("Executing command: /%s", command_name)
await handler() await self._commands[command_name]()
else: else:
logger.debug("Unknown command: /%s", command_name) logger.debug("Unknown command: /%s", command_name)
await self._client.send_message( await self._client.send_message(

View File

@@ -0,0 +1,114 @@
"""Auto-generated strategy: v20260220_210124
Generated at: 2026-02-20T21:01:24.706847+00:00
Rationale: Auto-evolved from 6 failures. Primary failure markets: ['US_AMEX', 'US_NYSE', 'US_NASDAQ']. Average loss: -194.69
"""
from __future__ import annotations
from typing import Any
from src.strategies.base import BaseStrategy
class Strategy_v20260220_210124(BaseStrategy):
"""Strategy: v20260220_210124"""
def evaluate(self, market_data: dict[str, Any]) -> dict[str, Any]:
import datetime
# --- Strategy Constants ---
# Minimum price for a stock to be considered for trading (avoids penny stocks)
MIN_PRICE = 5.0
# Momentum signal thresholds (stricter than previous failures)
MOMENTUM_PRICE_CHANGE_THRESHOLD = 7.0 # % price change
MOMENTUM_VOLUME_RATIO_THRESHOLD = 4.0 # X times average volume
# Oversold signal thresholds (more conservative)
OVERSOLD_RSI_THRESHOLD = 25.0 # RSI value (lower means more oversold)
# Confidence levels
CONFIDENCE_HOLD = 30
CONFIDENCE_BUY_OVERSOLD = 65
CONFIDENCE_BUY_MOMENTUM = 85
CONFIDENCE_BUY_STRONG_MOMENTUM = 90 # For higher-priced stocks with strong momentum
# Market hours in UTC (9:30 AM ET to 4:00 PM ET)
MARKET_OPEN_UTC = datetime.time(14, 30)
MARKET_CLOSE_UTC = datetime.time(21, 0)
# Volatile periods within market hours (UTC) to avoid
# First hour after open (14:30 UTC - 15:30 UTC)
VOLATILE_OPEN_END_UTC = datetime.time(15, 30)
# Last 30 minutes before close (20:30 UTC - 21:00 UTC)
VOLATILE_CLOSE_START_UTC = datetime.time(20, 30)
current_price = market_data.get('current_price')
price_change_pct = market_data.get('price_change_pct')
volume_ratio = market_data.get('volume_ratio') # Assumed pre-computed indicator
rsi = market_data.get('rsi') # Assumed pre-computed indicator
timestamp_str = market_data.get('timestamp')
action = "HOLD"
confidence = CONFIDENCE_HOLD
rationale = "Initial HOLD: No clear signal or conditions not met."
# --- 1. Basic Data Validation ---
if current_price is None or price_change_pct is None:
return {"action": "HOLD", "confidence": CONFIDENCE_HOLD,
"rationale": "Insufficient core data (price or price change) to evaluate."}
# --- 2. Price Filter: Avoid low-priced/penny stocks ---
if current_price < MIN_PRICE:
return {"action": "HOLD", "confidence": CONFIDENCE_HOLD,
"rationale": f"Avoiding low-priced stock (${current_price:.2f} < ${MIN_PRICE:.2f})."}
# --- 3. Time Filter: Only trade during core market hours ---
if timestamp_str:
try:
dt_object = datetime.datetime.fromisoformat(timestamp_str)
current_time_utc = dt_object.time()
if not (MARKET_OPEN_UTC <= current_time_utc < MARKET_CLOSE_UTC):
return {"action": "HOLD", "confidence": CONFIDENCE_HOLD,
"rationale": f"Avoiding trade outside core market hours ({current_time_utc} UTC)."}
if (MARKET_OPEN_UTC <= current_time_utc < VOLATILE_OPEN_END_UTC) or \
(VOLATILE_CLOSE_START_UTC <= current_time_utc < MARKET_CLOSE_UTC):
return {"action": "HOLD", "confidence": CONFIDENCE_HOLD,
"rationale": f"Avoiding trade during volatile market open/close periods ({current_time_utc} UTC)."}
except ValueError:
rationale += " (Warning: Malformed timestamp, time filters skipped)"
# --- Initialize signal states ---
has_momentum_buy_signal = False
has_oversold_buy_signal = False
# --- 4. Evaluate Enhanced Buy Signals ---
# Momentum Buy Signal
if volume_ratio is not None and \
price_change_pct > MOMENTUM_PRICE_CHANGE_THRESHOLD and \
volume_ratio > MOMENTUM_VOLUME_RATIO_THRESHOLD:
has_momentum_buy_signal = True
rationale = f"Momentum BUY: Price change {price_change_pct:.2f}%, Volume {volume_ratio:.2f}x."
confidence = CONFIDENCE_BUY_MOMENTUM
if current_price >= 10.0:
confidence = CONFIDENCE_BUY_STRONG_MOMENTUM
# Oversold Buy Signal
if rsi is not None and rsi < OVERSOLD_RSI_THRESHOLD:
has_oversold_buy_signal = True
if not has_momentum_buy_signal:
rationale = f"Oversold BUY: RSI {rsi:.2f}."
confidence = CONFIDENCE_BUY_OVERSOLD
if current_price >= 10.0:
confidence = min(CONFIDENCE_BUY_OVERSOLD + 5, 80)
# --- 5. Decision Logic ---
if has_momentum_buy_signal:
action = "BUY"
elif has_oversold_buy_signal:
action = "BUY"
return {"action": action, "confidence": confidence, "rationale": rationale}

View File

@@ -0,0 +1,97 @@
"""Auto-generated strategy: v20260220_210159
Generated at: 2026-02-20T21:01:59.391523+00:00
Rationale: Auto-evolved from 6 failures. Primary failure markets: ['US_AMEX', 'US_NYSE', 'US_NASDAQ']. Average loss: -194.69
"""
from __future__ import annotations
from typing import Any
from src.strategies.base import BaseStrategy
class Strategy_v20260220_210159(BaseStrategy):
"""Strategy: v20260220_210159"""
def evaluate(self, market_data: dict[str, Any]) -> dict[str, Any]:
import datetime
current_price = market_data.get('current_price')
price_change_pct = market_data.get('price_change_pct')
volume_ratio = market_data.get('volume_ratio')
rsi = market_data.get('rsi')
timestamp_str = market_data.get('timestamp')
market_name = market_data.get('market')
# Default action
action = "HOLD"
confidence = 0
rationale = "No strong signal or conditions not met."
# --- FAILURE PATTERN AVOIDANCE ---
# 1. Avoid low-priced/penny stocks
MIN_PRICE_THRESHOLD = 5.0 # USD
if current_price is not None and current_price < MIN_PRICE_THRESHOLD:
rationale = (
f"HOLD: Stock price (${current_price:.2f}) is below minimum threshold "
f"(${MIN_PRICE_THRESHOLD:.2f}). Past failures consistently involved low-priced stocks."
)
return {"action": action, "confidence": confidence, "rationale": rationale}
# 2. Avoid early market hour volatility
if timestamp_str:
try:
dt_obj = datetime.datetime.fromisoformat(timestamp_str)
utc_hour = dt_obj.hour
utc_minute = dt_obj.minute
if (utc_hour == 14 and utc_minute < 45) or (utc_hour == 13 and utc_minute >= 30):
rationale = (
f"HOLD: Trading during early market hours (UTC {utc_hour}:{utc_minute}), "
f"a period identified with past failures due to high volatility."
)
return {"action": action, "confidence": confidence, "rationale": rationale}
except ValueError:
pass
# --- IMPROVED BUY STRATEGY ---
# Momentum BUY signal
if volume_ratio is not None and price_change_pct is not None:
if price_change_pct > 7.0 and volume_ratio > 3.0:
action = "BUY"
confidence = 70
rationale = "Improved BUY: Momentum signal with high volume and above price threshold."
if market_name == 'US_AMEX':
confidence = max(55, confidence - 5)
rationale += " (Adjusted lower for AMEX market's higher risk profile)."
elif market_name == 'US_NASDAQ' and price_change_pct > 20:
confidence = max(50, confidence - 10)
rationale += " (Adjusted lower for aggressive NASDAQ momentum volatility)."
if price_change_pct > 15.0:
confidence = max(50, confidence - 5)
rationale += " (Caution: Very high daily price change, potential for reversal)."
return {"action": action, "confidence": confidence, "rationale": rationale}
# Oversold BUY signal
if rsi is not None and price_change_pct is not None:
if rsi < 30 and price_change_pct < -3.0:
action = "BUY"
confidence = 65
rationale = "Improved BUY: Oversold signal with recent decline and above price threshold."
if market_name == 'US_AMEX':
confidence = max(50, confidence - 5)
rationale += " (Adjusted lower for AMEX market's higher risk on oversold assets)."
if price_change_pct < -10.0:
confidence = max(45, confidence - 10)
rationale += " (Caution: Very steep decline, potential falling knife)."
return {"action": action, "confidence": confidence, "rationale": rationale}
# If no specific BUY signal, default to HOLD
return {"action": action, "confidence": confidence, "rationale": rationale}

View File

@@ -0,0 +1,88 @@
"""Auto-generated strategy: v20260220_210244
Generated at: 2026-02-20T21:02:44.387355+00:00
Rationale: Auto-evolved from 6 failures. Primary failure markets: ['US_AMEX', 'US_NYSE', 'US_NASDAQ']. Average loss: -194.69
"""
from __future__ import annotations
from typing import Any
from src.strategies.base import BaseStrategy
class Strategy_v20260220_210244(BaseStrategy):
"""Strategy: v20260220_210244"""
def evaluate(self, market_data: dict[str, Any]) -> dict[str, Any]:
from datetime import datetime
# Extract required data points safely
current_price = market_data.get("current_price")
price_change_pct = market_data.get("price_change_pct")
volume_ratio = market_data.get("volume_ratio")
rsi = market_data.get("rsi")
timestamp_str = market_data.get("timestamp")
market_name = market_data.get("market")
stock_code = market_data.get("stock_code", "UNKNOWN")
# Default action is HOLD with conservative confidence and rationale
action = "HOLD"
confidence = 50
rationale = f"No strong BUY signal for {stock_code} or awaiting more favorable conditions after avoiding known failure patterns."
# --- 1. Failure Pattern Avoidance Filters ---
# A. Avoid low-priced (penny) stocks
if current_price is not None and current_price < 5.0:
return {
"action": "HOLD",
"confidence": 50,
"rationale": f"AVOID {stock_code}: Stock price (${current_price:.2f}) is below minimum threshold ($5.00) for BUY action. Identified past failures on highly volatile, low-priced stocks."
}
# B. Avoid initiating BUY trades during identified high-volatility hours
if timestamp_str:
try:
trade_hour = datetime.fromisoformat(timestamp_str).hour
if trade_hour in [14, 20]:
return {
"action": "HOLD",
"confidence": 50,
"rationale": f"AVOID {stock_code}: Trading during historically volatile hour ({trade_hour} UTC) where previous BUYs resulted in losses. Prefer to observe market stability."
}
except ValueError:
pass
# C. Be cautious with extreme momentum spikes
if volume_ratio is not None and price_change_pct is not None:
if volume_ratio >= 9.0 and price_change_pct >= 15.0:
return {
"action": "HOLD",
"confidence": 50,
"rationale": f"AVOID {stock_code}: Extreme short-term momentum detected (price change: +{price_change_pct:.2f}%, volume ratio: {volume_ratio:.1f}x). Historical failures indicate buying into such rapid spikes often leads to reversals."
}
# D. Be cautious with "oversold" signals without further confirmation
if rsi is not None and rsi < 30:
return {
"action": "HOLD",
"confidence": 50,
"rationale": f"AVOID {stock_code}: Oversold signal (RSI={rsi:.1f}) detected. While often a BUY signal, historical failures on similar 'oversold' trades suggest waiting for stronger confirmation."
}
# --- 2. Improved BUY Signal Generation ---
if volume_ratio is not None and 2.0 <= volume_ratio < 9.0 and \
price_change_pct is not None and 2.0 <= price_change_pct < 15.0:
action = "BUY"
confidence = 70
rationale = f"BUY {stock_code}: Moderate momentum detected (price change: +{price_change_pct:.2f}%, volume ratio: {volume_ratio:.1f}x). Passed filters for price and extreme momentum, avoiding past failure patterns."
if market_name in ["US_AMEX", "US_NASDAQ"]:
confidence = max(60, confidence - 5)
rationale += f" Adjusted confidence for {market_name} market characteristics."
elif market_name == "US_NYSE":
confidence = max(65, confidence)
confidence = max(50, min(85, confidence))
return {"action": action, "confidence": confidence, "rationale": rationale}

View File

@@ -46,6 +46,18 @@ class StockCondition(BaseModel):
The ScenarioEngine evaluates all non-None fields as AND conditions. The ScenarioEngine evaluates all non-None fields as AND conditions.
A condition matches only if ALL specified fields are satisfied. A condition matches only if ALL specified fields are satisfied.
Technical indicator fields:
rsi_below / rsi_above — RSI threshold
volume_ratio_above / volume_ratio_below — volume vs previous day
price_above / price_below — absolute price level
price_change_pct_above / price_change_pct_below — intraday % change
Position-aware fields (require market_data enrichment from open position):
unrealized_pnl_pct_above — matches if unrealized P&L > threshold (e.g. 3.0 → +3%)
unrealized_pnl_pct_below — matches if unrealized P&L < threshold (e.g. -2.0 → -2%)
holding_days_above — matches if position held for more than N days
holding_days_below — matches if position held for fewer than N days
""" """
rsi_below: float | None = None rsi_below: float | None = None
@@ -56,6 +68,10 @@ class StockCondition(BaseModel):
price_below: float | None = None price_below: float | None = None
price_change_pct_above: float | None = None price_change_pct_above: float | None = None
price_change_pct_below: float | None = None price_change_pct_below: float | None = None
unrealized_pnl_pct_above: float | None = None
unrealized_pnl_pct_below: float | None = None
holding_days_above: int | None = None
holding_days_below: int | None = None
def has_any_condition(self) -> bool: def has_any_condition(self) -> bool:
"""Check if at least one condition field is set.""" """Check if at least one condition field is set."""
@@ -70,6 +86,10 @@ class StockCondition(BaseModel):
self.price_below, self.price_below,
self.price_change_pct_above, self.price_change_pct_above,
self.price_change_pct_below, self.price_change_pct_below,
self.unrealized_pnl_pct_above,
self.unrealized_pnl_pct_below,
self.holding_days_above,
self.holding_days_below,
) )
) )

View File

@@ -1,7 +1,8 @@
"""Pre-market planner — generates DayPlaybook via Gemini before market open. """Pre-market planner — generates DayPlaybook via Gemini before market open.
One Gemini API call per market per day. Candidates come from SmartVolatilityScanner. One Gemini API call per market per day. Candidates come from SmartVolatilityScanner.
On failure, returns a defensive playbook (all HOLD, no trades). On failure, returns a smart rule-based fallback playbook that uses scanner signals
(momentum/oversold) to generate BUY conditions, avoiding the all-HOLD problem.
""" """
from __future__ import annotations from __future__ import annotations
@@ -74,6 +75,7 @@ class PreMarketPlanner:
market: str, market: str,
candidates: list[ScanCandidate], candidates: list[ScanCandidate],
today: date | None = None, today: date | None = None,
current_holdings: list[dict] | None = None,
) -> DayPlaybook: ) -> DayPlaybook:
"""Generate a DayPlaybook for a market using Gemini. """Generate a DayPlaybook for a market using Gemini.
@@ -81,6 +83,10 @@ class PreMarketPlanner:
market: Market code ("KR" or "US") market: Market code ("KR" or "US")
candidates: Stock candidates from SmartVolatilityScanner candidates: Stock candidates from SmartVolatilityScanner
today: Override date (defaults to date.today()). Use market-local date. today: Override date (defaults to date.today()). Use market-local date.
current_holdings: Currently held positions with entry_price and unrealized_pnl_pct.
Each dict: {"stock_code": str, "name": str, "qty": int,
"entry_price": float, "unrealized_pnl_pct": float,
"holding_days": int}
Returns: Returns:
DayPlaybook with scenarios. Empty/defensive if no candidates or failure. DayPlaybook with scenarios. Empty/defensive if no candidates or failure.
@@ -105,6 +111,7 @@ class PreMarketPlanner:
context_data, context_data,
self_market_scorecard, self_market_scorecard,
cross_market, cross_market,
current_holdings=current_holdings,
) )
# 3. Call Gemini # 3. Call Gemini
@@ -117,7 +124,8 @@ class PreMarketPlanner:
# 4. Parse response # 4. Parse response
playbook = self._parse_response( playbook = self._parse_response(
decision.rationale, today, market, candidates, cross_market decision.rationale, today, market, candidates, cross_market,
current_holdings=current_holdings,
) )
playbook_with_tokens = playbook.model_copy( playbook_with_tokens = playbook.model_copy(
update={"token_count": decision.token_count} update={"token_count": decision.token_count}
@@ -134,7 +142,7 @@ class PreMarketPlanner:
except Exception: except Exception:
logger.exception("Playbook generation failed for %s", market) logger.exception("Playbook generation failed for %s", market)
if self._settings.DEFENSIVE_PLAYBOOK_ON_FAILURE: if self._settings.DEFENSIVE_PLAYBOOK_ON_FAILURE:
return self._defensive_playbook(today, market, candidates) return self._smart_fallback_playbook(today, market, candidates, self._settings)
return self._empty_playbook(today, market) return self._empty_playbook(today, market)
def build_cross_market_context( def build_cross_market_context(
@@ -229,6 +237,7 @@ class PreMarketPlanner:
context_data: dict[str, Any], context_data: dict[str, Any],
self_market_scorecard: dict[str, Any] | None, self_market_scorecard: dict[str, Any] | None,
cross_market: CrossMarketContext | None, cross_market: CrossMarketContext | None,
current_holdings: list[dict] | None = None,
) -> str: ) -> str:
"""Build a structured prompt for Gemini to generate scenario JSON.""" """Build a structured prompt for Gemini to generate scenario JSON."""
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
@@ -240,6 +249,26 @@ class PreMarketPlanner:
for c in candidates for c in candidates
) )
holdings_text = ""
if current_holdings:
lines = []
for h in current_holdings:
code = h.get("stock_code", "")
name = h.get("name", "")
qty = h.get("qty", 0)
entry_price = h.get("entry_price", 0.0)
pnl_pct = h.get("unrealized_pnl_pct", 0.0)
holding_days = h.get("holding_days", 0)
lines.append(
f" - {code} ({name}): {qty}주 @ {entry_price:,.0f}, "
f"미실현손익 {pnl_pct:+.2f}%, 보유 {holding_days}"
)
holdings_text = (
"\n## Current Holdings (보유 중 — SELL/HOLD 전략 고려 필요)\n"
+ "\n".join(lines)
+ "\n"
)
cross_market_text = "" cross_market_text = ""
if cross_market: if cross_market:
cross_market_text = ( cross_market_text = (
@@ -272,10 +301,20 @@ class PreMarketPlanner:
for key, value in list(layer_data.items())[:5]: for key, value in list(layer_data.items())[:5]:
context_text += f" - {key}: {value}\n" context_text += f" - {key}: {value}\n"
holdings_instruction = ""
if current_holdings:
holding_codes = [h.get("stock_code", "") for h in current_holdings]
holdings_instruction = (
f"- Also include SELL/HOLD scenarios for held stocks: "
f"{', '.join(holding_codes)} "
f"(even if not in candidates list)\n"
)
return ( return (
f"You are a pre-market trading strategist for the {market} market.\n" f"You are a pre-market trading strategist for the {market} market.\n"
f"Generate structured trading scenarios for today.\n\n" f"Generate structured trading scenarios for today.\n\n"
f"## Candidates (from volatility scanner)\n{candidates_text}\n" f"## Candidates (from volatility scanner)\n{candidates_text}\n"
f"{holdings_text}"
f"{self_market_text}" f"{self_market_text}"
f"{cross_market_text}" f"{cross_market_text}"
f"{context_text}\n" f"{context_text}\n"
@@ -293,7 +332,8 @@ class PreMarketPlanner:
f' "stock_code": "...",\n' f' "stock_code": "...",\n'
f' "scenarios": [\n' f' "scenarios": [\n'
f' {{\n' f' {{\n'
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0}},\n' f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0,'
f' "unrealized_pnl_pct_above": 3.0, "holding_days_above": 5}},\n'
f' "action": "BUY|SELL|HOLD",\n' f' "action": "BUY|SELL|HOLD",\n'
f' "confidence": 85,\n' f' "confidence": 85,\n'
f' "allocation_pct": 10.0,\n' f' "allocation_pct": 10.0,\n'
@@ -307,7 +347,8 @@ class PreMarketPlanner:
f'}}\n\n' f'}}\n\n'
f"Rules:\n" f"Rules:\n"
f"- Max {max_scenarios} scenarios per stock\n" f"- Max {max_scenarios} scenarios per stock\n"
f"- Only use stocks from the candidates list\n" f"- Candidates list is the primary source for BUY candidates\n"
f"{holdings_instruction}"
f"- Confidence 0-100 (80+ for actionable trades)\n" f"- Confidence 0-100 (80+ for actionable trades)\n"
f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\n" f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\n"
f"- Return ONLY the JSON, no markdown fences or explanation\n" f"- Return ONLY the JSON, no markdown fences or explanation\n"
@@ -320,12 +361,19 @@ class PreMarketPlanner:
market: str, market: str,
candidates: list[ScanCandidate], candidates: list[ScanCandidate],
cross_market: CrossMarketContext | None, cross_market: CrossMarketContext | None,
current_holdings: list[dict] | None = None,
) -> DayPlaybook: ) -> DayPlaybook:
"""Parse Gemini's JSON response into a validated DayPlaybook.""" """Parse Gemini's JSON response into a validated DayPlaybook."""
cleaned = self._extract_json(response_text) cleaned = self._extract_json(response_text)
data = json.loads(cleaned) data = json.loads(cleaned)
valid_codes = {c.stock_code for c in candidates} valid_codes = {c.stock_code for c in candidates}
# Holdings are also valid — AI may generate SELL/HOLD scenarios for them
if current_holdings:
for h in current_holdings:
code = h.get("stock_code", "")
if code:
valid_codes.add(code)
# Parse market outlook # Parse market outlook
outlook_str = data.get("market_outlook", "neutral") outlook_str = data.get("market_outlook", "neutral")
@@ -389,6 +437,10 @@ class PreMarketPlanner:
price_below=cond_data.get("price_below"), price_below=cond_data.get("price_below"),
price_change_pct_above=cond_data.get("price_change_pct_above"), price_change_pct_above=cond_data.get("price_change_pct_above"),
price_change_pct_below=cond_data.get("price_change_pct_below"), price_change_pct_below=cond_data.get("price_change_pct_below"),
unrealized_pnl_pct_above=cond_data.get("unrealized_pnl_pct_above"),
unrealized_pnl_pct_below=cond_data.get("unrealized_pnl_pct_below"),
holding_days_above=cond_data.get("holding_days_above"),
holding_days_below=cond_data.get("holding_days_below"),
) )
if not condition.has_any_condition(): if not condition.has_any_condition():
@@ -470,3 +522,99 @@ class PreMarketPlanner:
), ),
], ],
) )
@staticmethod
def _smart_fallback_playbook(
today: date,
market: str,
candidates: list[ScanCandidate],
settings: Settings,
) -> DayPlaybook:
"""Rule-based fallback playbook when Gemini is unavailable.
Uses scanner signals (RSI, volume_ratio) to generate meaningful BUY
conditions instead of the all-SELL defensive playbook. Candidates are
already pre-qualified by SmartVolatilityScanner, so we trust their
signals and build actionable scenarios from them.
Scenario logic per candidate:
- momentum signal: BUY when volume_ratio exceeds scanner threshold
- oversold signal: BUY when RSI is below oversold threshold
- always: SELL stop-loss at -3.0% as guard
"""
stock_playbooks = []
for c in candidates:
scenarios: list[StockScenario] = []
if c.signal == "momentum":
scenarios.append(
StockScenario(
condition=StockCondition(
volume_ratio_above=settings.VOL_MULTIPLIER,
),
action=ScenarioAction.BUY,
confidence=80,
allocation_pct=10.0,
stop_loss_pct=-3.0,
take_profit_pct=5.0,
rationale=(
f"Rule-based BUY: momentum signal, "
f"volume={c.volume_ratio:.1f}x (fallback planner)"
),
)
)
elif c.signal == "oversold":
scenarios.append(
StockScenario(
condition=StockCondition(
rsi_below=settings.RSI_OVERSOLD_THRESHOLD,
),
action=ScenarioAction.BUY,
confidence=80,
allocation_pct=10.0,
stop_loss_pct=-3.0,
take_profit_pct=5.0,
rationale=(
f"Rule-based BUY: oversold signal, "
f"RSI={c.rsi:.0f} (fallback planner)"
),
)
)
# Always add stop-loss guard
scenarios.append(
StockScenario(
condition=StockCondition(price_change_pct_below=-3.0),
action=ScenarioAction.SELL,
confidence=90,
stop_loss_pct=-3.0,
rationale="Rule-based stop-loss (fallback planner)",
)
)
stock_playbooks.append(
StockPlaybook(
stock_code=c.stock_code,
scenarios=scenarios,
)
)
logger.info(
"Smart fallback playbook for %s: %d stocks with rule-based BUY/SELL conditions",
market,
len(stock_playbooks),
)
return DayPlaybook(
date=today,
market=market,
market_outlook=MarketOutlook.NEUTRAL,
default_action=ScenarioAction.HOLD,
stock_playbooks=stock_playbooks,
global_rules=[
GlobalRule(
condition="portfolio_pnl_pct < -2.0",
action=ScenarioAction.REDUCE_ALL,
rationale="Defensive: reduce on loss threshold",
),
],
)

View File

@@ -206,6 +206,37 @@ class ScenarioEngine:
if condition.price_change_pct_below is not None: if condition.price_change_pct_below is not None:
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below) checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
# Position-aware conditions
unrealized_pnl_pct = self._safe_float(market_data.get("unrealized_pnl_pct"))
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
if "unrealized_pnl_pct" not in market_data:
self._warn_missing_key("unrealized_pnl_pct")
if condition.unrealized_pnl_pct_above is not None:
checks.append(
unrealized_pnl_pct is not None
and unrealized_pnl_pct > condition.unrealized_pnl_pct_above
)
if condition.unrealized_pnl_pct_below is not None:
checks.append(
unrealized_pnl_pct is not None
and unrealized_pnl_pct < condition.unrealized_pnl_pct_below
)
holding_days = self._safe_float(market_data.get("holding_days"))
if condition.holding_days_above is not None or condition.holding_days_below is not None:
if "holding_days" not in market_data:
self._warn_missing_key("holding_days")
if condition.holding_days_above is not None:
checks.append(
holding_days is not None
and holding_days > condition.holding_days_above
)
if condition.holding_days_below is not None:
checks.append(
holding_days is not None
and holding_days < condition.holding_days_below
)
return len(checks) > 0 and all(checks) return len(checks) > 0 and all(checks)
def _evaluate_global_condition( def _evaluate_global_condition(
@@ -266,5 +297,9 @@ class ScenarioEngine:
details["current_price"] = self._safe_float(market_data.get("current_price")) details["current_price"] = self._safe_float(market_data.get("current_price"))
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None: if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct")) details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
details["unrealized_pnl_pct"] = self._safe_float(market_data.get("unrealized_pnl_pct"))
if condition.holding_days_above is not None or condition.holding_days_below is not None:
details["holding_days"] = self._safe_float(market_data.get("holding_days"))
return details return details

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

@@ -2,6 +2,10 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.brain.gemini_client import GeminiClient from src.brain.gemini_client import GeminiClient
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -270,3 +274,97 @@ class TestBatchDecisionParsing:
assert decisions["AAPL"].action == "HOLD" assert decisions["AAPL"].action == "HOLD"
assert decisions["AAPL"].confidence == 0 assert decisions["AAPL"].confidence == 0
# ---------------------------------------------------------------------------
# Prompt Override (used by pre_market_planner)
# ---------------------------------------------------------------------------
class TestPromptOverride:
"""decide() must use prompt_override when present in market_data."""
@pytest.mark.asyncio
async def test_prompt_override_is_sent_to_gemini(self, settings):
"""When prompt_override is in market_data, it should be used as the prompt."""
client = GeminiClient(settings)
custom_prompt = "You are a playbook generator. Return JSON with scenarios."
mock_response = MagicMock()
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "test"}'
with patch.object(
client._client.aio.models,
"generate_content",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_generate:
market_data = {
"stock_code": "PLANNER",
"current_price": 0,
"prompt_override": custom_prompt,
}
await client.decide(market_data)
# Verify the custom prompt was sent, not a built prompt
mock_generate.assert_called_once()
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
)
assert actual_prompt == custom_prompt
@pytest.mark.asyncio
async def test_prompt_override_skips_optimization(self, settings):
"""prompt_override should bypass prompt optimization."""
client = GeminiClient(settings)
client._enable_optimization = True
custom_prompt = "Custom playbook prompt"
mock_response = MagicMock()
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "ok"}'
with patch.object(
client._client.aio.models,
"generate_content",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_generate:
market_data = {
"stock_code": "PLANNER",
"current_price": 0,
"prompt_override": custom_prompt,
}
await client.decide(market_data)
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
)
assert actual_prompt == custom_prompt
@pytest.mark.asyncio
async def test_without_prompt_override_uses_build_prompt(self, settings):
"""Without prompt_override, decide() should use build_prompt as before."""
client = GeminiClient(settings)
mock_response = MagicMock()
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "ok"}'
with patch.object(
client._client.aio.models,
"generate_content",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_generate:
market_data = {
"stock_code": "005930",
"current_price": 72000,
}
await client.decide(market_data)
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
)
# Should contain stock code from build_prompt, not be a custom override
assert "005930" in actual_prompt

View File

@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -90,12 +90,12 @@ class TestTokenManagement:
await broker.close() await broker.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_token_refresh_cooldown_prevents_rapid_retries(self, settings): async def test_token_refresh_cooldown_waits_then_retries(self, settings):
"""Token refresh should enforce cooldown after failure (issue #54).""" """Token refresh should wait out cooldown then retry (issue #54)."""
broker = KISBroker(settings) broker = KISBroker(settings)
broker._refresh_cooldown = 2.0 # Short cooldown for testing broker._refresh_cooldown = 0.1 # Short cooldown for testing
# First refresh attempt fails with 403 (EGW00133) # All attempts fail with 403 (EGW00133)
mock_resp_403 = AsyncMock() mock_resp_403 = AsyncMock()
mock_resp_403.status = 403 mock_resp_403.status = 403
mock_resp_403.text = AsyncMock( mock_resp_403.text = AsyncMock(
@@ -109,8 +109,8 @@ class TestTokenManagement:
with pytest.raises(ConnectionError, match="Token refresh failed"): with pytest.raises(ConnectionError, match="Token refresh failed"):
await broker._ensure_token() await broker._ensure_token()
# Second attempt within cooldown should fail with cooldown error # Second attempt within cooldown should wait then retry (and still get 403)
with pytest.raises(ConnectionError, match="Token refresh on cooldown"): with pytest.raises(ConnectionError, match="Token refresh failed"):
await broker._ensure_token() await broker._ensure_token()
await broker.close() await broker.close()
@@ -296,3 +296,432 @@ class TestHashKey:
mock_acquire.assert_called_once() mock_acquire.assert_called_once()
await broker.close() await broker.close()
# ---------------------------------------------------------------------------
# fetch_market_rankings — TR_ID, path, params (issue #155)
# ---------------------------------------------------------------------------
def _make_ranking_mock(items: list[dict]) -> AsyncMock:
"""Build a mock HTTP response returning ranking items."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": items})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
return mock_resp
class TestFetchMarketRankings:
"""Verify correct TR_ID, API path, and params per ranking_type (issue #155)."""
@pytest.fixture
def broker(self, settings) -> KISBroker:
b = KISBroker(settings)
b._access_token = "tok"
b._token_expires_at = float("inf")
b._rate_limiter.acquire = AsyncMock()
return b
@pytest.mark.asyncio
async def test_volume_uses_correct_tr_id_and_path(self, broker: KISBroker) -> None:
mock_resp = _make_ranking_mock([])
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
await broker.fetch_market_rankings(ranking_type="volume")
call_kwargs = mock_get.call_args
url = call_kwargs[0][0] if call_kwargs[0] else call_kwargs[1].get("url", "")
headers = call_kwargs[1].get("headers", {})
params = call_kwargs[1].get("params", {})
assert "volume-rank" in url
assert headers.get("tr_id") == "FHPST01710000"
assert params.get("FID_COND_SCR_DIV_CODE") == "20171"
assert params.get("FID_TRGT_EXLS_CLS_CODE") == "0000000000"
@pytest.mark.asyncio
async def test_fluctuation_uses_correct_tr_id_and_path(self, broker: KISBroker) -> None:
mock_resp = _make_ranking_mock([])
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
await broker.fetch_market_rankings(ranking_type="fluctuation")
call_kwargs = mock_get.call_args
url = call_kwargs[0][0] if call_kwargs[0] else call_kwargs[1].get("url", "")
headers = call_kwargs[1].get("headers", {})
params = call_kwargs[1].get("params", {})
assert "ranking/fluctuation" in url
assert headers.get("tr_id") == "FHPST01700000"
assert params.get("fid_cond_scr_div_code") == "20170"
@pytest.mark.asyncio
async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None:
items = [
{
"mksc_shrn_iscd": "005930",
"hts_kor_isnm": "삼성전자",
"stck_prpr": "75000",
"acml_vol": "10000000",
"prdy_ctrt": "2.5",
"vol_inrt": "150",
}
]
mock_resp = _make_ranking_mock(items)
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
result = await broker.fetch_market_rankings(ranking_type="volume")
assert len(result) == 1
assert result[0]["stock_code"] == "005930"
assert result[0]["price"] == 75000.0
assert result[0]["change_rate"] == 2.5
# ---------------------------------------------------------------------------
# KRX tick unit / round-down helpers (issue #157)
# ---------------------------------------------------------------------------
from src.broker.kis_api import kr_tick_unit, kr_round_down # noqa: E402
class TestKrTickUnit:
"""kr_tick_unit and kr_round_down must implement KRX price tick rules."""
@pytest.mark.parametrize(
"price, expected_tick",
[
(1999, 1),
(2000, 5),
(4999, 5),
(5000, 10),
(19999, 10),
(20000, 50),
(49999, 50),
(50000, 100),
(199999, 100),
(200000, 500),
(499999, 500),
(500000, 1000),
(1000000, 1000),
],
)
def test_tick_unit_boundaries(self, price: int, expected_tick: int) -> None:
assert kr_tick_unit(price) == expected_tick
@pytest.mark.parametrize(
"price, expected_rounded",
[
(188150, 188100), # 100원 단위, 50원 잔여 → 내림
(188100, 188100), # 이미 정렬됨
(75050, 75000), # 100원 단위, 50원 잔여 → 내림
(49950, 49950), # 50원 단위 정렬됨
(49960, 49950), # 50원 단위, 10원 잔여 → 내림
(1999, 1999), # 1원 단위 → 그대로
(5003, 5000), # 10원 단위, 3원 잔여 → 내림
],
)
def test_round_down_to_tick(self, price: int, expected_rounded: int) -> None:
assert kr_round_down(price) == expected_rounded
# ---------------------------------------------------------------------------
# get_current_price (issue #157)
# ---------------------------------------------------------------------------
class TestGetCurrentPrice:
"""get_current_price must use inquire-price API and return (price, change, foreigner)."""
@pytest.fixture
def broker(self, settings) -> KISBroker:
b = KISBroker(settings)
b._access_token = "tok"
b._token_expires_at = float("inf")
b._rate_limiter.acquire = AsyncMock()
return b
@pytest.mark.asyncio
async def test_returns_correct_fields(self, broker: KISBroker) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={
"rt_cd": "0",
"output": {
"stck_prpr": "188600",
"prdy_ctrt": "3.97",
"frgn_ntby_qty": "12345",
},
}
)
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:
price, change_pct, foreigner = await broker.get_current_price("005930")
assert price == 188600.0
assert change_pct == 3.97
assert foreigner == 12345.0
call_kwargs = mock_get.call_args
url = call_kwargs[0][0] if call_kwargs[0] else call_kwargs[1].get("url", "")
headers = call_kwargs[1].get("headers", {})
assert "inquire-price" in url
assert headers.get("tr_id") == "FHKST01010100"
@pytest.mark.asyncio
async def test_http_error_raises_connection_error(self, broker: KISBroker) -> None:
mock_resp = AsyncMock()
mock_resp.status = 500
mock_resp.text = AsyncMock(return_value="Internal Server Error")
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
with pytest.raises(ConnectionError, match="get_current_price failed"):
await broker.get_current_price("005930")
# ---------------------------------------------------------------------------
# send_order tick rounding and ORD_DVSN (issue #157)
# ---------------------------------------------------------------------------
class TestSendOrderTickRounding:
"""send_order must apply KRX tick rounding and correct ORD_DVSN codes."""
@pytest.fixture
def broker(self, settings) -> KISBroker:
b = KISBroker(settings)
b._access_token = "tok"
b._token_expires_at = float("inf")
b._rate_limiter.acquire = AsyncMock()
return b
@pytest.mark.asyncio
async def test_limit_order_rounds_down_to_tick(self, broker: KISBroker) -> None:
"""Price 188150 (not on 100-won tick) must be rounded to 188100."""
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, price=188150)
order_call = mock_post.call_args_list[1]
body = order_call[1].get("json", {})
assert body["ORD_UNPR"] == "188100" # rounded down
assert body["ORD_DVSN"] == "00" # 지정가
@pytest.mark.asyncio
async def test_limit_order_ord_dvsn_is_00(self, broker: KISBroker) -> None:
"""send_order with price>0 must use ORD_DVSN='00' (지정가)."""
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, price=50000)
order_call = mock_post.call_args_list[1]
body = order_call[1].get("json", {})
assert body["ORD_DVSN"] == "00"
@pytest.mark.asyncio
async def test_market_order_ord_dvsn_is_01(self, broker: KISBroker) -> None:
"""send_order with price=0 must use ORD_DVSN='01' (시장가)."""
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, price=0)
order_call = mock_post.call_args_list[1]
body = order_call[1].get("json", {})
assert body["ORD_DVSN"] == "01"
# ---------------------------------------------------------------------------
# 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

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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,815 @@
"""Tests for OverseasBroker — rankings, price, balance, order, and helpers."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import aiohttp
import pytest
from src.broker.kis_api import KISBroker
from src.broker.overseas import OverseasBroker, _PRICE_EXCHANGE_MAP, _RANKING_EXCHANGE_MAP
from src.config import Settings
def _make_async_cm(mock_resp: AsyncMock) -> MagicMock:
"""Create an async context manager that returns mock_resp on __aenter__."""
cm = MagicMock()
cm.__aenter__ = AsyncMock(return_value=mock_resp)
cm.__aexit__ = AsyncMock(return_value=False)
return cm
@pytest.fixture
def mock_settings() -> Settings:
"""Provide mock settings with correct default TR_IDs/paths."""
return Settings(
KIS_APP_KEY="test_key",
KIS_APP_SECRET="test_secret",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="test_gemini_key",
)
@pytest.fixture
def mock_broker(mock_settings: Settings) -> KISBroker:
"""Provide a mock KIS broker."""
broker = KISBroker(mock_settings)
broker.get_orderbook = AsyncMock() # type: ignore[method-assign]
return broker
@pytest.fixture
def overseas_broker(mock_broker: KISBroker) -> OverseasBroker:
"""Provide an OverseasBroker wrapping a mock KISBroker."""
return OverseasBroker(mock_broker)
def _setup_broker_mocks(overseas_broker: OverseasBroker, mock_session: MagicMock) -> None:
"""Wire up common broker mocks."""
overseas_broker._broker._rate_limiter.acquire = AsyncMock()
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
overseas_broker._broker._auth_headers = AsyncMock(return_value={})
class TestRankingExchangeMap:
"""Test exchange code mapping for ranking API."""
def test_nasd_maps_to_nas(self) -> None:
assert _RANKING_EXCHANGE_MAP["NASD"] == "NAS"
def test_nyse_maps_to_nys(self) -> None:
assert _RANKING_EXCHANGE_MAP["NYSE"] == "NYS"
def test_amex_maps_to_ams(self) -> None:
assert _RANKING_EXCHANGE_MAP["AMEX"] == "AMS"
def test_sehk_maps_to_hks(self) -> None:
assert _RANKING_EXCHANGE_MAP["SEHK"] == "HKS"
def test_unmapped_exchange_passes_through(self) -> None:
assert _RANKING_EXCHANGE_MAP.get("UNKNOWN", "UNKNOWN") == "UNKNOWN"
def test_tse_unchanged(self) -> None:
assert _RANKING_EXCHANGE_MAP["TSE"] == "TSE"
class TestConfigDefaults:
"""Test that config defaults match KIS official API specs."""
def test_fluct_tr_id(self, mock_settings: Settings) -> None:
assert mock_settings.OVERSEAS_RANKING_FLUCT_TR_ID == "HHDFS76290000"
def test_volume_tr_id(self, mock_settings: Settings) -> None:
assert mock_settings.OVERSEAS_RANKING_VOLUME_TR_ID == "HHDFS76270000"
def test_fluct_path(self, mock_settings: Settings) -> None:
assert mock_settings.OVERSEAS_RANKING_FLUCT_PATH == "/uapi/overseas-stock/v1/ranking/updown-rate"
def test_volume_path(self, mock_settings: Settings) -> None:
assert mock_settings.OVERSEAS_RANKING_VOLUME_PATH == "/uapi/overseas-stock/v1/ranking/volume-surge"
class TestFetchOverseasRankings:
"""Test fetch_overseas_rankings method."""
@pytest.mark.asyncio
async def test_fluctuation_uses_correct_params(
self, overseas_broker: OverseasBroker
) -> None:
"""Fluctuation ranking should use HHDFS76290000, updown-rate path, and correct params."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={"output": [{"symb": "AAPL", "name": "Apple"}]}
)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
overseas_broker._broker._auth_headers = AsyncMock(
return_value={"authorization": "Bearer test"}
)
result = await overseas_broker.fetch_overseas_rankings("NASD", "fluctuation")
assert len(result) == 1
assert result[0]["symb"] == "AAPL"
call_args = mock_session.get.call_args
url = call_args[0][0]
params = call_args[1]["params"]
assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url
assert params["EXCD"] == "NAS"
assert params["NDAY"] == "0"
assert params["GUBN"] == "1"
assert params["VOL_RANG"] == "0"
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000")
@pytest.mark.asyncio
async def test_volume_uses_correct_params(
self, overseas_broker: OverseasBroker
) -> None:
"""Volume ranking should use HHDFS76270000, volume-surge path, and correct params."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={"output": [{"symb": "TSLA", "name": "Tesla"}]}
)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
overseas_broker._broker._auth_headers = AsyncMock(
return_value={"authorization": "Bearer test"}
)
result = await overseas_broker.fetch_overseas_rankings("NYSE", "volume")
assert len(result) == 1
call_args = mock_session.get.call_args
url = call_args[0][0]
params = call_args[1]["params"]
assert "/uapi/overseas-stock/v1/ranking/volume-surge" in url
assert params["EXCD"] == "NYS"
assert params["MIXN"] == "0"
assert params["VOL_RANG"] == "0"
assert "NDAY" not in params
assert "GUBN" not in params
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76270000")
@pytest.mark.asyncio
async def test_404_returns_empty_list(
self, overseas_broker: OverseasBroker
) -> None:
"""HTTP 404 should return empty list (fallback) instead of raising."""
mock_resp = AsyncMock()
mock_resp.status = 404
mock_resp.text = AsyncMock(return_value="Not Found")
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
result = await overseas_broker.fetch_overseas_rankings("AMEX", "fluctuation")
assert result == []
@pytest.mark.asyncio
async def test_non_404_error_raises(
self, overseas_broker: OverseasBroker
) -> None:
"""Non-404 HTTP errors should raise ConnectionError."""
mock_resp = AsyncMock()
mock_resp.status = 500
mock_resp.text = AsyncMock(return_value="Internal Server Error")
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
with pytest.raises(ConnectionError, match="500"):
await overseas_broker.fetch_overseas_rankings("NASD")
@pytest.mark.asyncio
async def test_empty_response_returns_empty(
self, overseas_broker: OverseasBroker
) -> None:
"""Empty output in response should return empty list."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": []})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
result = await overseas_broker.fetch_overseas_rankings("NASD")
assert result == []
@pytest.mark.asyncio
async def test_ranking_disabled_returns_empty(
self, overseas_broker: OverseasBroker
) -> None:
"""When OVERSEAS_RANKING_ENABLED=False, should return empty immediately."""
overseas_broker._broker._settings.OVERSEAS_RANKING_ENABLED = False
result = await overseas_broker.fetch_overseas_rankings("NASD")
assert result == []
@pytest.mark.asyncio
async def test_limit_truncates_results(
self, overseas_broker: OverseasBroker
) -> None:
"""Results should be truncated to the specified limit."""
rows = [{"symb": f"SYM{i}"} for i in range(20)]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": rows})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
result = await overseas_broker.fetch_overseas_rankings("NASD", limit=5)
assert len(result) == 5
@pytest.mark.asyncio
async def test_network_error_raises(
self, overseas_broker: OverseasBroker
) -> None:
"""Network errors should raise ConnectionError."""
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"):
await overseas_broker.fetch_overseas_rankings("NASD")
@pytest.mark.asyncio
async def test_exchange_code_mapping_applied(
self, overseas_broker: OverseasBroker
) -> None:
"""All major exchanges should use mapped codes in API params."""
for original, mapped in [("NASD", "NAS"), ("NYSE", "NYS"), ("AMEX", "AMS")]:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": [{"symb": "X"}]})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
await overseas_broker.fetch_overseas_rankings(original)
call_params = mock_session.get.call_args[1]["params"]
assert call_params["EXCD"] == mapped, f"{original} should map to {mapped}"
class TestGetOverseasPrice:
"""Test get_overseas_price method."""
@pytest.mark.asyncio
async def test_success(self, overseas_broker: OverseasBroker) -> None:
"""Successful price fetch returns JSON data."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": {"last": "150.00"}})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
overseas_broker._broker._auth_headers = AsyncMock(return_value={"authorization": "Bearer t"})
result = await overseas_broker.get_overseas_price("NASD", "AAPL")
assert result["output"]["last"] == "150.00"
call_args = mock_session.get.call_args
params = call_args[1]["params"]
assert params["EXCD"] == "NAS" # NASD → NAS via _PRICE_EXCHANGE_MAP
assert params["SYMB"] == "AAPL"
@pytest.mark.asyncio
async def test_http_error_raises(self, overseas_broker: OverseasBroker) -> None:
"""Non-200 response should raise ConnectionError."""
mock_resp = AsyncMock()
mock_resp.status = 400
mock_resp.text = AsyncMock(return_value="Bad Request")
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
with pytest.raises(ConnectionError, match="get_overseas_price failed"):
await overseas_broker.get_overseas_price("NASD", "AAPL")
@pytest.mark.asyncio
async def test_network_error_raises(self, overseas_broker: OverseasBroker) -> None:
"""Network error should raise ConnectionError."""
cm = MagicMock()
cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("conn refused"))
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"):
await overseas_broker.get_overseas_price("NASD", "AAPL")
class TestGetOverseasBalance:
"""Test get_overseas_balance method."""
@pytest.mark.asyncio
async def test_success(self, overseas_broker: OverseasBroker) -> None:
"""Successful balance fetch returns JSON data."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output1": [{"pdno": "AAPL"}]})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
result = await overseas_broker.get_overseas_balance("NASD")
assert result["output1"][0]["pdno"] == "AAPL"
@pytest.mark.asyncio
async def test_http_error_raises(self, overseas_broker: OverseasBroker) -> None:
"""Non-200 should raise ConnectionError."""
mock_resp = AsyncMock()
mock_resp.status = 500
mock_resp.text = AsyncMock(return_value="Server Error")
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
with pytest.raises(ConnectionError, match="get_overseas_balance failed"):
await overseas_broker.get_overseas_balance("NASD")
@pytest.mark.asyncio
async def test_network_error_raises(self, overseas_broker: OverseasBroker) -> None:
"""Network error should raise ConnectionError."""
cm = MagicMock()
cm.__aenter__ = AsyncMock(side_effect=TimeoutError("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"):
await overseas_broker.get_overseas_balance("NYSE")
class TestSendOverseasOrder:
"""Test send_overseas_order method."""
@pytest.mark.asyncio
async def test_buy_market_order(self, overseas_broker: OverseasBroker) -> None:
"""Market buy order should use VTTT1002U and ORD_DVSN=01."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10)
assert result["rt_cd"] == "0"
# Verify BUY TR_ID
overseas_broker._broker._auth_headers.assert_called_with("VTTT1002U")
call_args = mock_session.post.call_args
body = call_args[1]["json"]
assert body["ORD_DVSN"] == "01" # market order
assert body["OVRS_ORD_UNPR"] == "0"
@pytest.mark.asyncio
async def test_sell_limit_order(self, overseas_broker: OverseasBroker) -> None:
"""Limit sell order should use VTTT1001U and ORD_DVSN=00."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
result = await overseas_broker.send_overseas_order("NYSE", "MSFT", "SELL", 5, price=350.0)
assert result["rt_cd"] == "0"
overseas_broker._broker._auth_headers.assert_called_with("VTTT1001U")
call_args = mock_session.post.call_args
body = call_args[1]["json"]
assert body["ORD_DVSN"] == "00" # limit order
assert body["OVRS_ORD_UNPR"] == "350.0"
@pytest.mark.asyncio
async def test_order_http_error_raises(self, overseas_broker: OverseasBroker) -> None:
"""Non-200 should raise ConnectionError."""
mock_resp = AsyncMock()
mock_resp.status = 400
mock_resp.text = AsyncMock(return_value="Bad Request")
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
with pytest.raises(ConnectionError, match="send_overseas_order failed"):
await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 1)
@pytest.mark.asyncio
async def test_order_network_error_raises(self, overseas_broker: OverseasBroker) -> None:
"""Network error should raise ConnectionError."""
cm = MagicMock()
cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("conn reset"))
cm.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=cm)
_setup_broker_mocks(overseas_broker, mock_session)
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
with pytest.raises(ConnectionError, match="Network error"):
await overseas_broker.send_overseas_order("NASD", "TSLA", "SELL", 2)
class TestGetCurrencyCode:
"""Test _get_currency_code mapping."""
def test_us_exchanges(self, overseas_broker: OverseasBroker) -> None:
assert overseas_broker._get_currency_code("NASD") == "USD"
assert overseas_broker._get_currency_code("NYSE") == "USD"
assert overseas_broker._get_currency_code("AMEX") == "USD"
def test_japan(self, overseas_broker: OverseasBroker) -> None:
assert overseas_broker._get_currency_code("TSE") == "JPY"
def test_hong_kong(self, overseas_broker: OverseasBroker) -> None:
assert overseas_broker._get_currency_code("SEHK") == "HKD"
def test_china(self, overseas_broker: OverseasBroker) -> None:
assert overseas_broker._get_currency_code("SHAA") == "CNY"
assert overseas_broker._get_currency_code("SZAA") == "CNY"
def test_vietnam(self, overseas_broker: OverseasBroker) -> None:
assert overseas_broker._get_currency_code("HNX") == "VND"
assert overseas_broker._get_currency_code("HSX") == "VND"
def test_unknown_defaults_usd(self, overseas_broker: OverseasBroker) -> None:
assert overseas_broker._get_currency_code("UNKNOWN") == "USD"
class TestExtractRankingRows:
"""Test _extract_ranking_rows helper."""
def test_output_key(self, overseas_broker: OverseasBroker) -> None:
data = {"output": [{"a": 1}, {"b": 2}]}
assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}]
def test_output1_key(self, overseas_broker: OverseasBroker) -> None:
data = {"output1": [{"c": 3}]}
assert overseas_broker._extract_ranking_rows(data) == [{"c": 3}]
def test_output2_key(self, overseas_broker: OverseasBroker) -> None:
data = {"output2": [{"d": 4}]}
assert overseas_broker._extract_ranking_rows(data) == [{"d": 4}]
def test_no_list_returns_empty(self, overseas_broker: OverseasBroker) -> None:
data = {"output": "not a list"}
assert overseas_broker._extract_ranking_rows(data) == []
def test_empty_data(self, overseas_broker: OverseasBroker) -> None:
assert overseas_broker._extract_ranking_rows({}) == []
def test_filters_non_dict_rows(self, overseas_broker: OverseasBroker) -> None:
data = {"output": [{"a": 1}, "invalid", {"b": 2}]}
assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}]
class TestPriceExchangeMap:
"""Test _PRICE_EXCHANGE_MAP is applied in get_overseas_price (issue #151)."""
def test_price_map_equals_ranking_map(self) -> None:
assert _PRICE_EXCHANGE_MAP is _RANKING_EXCHANGE_MAP
@pytest.mark.parametrize("original,expected", [
("NASD", "NAS"),
("NYSE", "NYS"),
("AMEX", "AMS"),
])
def test_us_exchange_code_mapping(self, original: str, expected: str) -> None:
assert _PRICE_EXCHANGE_MAP[original] == expected
@pytest.mark.asyncio
async def test_get_overseas_price_sends_mapped_code(
self, overseas_broker: OverseasBroker
) -> None:
"""NASD → NAS must be sent to HHDFS00000300."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": {"last": "200.00"}})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
await overseas_broker.get_overseas_price("NASD", "AAPL")
params = mock_session.get.call_args[1]["params"]
assert params["EXCD"] == "NAS"
class TestOrderRtCdCheck:
"""Test that send_overseas_order checks rt_cd and logs accordingly (issue #151)."""
@pytest.fixture
def overseas_broker(self, mock_settings: Settings) -> OverseasBroker:
broker = MagicMock(spec=KISBroker)
broker._settings = mock_settings
broker._account_no = "12345678"
broker._product_cd = "01"
broker._base_url = "https://openapivts.koreainvestment.com:9443"
broker._rate_limiter = AsyncMock()
broker._rate_limiter.acquire = AsyncMock()
broker._auth_headers = AsyncMock(return_value={"authorization": "Bearer t"})
broker._get_hash_key = AsyncMock(return_value="hashval")
return OverseasBroker(broker)
@pytest.mark.asyncio
async def test_success_rt_cd_returns_data(
self, overseas_broker: OverseasBroker
) -> None:
"""rt_cd='0' → order accepted, data returned."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "완료"})
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10, price=150.0)
assert result["rt_cd"] == "0"
@pytest.mark.asyncio
async def test_error_rt_cd_returns_data_with_msg(
self, overseas_broker: OverseasBroker
) -> None:
"""rt_cd != '0' → order rejected, data still returned (caller checks rt_cd)."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={"rt_cd": "1", "msg1": "주문가능금액이 부족합니다."}
)
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10, price=150.0)
assert result["rt_cd"] == "1"
assert "부족" in result["msg1"]
class TestPaperOverseasCash:
"""Test PAPER_OVERSEAS_CASH config setting (issue #151)."""
def test_default_value(self) -> None:
settings = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
)
assert settings.PAPER_OVERSEAS_CASH == 50000.0
def test_env_override(self) -> None:
import os
os.environ["PAPER_OVERSEAS_CASH"] = "25000"
settings = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
)
assert settings.PAPER_OVERSEAS_CASH == 25000.0
del os.environ["PAPER_OVERSEAS_CASH"]
def test_zero_disables_fallback(self) -> None:
import os
os.environ["PAPER_OVERSEAS_CASH"] = "0"
settings = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
)
assert settings.PAPER_OVERSEAS_CASH == 0.0
# ---------------------------------------------------------------------------
# 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

@@ -164,18 +164,23 @@ class TestGeneratePlaybook:
assert pb.market_outlook == MarketOutlook.NEUTRAL assert pb.market_outlook == MarketOutlook.NEUTRAL
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_gemini_failure_returns_defensive(self) -> None: async def test_gemini_failure_returns_smart_fallback(self) -> None:
planner = _make_planner() planner = _make_planner()
planner._gemini.decide = AsyncMock(side_effect=RuntimeError("API timeout")) planner._gemini.decide = AsyncMock(side_effect=RuntimeError("API timeout"))
# oversold candidate (signal="oversold", rsi=28.5)
candidates = [_candidate()] candidates = [_candidate()]
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8)) pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
assert pb.default_action == ScenarioAction.HOLD assert pb.default_action == ScenarioAction.HOLD
assert pb.market_outlook == MarketOutlook.NEUTRAL_TO_BEARISH # Smart fallback uses NEUTRAL outlook (not NEUTRAL_TO_BEARISH)
assert pb.market_outlook == MarketOutlook.NEUTRAL
assert pb.stock_count == 1 assert pb.stock_count == 1
# Defensive playbook has stop-loss scenarios # Oversold candidate → first scenario is BUY, second is SELL stop-loss
assert pb.stock_playbooks[0].scenarios[0].action == ScenarioAction.SELL scenarios = pb.stock_playbooks[0].scenarios
assert scenarios[0].action == ScenarioAction.BUY
assert scenarios[0].condition.rsi_below == 30
assert scenarios[1].action == ScenarioAction.SELL
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_gemini_failure_empty_when_defensive_disabled(self) -> None: async def test_gemini_failure_empty_when_defensive_disabled(self) -> None:
@@ -657,3 +662,339 @@ class TestDefensivePlaybook:
assert pb.stock_count == 0 assert pb.stock_count == 0
assert pb.market == "US" assert pb.market == "US"
assert pb.market_outlook == MarketOutlook.NEUTRAL assert pb.market_outlook == MarketOutlook.NEUTRAL
# ---------------------------------------------------------------------------
# Smart fallback playbook
# ---------------------------------------------------------------------------
class TestSmartFallbackPlaybook:
"""Tests for _smart_fallback_playbook — rule-based BUY/SELL on Gemini failure."""
def _make_settings(self) -> Settings:
return Settings(
KIS_APP_KEY="test",
KIS_APP_SECRET="test",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="test",
RSI_OVERSOLD_THRESHOLD=30,
VOL_MULTIPLIER=2.0,
)
def test_momentum_candidate_gets_buy_on_volume(self) -> None:
candidates = [
_candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0)
]
settings = self._make_settings()
pb = PreMarketPlanner._smart_fallback_playbook(
date(2026, 2, 17), "US_AMEX", candidates, settings
)
assert pb.stock_count == 1
sp = pb.stock_playbooks[0]
assert sp.stock_code == "CHOW"
# First scenario: BUY with volume_ratio_above
buy_sc = sp.scenarios[0]
assert buy_sc.action == ScenarioAction.BUY
assert buy_sc.condition.volume_ratio_above == 2.0
assert buy_sc.condition.rsi_below is None
assert buy_sc.confidence == 80
# Second scenario: stop-loss SELL
sell_sc = sp.scenarios[1]
assert sell_sc.action == ScenarioAction.SELL
assert sell_sc.condition.price_change_pct_below == -3.0
def test_oversold_candidate_gets_buy_on_rsi(self) -> None:
candidates = [
_candidate(code="005930", signal="oversold", rsi=22.0, volume_ratio=3.5)
]
settings = self._make_settings()
pb = PreMarketPlanner._smart_fallback_playbook(
date(2026, 2, 17), "KR", candidates, settings
)
sp = pb.stock_playbooks[0]
buy_sc = sp.scenarios[0]
assert buy_sc.action == ScenarioAction.BUY
assert buy_sc.condition.rsi_below == 30
assert buy_sc.condition.volume_ratio_above is None
def test_all_candidates_have_stop_loss_sell(self) -> None:
candidates = [
_candidate(code="AAA", signal="momentum", volume_ratio=5.0),
_candidate(code="BBB", signal="oversold", rsi=25.0),
]
settings = self._make_settings()
pb = PreMarketPlanner._smart_fallback_playbook(
date(2026, 2, 17), "US_NASDAQ", candidates, settings
)
assert pb.stock_count == 2
for sp in pb.stock_playbooks:
sell_scenarios = [s for s in sp.scenarios if s.action == ScenarioAction.SELL]
assert len(sell_scenarios) == 1
assert sell_scenarios[0].condition.price_change_pct_below == -3.0
assert sell_scenarios[0].condition.price_change_pct_below == -3.0
def test_market_outlook_is_neutral(self) -> None:
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
settings = self._make_settings()
pb = PreMarketPlanner._smart_fallback_playbook(
date(2026, 2, 17), "US_AMEX", candidates, settings
)
assert pb.market_outlook == MarketOutlook.NEUTRAL
def test_default_action_is_hold(self) -> None:
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
settings = self._make_settings()
pb = PreMarketPlanner._smart_fallback_playbook(
date(2026, 2, 17), "US_AMEX", candidates, settings
)
assert pb.default_action == ScenarioAction.HOLD
def test_has_global_reduce_all_rule(self) -> None:
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
settings = self._make_settings()
pb = PreMarketPlanner._smart_fallback_playbook(
date(2026, 2, 17), "US_AMEX", candidates, settings
)
assert len(pb.global_rules) == 1
rule = pb.global_rules[0]
assert rule.action == ScenarioAction.REDUCE_ALL
assert "portfolio_pnl_pct" in rule.condition
def test_empty_candidates_returns_empty_playbook(self) -> None:
settings = self._make_settings()
pb = PreMarketPlanner._smart_fallback_playbook(
date(2026, 2, 17), "US_AMEX", [], settings
)
assert pb.stock_count == 0
def test_vol_multiplier_applied_from_settings(self) -> None:
"""VOL_MULTIPLIER=3.0 should set volume_ratio_above=3.0 for momentum."""
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
settings = self._make_settings()
settings = settings.model_copy(update={"VOL_MULTIPLIER": 3.0})
pb = PreMarketPlanner._smart_fallback_playbook(
date(2026, 2, 17), "US_AMEX", candidates, settings
)
buy_sc = pb.stock_playbooks[0].scenarios[0]
assert buy_sc.condition.volume_ratio_above == 3.0
def test_rsi_oversold_threshold_applied_from_settings(self) -> None:
"""RSI_OVERSOLD_THRESHOLD=25 should set rsi_below=25 for oversold."""
candidates = [_candidate(signal="oversold", rsi=22.0)]
settings = self._make_settings()
settings = settings.model_copy(update={"RSI_OVERSOLD_THRESHOLD": 25})
pb = PreMarketPlanner._smart_fallback_playbook(
date(2026, 2, 17), "KR", candidates, settings
)
buy_sc = pb.stock_playbooks[0].scenarios[0]
assert buy_sc.condition.rsi_below == 25
@pytest.mark.asyncio
async def test_generate_playbook_uses_smart_fallback_on_gemini_error(self) -> None:
"""generate_playbook() should use smart fallback (not defensive) on API failure."""
planner = _make_planner()
planner._gemini.decide = AsyncMock(side_effect=ConnectionError("429 quota exceeded"))
# momentum candidate
candidates = [
_candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0)
]
pb = await planner.generate_playbook(
"US_AMEX", candidates, today=date(2026, 2, 18)
)
# Should NOT be all-SELL defensive; should have BUY for momentum
assert pb.stock_count == 1
buy_scenarios = [
s for s in pb.stock_playbooks[0].scenarios
if s.action == ScenarioAction.BUY
]
assert len(buy_scenarios) == 1
assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default
# ---------------------------------------------------------------------------
# Holdings in prompt (#170)
# ---------------------------------------------------------------------------
class TestHoldingsInPrompt:
"""Tests for current_holdings parameter in generate_playbook / _build_prompt."""
def _make_holdings(self) -> list[dict]:
return [
{
"stock_code": "005930",
"name": "Samsung",
"qty": 10,
"entry_price": 71000.0,
"unrealized_pnl_pct": 2.3,
"holding_days": 3,
}
]
def test_build_prompt_includes_holdings_section(self) -> None:
"""Prompt should contain a Current Holdings section when holdings are given."""
planner = _make_planner()
candidates = [_candidate()]
holdings = self._make_holdings()
prompt = planner._build_prompt(
"KR",
candidates,
context_data={},
self_market_scorecard=None,
cross_market=None,
current_holdings=holdings,
)
assert "## Current Holdings" in prompt
assert "005930" in prompt
assert "+2.30%" in prompt
assert "보유 3일" in prompt
def test_build_prompt_no_holdings_omits_section(self) -> None:
"""Prompt should NOT contain a Current Holdings section when holdings=None."""
planner = _make_planner()
candidates = [_candidate()]
prompt = planner._build_prompt(
"KR",
candidates,
context_data={},
self_market_scorecard=None,
cross_market=None,
current_holdings=None,
)
assert "## Current Holdings" not in prompt
def test_build_prompt_empty_holdings_omits_section(self) -> None:
"""Empty list should also omit the holdings section."""
planner = _make_planner()
candidates = [_candidate()]
prompt = planner._build_prompt(
"KR",
candidates,
context_data={},
self_market_scorecard=None,
cross_market=None,
current_holdings=[],
)
assert "## Current Holdings" not in prompt
def test_build_prompt_holdings_instruction_included(self) -> None:
"""Prompt should include instruction to generate scenarios for held stocks."""
planner = _make_planner()
candidates = [_candidate()]
holdings = self._make_holdings()
prompt = planner._build_prompt(
"KR",
candidates,
context_data={},
self_market_scorecard=None,
cross_market=None,
current_holdings=holdings,
)
assert "005930" in prompt
assert "SELL/HOLD" in prompt
@pytest.mark.asyncio
async def test_generate_playbook_passes_holdings_to_prompt(self) -> None:
"""generate_playbook should pass current_holdings through to the prompt."""
planner = _make_planner()
candidates = [_candidate()]
holdings = self._make_holdings()
# Capture the actual prompt sent to Gemini
captured_prompts: list[str] = []
original_decide = planner._gemini.decide
async def capture_and_call(data: dict) -> TradeDecision:
captured_prompts.append(data.get("prompt_override", ""))
return await original_decide(data)
planner._gemini.decide = capture_and_call # type: ignore[method-assign]
await planner.generate_playbook(
"KR", candidates, today=date(2026, 2, 8), current_holdings=holdings
)
assert len(captured_prompts) == 1
assert "## Current Holdings" in captured_prompts[0]
assert "005930" in captured_prompts[0]
@pytest.mark.asyncio
async def test_holdings_stock_allowed_in_parse_response(self) -> None:
"""Holdings stocks not in candidates list should be accepted in the response."""
holding_code = "000660" # Not in candidates
stocks = [
{
"stock_code": "005930", # candidate
"scenarios": [
{
"condition": {"rsi_below": 30},
"action": "BUY",
"confidence": 85,
"rationale": "oversold",
}
],
},
{
"stock_code": holding_code, # holding only
"scenarios": [
{
"condition": {"price_change_pct_below": -2.0},
"action": "SELL",
"confidence": 90,
"rationale": "stop-loss",
}
],
},
]
planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks))
candidates = [_candidate()] # only 005930
holdings = [
{
"stock_code": holding_code,
"name": "SK Hynix",
"qty": 5,
"entry_price": 180000.0,
"unrealized_pnl_pct": -1.5,
"holding_days": 7,
}
]
pb = await planner.generate_playbook(
"KR",
candidates,
today=date(2026, 2, 8),
current_holdings=holdings,
)
codes = [sp.stock_code for sp in pb.stock_playbooks]
assert "005930" in codes
assert holding_code in codes

View File

@@ -440,3 +440,135 @@ class TestEvaluate:
assert result.action == ScenarioAction.BUY assert result.action == ScenarioAction.BUY
assert result.match_details["rsi"] == 25.0 assert result.match_details["rsi"] == 25.0
assert isinstance(result.match_details["rsi"], float) assert isinstance(result.match_details["rsi"], float)
# ---------------------------------------------------------------------------
# Position-aware condition tests (#171)
# ---------------------------------------------------------------------------
class TestPositionAwareConditions:
"""Tests for unrealized_pnl_pct and holding_days condition fields."""
def test_evaluate_condition_unrealized_pnl_above_matches(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_above should match when P&L exceeds threshold."""
condition = StockCondition(unrealized_pnl_pct_above=3.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 5.0}) is True
def test_evaluate_condition_unrealized_pnl_above_no_match(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_above should NOT match when P&L is below threshold."""
condition = StockCondition(unrealized_pnl_pct_above=3.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 2.0}) is False
def test_evaluate_condition_unrealized_pnl_below_matches(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_below should match when P&L is under threshold."""
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -3.5}) is True
def test_evaluate_condition_unrealized_pnl_below_no_match(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_below should NOT match when P&L is above threshold."""
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -1.0}) is False
def test_evaluate_condition_holding_days_above_matches(
self, engine: ScenarioEngine
) -> None:
"""holding_days_above should match when position held longer than threshold."""
condition = StockCondition(holding_days_above=5)
assert engine.evaluate_condition(condition, {"holding_days": 7}) is True
def test_evaluate_condition_holding_days_above_no_match(
self, engine: ScenarioEngine
) -> None:
"""holding_days_above should NOT match when position held shorter."""
condition = StockCondition(holding_days_above=5)
assert engine.evaluate_condition(condition, {"holding_days": 3}) is False
def test_evaluate_condition_holding_days_below_matches(
self, engine: ScenarioEngine
) -> None:
"""holding_days_below should match when position held fewer days."""
condition = StockCondition(holding_days_below=3)
assert engine.evaluate_condition(condition, {"holding_days": 1}) is True
def test_evaluate_condition_holding_days_below_no_match(
self, engine: ScenarioEngine
) -> None:
"""holding_days_below should NOT match when held more days."""
condition = StockCondition(holding_days_below=3)
assert engine.evaluate_condition(condition, {"holding_days": 5}) is False
def test_combined_pnl_and_holding_days(self, engine: ScenarioEngine) -> None:
"""Combined position-aware conditions should AND-evaluate correctly."""
condition = StockCondition(
unrealized_pnl_pct_above=3.0,
holding_days_above=5,
)
# Both met → match
assert engine.evaluate_condition(
condition,
{"unrealized_pnl_pct": 4.5, "holding_days": 7},
) is True
# Only pnl met → no match
assert engine.evaluate_condition(
condition,
{"unrealized_pnl_pct": 4.5, "holding_days": 3},
) is False
def test_missing_unrealized_pnl_does_not_match(
self, engine: ScenarioEngine
) -> None:
"""Missing unrealized_pnl_pct key should not match the condition."""
condition = StockCondition(unrealized_pnl_pct_above=3.0)
assert engine.evaluate_condition(condition, {}) is False
def test_missing_holding_days_does_not_match(
self, engine: ScenarioEngine
) -> None:
"""Missing holding_days key should not match the condition."""
condition = StockCondition(holding_days_above=5)
assert engine.evaluate_condition(condition, {}) is False
def test_match_details_includes_position_fields(
self, engine: ScenarioEngine
) -> None:
"""match_details should include position fields when condition specifies them."""
pb = _playbook(
scenarios=[
StockScenario(
condition=StockCondition(unrealized_pnl_pct_above=3.0),
action=ScenarioAction.SELL,
confidence=90,
rationale="Take profit",
)
]
)
result = engine.evaluate(
pb,
"005930",
{"unrealized_pnl_pct": 5.0},
{},
)
assert result.action == ScenarioAction.SELL
assert "unrealized_pnl_pct" in result.match_details
assert result.match_details["unrealized_pnl_pct"] == 5.0
def test_position_conditions_parse_from_planner(self) -> None:
"""StockCondition should accept and store new fields from JSON parsing."""
condition = StockCondition(
unrealized_pnl_pct_above=3.0,
unrealized_pnl_pct_below=None,
holding_days_above=5,
holding_days_below=None,
)
assert condition.unrealized_pnl_pct_above == 3.0
assert condition.holding_days_above == 5
assert condition.has_any_condition() is True

View File

@@ -350,6 +350,42 @@ class TestSmartVolatilityScanner:
assert [c.stock_code for c in candidates] == ["ABCD"] assert [c.stock_code for c in candidates] == ["ABCD"]
class TestImpliedRSIFormula:
"""Test the implied_rsi formula in SmartVolatilityScanner (issue #181)."""
def test_neutral_change_gives_neutral_rsi(self) -> None:
"""0% change → implied_rsi = 50 (neutral)."""
# formula: 50 + (change_rate * 2.0)
rsi = max(0.0, min(100.0, 50.0 + (0.0 * 2.0)))
assert rsi == 50.0
def test_10pct_change_gives_rsi_70(self) -> None:
"""10% upward change → implied_rsi = 70 (momentum signal)."""
rsi = max(0.0, min(100.0, 50.0 + (10.0 * 2.0)))
assert rsi == 70.0
def test_minus_10pct_gives_rsi_30(self) -> None:
"""-10% change → implied_rsi = 30 (oversold signal)."""
rsi = max(0.0, min(100.0, 50.0 + (-10.0 * 2.0)))
assert rsi == 30.0
def test_saturation_at_25pct(self) -> None:
"""Saturation occurs at >=25% change (not 12.5% as with old coefficient 4.0)."""
rsi_12pct = max(0.0, min(100.0, 50.0 + (12.5 * 2.0)))
rsi_25pct = max(0.0, min(100.0, 50.0 + (25.0 * 2.0)))
rsi_30pct = max(0.0, min(100.0, 50.0 + (30.0 * 2.0)))
# At 12.5% change: RSI = 75 (not 100, unlike old formula)
assert rsi_12pct == 75.0
# At 25%+ saturation
assert rsi_25pct == 100.0
assert rsi_30pct == 100.0 # Capped
def test_negative_saturation(self) -> None:
"""Saturation at -25% gives RSI = 0."""
rsi = max(0.0, min(100.0, 50.0 + (-25.0 * 2.0)))
assert rsi == 0.0
class TestRSICalculation: class TestRSICalculation:
"""Test RSI calculation in VolatilityAnalyzer.""" """Test RSI calculation in VolatilityAnalyzer."""

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

View File

@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
import aiohttp import aiohttp
import pytest import pytest
from src.notifications.telegram_client import NotificationPriority, TelegramClient from src.notifications.telegram_client import NotificationFilter, NotificationPriority, TelegramClient
class TestTelegramClientInit: class TestTelegramClientInit:
@@ -481,3 +481,187 @@ class TestClientCleanup:
# Should not raise exception # Should not raise exception
await client.close() await client.close()
class TestNotificationFilter:
"""Test granular notification filter behavior."""
def test_default_filter_allows_all(self) -> None:
"""Default NotificationFilter has all flags enabled."""
f = NotificationFilter()
assert f.trades is True
assert f.market_open_close is True
assert f.fat_finger is True
assert f.system_events is True
assert f.playbook is True
assert f.scenario_match is True
assert f.errors is True
def test_client_uses_default_filter_when_none_given(self) -> None:
"""TelegramClient creates a default NotificationFilter when none provided."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
assert isinstance(client._filter, NotificationFilter)
assert client._filter.scenario_match is True
def test_client_stores_provided_filter(self) -> None:
"""TelegramClient stores a custom NotificationFilter."""
nf = NotificationFilter(scenario_match=False, trades=False)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
)
assert client._filter.scenario_match is False
assert client._filter.trades is False
assert client._filter.market_open_close is True # default still True
@pytest.mark.asyncio
async def test_scenario_match_filtered_does_not_send(self) -> None:
"""notify_scenario_matched skips send when scenario_match=False."""
nf = NotificationFilter(scenario_match=False)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
)
with patch("aiohttp.ClientSession.post") as mock_post:
await client.notify_scenario_matched(
stock_code="005930", action="BUY", condition_summary="rsi<30", confidence=85.0
)
mock_post.assert_not_called()
@pytest.mark.asyncio
async def test_trades_filtered_does_not_send(self) -> None:
"""notify_trade_execution skips send when trades=False."""
nf = NotificationFilter(trades=False)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
)
with patch("aiohttp.ClientSession.post") as mock_post:
await client.notify_trade_execution(
stock_code="005930", market="KR", action="BUY",
quantity=10, price=70000.0, confidence=85.0
)
mock_post.assert_not_called()
@pytest.mark.asyncio
async def test_market_open_close_filtered_does_not_send(self) -> None:
"""notify_market_open/close skip send when market_open_close=False."""
nf = NotificationFilter(market_open_close=False)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
)
with patch("aiohttp.ClientSession.post") as mock_post:
await client.notify_market_open("Korea")
await client.notify_market_close("Korea", pnl_pct=1.5)
mock_post.assert_not_called()
@pytest.mark.asyncio
async def test_circuit_breaker_always_sends_regardless_of_filter(self) -> None:
"""notify_circuit_breaker always sends (no filter flag)."""
nf = NotificationFilter(
trades=False, market_open_close=False, fat_finger=False,
system_events=False, playbook=False, scenario_match=False, errors=False,
)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await client.notify_circuit_breaker(pnl_pct=-3.5, threshold=-3.0)
assert mock_post.call_count == 1
@pytest.mark.asyncio
async def test_errors_filtered_does_not_send(self) -> None:
"""notify_error skips send when errors=False."""
nf = NotificationFilter(errors=False)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
)
with patch("aiohttp.ClientSession.post") as mock_post:
await client.notify_error("TestError", "something went wrong", "KR")
mock_post.assert_not_called()
@pytest.mark.asyncio
async def test_playbook_filtered_does_not_send(self) -> None:
"""notify_playbook_generated/failed skip send when playbook=False."""
nf = NotificationFilter(playbook=False)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
)
with patch("aiohttp.ClientSession.post") as mock_post:
await client.notify_playbook_generated("KR", 3, 10, 1200)
await client.notify_playbook_failed("KR", "timeout")
mock_post.assert_not_called()
@pytest.mark.asyncio
async def test_system_events_filtered_does_not_send(self) -> None:
"""notify_system_start/shutdown skip send when system_events=False."""
nf = NotificationFilter(system_events=False)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
)
with patch("aiohttp.ClientSession.post") as mock_post:
await client.notify_system_start("paper", ["KR"])
await client.notify_system_shutdown("Normal shutdown")
mock_post.assert_not_called()
def test_set_flag_valid_key(self) -> None:
"""set_flag returns True and updates field for a known key."""
nf = NotificationFilter()
assert nf.set_flag("scenario", False) is True
assert nf.scenario_match is False
def test_set_flag_invalid_key(self) -> None:
"""set_flag returns False for an unknown key."""
nf = NotificationFilter()
assert nf.set_flag("unknown_key", False) is False
def test_as_dict_keys_match_KEYS(self) -> None:
"""as_dict() returns every key defined in KEYS."""
nf = NotificationFilter()
d = nf.as_dict()
assert set(d.keys()) == set(NotificationFilter.KEYS.keys())
def test_set_notification_valid_key(self) -> None:
"""TelegramClient.set_notification toggles filter at runtime."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
assert client._filter.scenario_match is True
assert client.set_notification("scenario", False) is True
assert client._filter.scenario_match is False
def test_set_notification_all_off(self) -> None:
"""set_notification('all', False) disables every filter flag."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
assert client.set_notification("all", False) is True
for v in client.filter_status().values():
assert v is False
def test_set_notification_all_on(self) -> None:
"""set_notification('all', True) enables every filter flag."""
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True,
notification_filter=NotificationFilter(
trades=False, market_open_close=False, scenario_match=False,
fat_finger=False, system_events=False, playbook=False, errors=False,
),
)
assert client.set_notification("all", True) is True
for v in client.filter_status().values():
assert v is True
def test_set_notification_unknown_key(self) -> None:
"""set_notification returns False for an unknown key."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
assert client.set_notification("unknown", False) is False
def test_filter_status_reflects_current_state(self) -> None:
"""filter_status() matches the current NotificationFilter state."""
nf = NotificationFilter(trades=False, scenario_match=False)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
)
status = client.filter_status()
assert status["trades"] is False
assert status["scenario"] is False
assert status["market"] is True

View File

@@ -875,3 +875,139 @@ class TestGetUpdates:
updates = await handler._get_updates() updates = await handler._get_updates()
assert updates == [] assert updates == []
@pytest.mark.asyncio
async def test_get_updates_409_stops_polling(self) -> None:
"""409 Conflict response stops the poller (_running = False) and returns empty list."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
handler._running = True # simulate active poller
mock_resp = AsyncMock()
mock_resp.status = 409
mock_resp.text = AsyncMock(
return_value='{"ok":false,"error_code":409,"description":"Conflict"}'
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
updates = await handler._get_updates()
assert updates == []
assert handler._running is False # poller stopped
@pytest.mark.asyncio
async def test_poll_loop_exits_after_409(self) -> None:
"""_poll_loop exits naturally after _running is set to False by a 409 response."""
import asyncio as _asyncio
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
call_count = 0
async def mock_get_updates_409() -> list[dict]:
nonlocal call_count
call_count += 1
# Simulate 409 stopping the poller
handler._running = False
return []
handler._get_updates = mock_get_updates_409 # type: ignore[method-assign]
handler._running = True
task = _asyncio.create_task(handler._poll_loop())
await _asyncio.wait_for(task, timeout=2.0)
# _get_updates called exactly once, then loop exited
assert call_count == 1
assert handler._running is False
class TestCommandWithArgs:
"""Test register_command_with_args and argument dispatch."""
def test_register_command_with_args_stored(self) -> None:
"""register_command_with_args stores handler in _commands_with_args."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
async def my_handler(args: list[str]) -> None:
pass
handler.register_command_with_args("notify", my_handler)
assert "notify" in handler._commands_with_args
assert handler._commands_with_args["notify"] is my_handler
@pytest.mark.asyncio
async def test_args_handler_receives_arguments(self) -> None:
"""Args handler is called with the trailing tokens."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
received: list[list[str]] = []
async def capture(args: list[str]) -> None:
received.append(args)
handler.register_command_with_args("notify", capture)
update = {
"message": {
"chat": {"id": "456"},
"text": "/notify scenario off",
}
}
await handler._handle_update(update)
assert received == [["scenario", "off"]]
@pytest.mark.asyncio
async def test_args_handler_takes_priority_over_no_args_handler(self) -> None:
"""When both handlers exist for same command, args handler wins."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
no_args_called = []
args_called = []
async def no_args_handler() -> None:
no_args_called.append(True)
async def args_handler(args: list[str]) -> None:
args_called.append(args)
handler.register_command("notify", no_args_handler)
handler.register_command_with_args("notify", args_handler)
update = {
"message": {
"chat": {"id": "456"},
"text": "/notify all off",
}
}
await handler._handle_update(update)
assert args_called == [["all", "off"]]
assert no_args_called == []
@pytest.mark.asyncio
async def test_args_handler_with_no_trailing_args(self) -> None:
"""/notify with no args still dispatches to args handler with empty list."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
received: list[list[str]] = []
async def capture(args: list[str]) -> None:
received.append(args)
handler.register_command_with_args("notify", capture)
update = {
"message": {
"chat": {"id": "456"},
"text": "/notify",
}
}
await handler._handle_update(update)
assert received == [[]]