Compare commits

...

59 Commits

Author SHA1 Message Date
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
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
25 changed files with 5328 additions and 237 deletions

View File

@@ -192,6 +192,27 @@ When `TELEGRAM_COMMANDS_ENABLED=true` (default), the bot accepts these interacti
Commands are only processed from the authorized `TELEGRAM_CHAT_ID`. 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

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

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

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."""
@@ -198,6 +231,55 @@ 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()
@@ -249,13 +331,23 @@ class KISBroker:
session = self._get_session() session = self._get_session()
tr_id = "VTTC0802U" if order_type == "BUY" else "VTTC0801U" tr_id = "VTTC0802U" if order_type == "BUY" else "VTTC0801U"
# 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)
@@ -304,26 +396,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,66 +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":
configured_tr_id = self._broker._settings.OVERSEAS_RANKING_VOLUME_TR_ID tr_id = self._broker._settings.OVERSEAS_RANKING_VOLUME_TR_ID
configured_path = self._broker._settings.OVERSEAS_RANKING_VOLUME_PATH path = self._broker._settings.OVERSEAS_RANKING_VOLUME_PATH
default_tr_id = "HHDFS76200200" params: dict[str, str] = {
default_path = "/uapi/overseas-price/v1/quotations/inquire-volume-rank" "AUTH": "",
"EXCD": ranking_excd,
"MIXN": "0",
"VOL_RANG": "0",
}
else: else:
configured_tr_id = self._broker._settings.OVERSEAS_RANKING_FLUCT_TR_ID tr_id = self._broker._settings.OVERSEAS_RANKING_FLUCT_TR_ID
configured_path = self._broker._settings.OVERSEAS_RANKING_FLUCT_PATH path = self._broker._settings.OVERSEAS_RANKING_FLUCT_PATH
default_tr_id = "HHDFS76200100" params = {
default_path = "/uapi/overseas-price/v1/quotations/inquire-updown-rank" "AUTH": "",
"EXCD": ranking_excd,
"NDAY": "0",
"GUBN": "1",
"VOL_RANG": "0",
}
endpoint_specs: list[tuple[str, str]] = [(configured_tr_id, configured_path)] headers = await self._broker._auth_headers(tr_id)
if (configured_tr_id, configured_path) != (default_tr_id, default_path): url = f"{self._broker._base_url}{path}"
endpoint_specs.append((default_tr_id, default_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))}, text = await resp.text()
{"AUTH": "", "EXCD": exchange_code}, if resp.status == 404:
{"AUTH": "", "OVRS_EXCG_CD": exchange_code}, logger.warning(
] "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}"
)
last_error: str | None = None data = await resp.json()
saw_http_404 = False rows = self._extract_ranking_rows(data)
for tr_id, path in endpoint_specs: if rows:
headers = await self._broker._auth_headers(tr_id) return rows[:limit]
url = f"{self._broker._base_url}{path}"
for params in param_variants:
try:
async with session.get(url, headers=headers, params=params) as resp:
text = await resp.text()
if resp.status != 200:
last_error = f"HTTP {resp.status}: {text}"
if resp.status == 404:
saw_http_404 = True
continue
data = await resp.json() logger.debug(
rows = self._extract_ranking_rows(data) "Overseas ranking returned empty for %s/%s (keys=%s)",
if rows: exchange_code,
return rows[:limit] ranking_type,
list(data.keys()),
# keep trying another param variant if response has no usable rows )
last_error = f"empty output (keys={list(data.keys())})" return []
except (TimeoutError, aiohttp.ClientError) as exc: except (TimeoutError, aiohttp.ClientError) as exc:
last_error = str(exc) raise ConnectionError(
continue f"Network error fetching overseas rankings: {exc}"
) from exc
if saw_http_404:
logger.warning(
"Overseas ranking endpoint unavailable (404) for %s/%s; using symbol fallback scan",
exchange_code,
ranking_type,
)
return []
raise ConnectionError(
f"fetch_overseas_rankings failed for {exchange_code}/{ranking_type}: {last_error}"
)
async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]: async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]:
""" """
@@ -213,7 +230,9 @@ class OverseasBroker:
session = self._broker._get_session() session = self._broker._get_session()
# Virtual trading TR_IDs for overseas orders # Virtual trading TR_IDs for overseas orders
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1006U" # Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 주문' 시트
# VTTT1002U: 모의투자 미국 매수, VTTT1001U: 모의투자 미국 매도
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1001U"
body = { body = {
"CANO": self._broker._account_no, "CANO": self._broker._account_no,
@@ -240,14 +259,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

@@ -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,534 @@
--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); } }
</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> </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);
}
async function refreshAll() {
document.getElementById('last-updated').textContent = '업데이트 중...';
await Promise.all([
fetchStatus(),
fetchPerformance(),
fetchPositions(),
fetchPnlHistory(currentDays),
fetchDecisions(currentMarket),
]);
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

@@ -131,6 +131,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

View File

@@ -41,8 +41,8 @@ from src.evolution.optimizer import EvolutionOptimizer
from src.logging.decision_logger import DecisionLogger from src.logging.decision_logger import DecisionLogger
from src.logging_config import setup_logging from src.logging_config import setup_logging
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
from src.notifications.telegram_client import TelegramClient, TelegramCommandHandler from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
from src.strategy.models import DayPlaybook from src.strategy.models import DayPlaybook, MarketOutlook
from src.strategy.playbook_store import PlaybookStore from src.strategy.playbook_store import PlaybookStore
from src.strategy.pre_market_planner import PreMarketPlanner from src.strategy.pre_market_planner import PreMarketPlanner
from src.strategy.scenario_engine import ScenarioEngine from src.strategy.scenario_engine import ScenarioEngine
@@ -81,6 +81,7 @@ def safe_float(value: str | float | None, default: float = 0.0) -> float:
TRADE_INTERVAL_SECONDS = 60 TRADE_INTERVAL_SECONDS = 60
SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds
MAX_CONNECTION_RETRIES = 3 MAX_CONNECTION_RETRIES = 3
_BUY_COOLDOWN_SECONDS = 600 # 10-minute cooldown after insufficient-balance rejection
# Daily trading mode constants (for Free tier API efficiency) # Daily trading mode constants (for Free tier API efficiency)
DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
@@ -106,6 +107,82 @@ def _extract_symbol_from_holding(item: dict[str, Any]) -> str:
return "" return ""
def _extract_held_codes_from_balance(
balance_data: dict[str, Any],
*,
is_domestic: bool,
) -> list[str]:
"""Return stock codes with a positive orderable quantity from a balance response.
Uses the broker's live output1 as the source of truth so that partial fills
and manual external trades are always reflected correctly.
"""
output1 = balance_data.get("output1", [])
if isinstance(output1, dict):
output1 = [output1]
if not isinstance(output1, list):
return []
codes: list[str] = []
for holding in output1:
if not isinstance(holding, dict):
continue
code_key = "pdno" if is_domestic else "ovrs_pdno"
code = str(holding.get(code_key, "")).strip().upper()
if not code:
continue
if is_domestic:
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
else:
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
if qty > 0:
codes.append(code)
return codes
def _extract_held_qty_from_balance(
balance_data: dict[str, Any],
stock_code: str,
*,
is_domestic: bool,
) -> int:
"""Extract the broker-confirmed orderable quantity for a stock.
Uses the broker's live balance response (output1) as the source of truth
rather than the local DB, because DB records reflect order quantity which
may differ from actual fill quantity due to partial fills.
Domestic fields (VTTC8434R output1):
pdno — 종목코드
ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled)
hldg_qty — 보유수량 (fallback)
Overseas fields (output1):
ovrs_pdno — 종목코드
ovrs_cblc_qty — 해외잔고수량 (preferred)
hldg_qty — 보유수량 (fallback)
"""
output1 = balance_data.get("output1", [])
if isinstance(output1, dict):
output1 = [output1]
if not isinstance(output1, list):
return 0
for holding in output1:
if not isinstance(holding, dict):
continue
code_key = "pdno" if is_domestic else "ovrs_pdno"
held_code = str(holding.get(code_key, "")).strip().upper()
if held_code != stock_code.strip().upper():
continue
if is_domestic:
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
else:
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
return qty
return 0
def _determine_order_quantity( def _determine_order_quantity(
*, *,
action: str, action: str,
@@ -113,16 +190,40 @@ def _determine_order_quantity(
total_cash: float, total_cash: float,
candidate: ScanCandidate | None, candidate: ScanCandidate | None,
settings: Settings | None, settings: Settings | None,
broker_held_qty: int = 0,
playbook_allocation_pct: float | None = None,
scenario_confidence: int = 80,
) -> int: ) -> int:
"""Determine order quantity using volatility-aware position sizing.""" """Determine order quantity using volatility-aware position sizing.
if action != "BUY":
return 1 Priority:
1. playbook_allocation_pct (AI-specified) scaled by scenario_confidence
2. Fallback: volatility-score-based allocation from scanner candidate
"""
if action == "SELL":
return broker_held_qty
if current_price <= 0 or total_cash <= 0: if current_price <= 0 or total_cash <= 0:
return 0 return 0
if settings is None or not settings.POSITION_SIZING_ENABLED: if settings is None or not settings.POSITION_SIZING_ENABLED:
return 1 return 1
# Use AI-specified allocation_pct if available
if playbook_allocation_pct is not None:
# Confidence scaling: confidence 80 → 1.0x, confidence 95 → 1.19x
confidence_scale = scenario_confidence / 80.0
effective_pct = min(
settings.POSITION_MAX_ALLOCATION_PCT,
max(
settings.POSITION_MIN_ALLOCATION_PCT,
playbook_allocation_pct * confidence_scale,
),
)
budget = total_cash * (effective_pct / 100.0)
quantity = int(budget // current_price)
return max(0, quantity)
# Fallback: volatility-score-based allocation
target_score = max(1.0, settings.POSITION_VOLATILITY_TARGET_SCORE) target_score = max(1.0, settings.POSITION_VOLATILITY_TARGET_SCORE)
observed_score = candidate.score if candidate else target_score observed_score = candidate.score if candidate else target_score
observed_score = max(1.0, min(100.0, observed_score)) observed_score = max(1.0, min(100.0, observed_score))
@@ -198,13 +299,16 @@ async def trading_cycle(
stock_code: str, stock_code: str,
scan_candidates: dict[str, dict[str, ScanCandidate]], scan_candidates: dict[str, dict[str, ScanCandidate]],
settings: Settings | None = None, settings: Settings | None = None,
buy_cooldown: dict[str, float] | None = None,
) -> None: ) -> None:
"""Execute one trading cycle for a single stock.""" """Execute one trading cycle for a single stock."""
cycle_start_time = asyncio.get_event_loop().time() cycle_start_time = asyncio.get_event_loop().time()
# 1. Fetch market data # 1. Fetch market data
if market.is_domestic: if market.is_domestic:
orderbook = await broker.get_orderbook(stock_code) current_price, price_change_pct, foreigner_net = await broker.get_current_price(
stock_code
)
balance_data = await broker.get_balance() balance_data = await broker.get_balance()
output2 = balance_data.get("output2", [{}]) output2 = balance_data.get("output2", [{}])
@@ -215,10 +319,6 @@ async def trading_cycle(
else "0" else "0"
) )
purchase_total = safe_float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0 purchase_total = safe_float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0
current_price = safe_float(orderbook.get("output1", {}).get("stck_prpr", "0"))
foreigner_net = safe_float(orderbook.get("output1", {}).get("frgn_ntby_qty", "0"))
price_change_pct = safe_float(orderbook.get("output1", {}).get("prdy_ctrt", "0"))
else: else:
# Overseas market # Overseas market
price_data = await overseas_broker.get_overseas_price( price_data = await overseas_broker.get_overseas_price(
@@ -239,10 +339,43 @@ async def trading_cycle(
total_cash = safe_float(balance_info.get("frcr_dncl_amt_2", "0") or "0") total_cash = safe_float(balance_info.get("frcr_dncl_amt_2", "0") or "0")
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0") purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
# Paper mode fallback: VTS overseas balance API often fails for many accounts.
if total_cash <= 0 and settings and settings.PAPER_OVERSEAS_CASH > 0:
logger.debug(
"Overseas cash balance is 0 for %s; using paper fallback %.2f USD",
market.exchange_code,
settings.PAPER_OVERSEAS_CASH,
)
total_cash = settings.PAPER_OVERSEAS_CASH
current_price = safe_float(price_data.get("output", {}).get("last", "0")) current_price = safe_float(price_data.get("output", {}).get("last", "0"))
# Fallback: if price API returns 0, use scanner candidate price
if current_price <= 0:
market_candidates_lookup = scan_candidates.get(market.code, {})
cand_lookup = market_candidates_lookup.get(stock_code)
if cand_lookup and cand_lookup.price > 0:
logger.debug(
"Price API returned 0 for %s; using scanner candidate price %.4f",
stock_code,
cand_lookup.price,
)
current_price = cand_lookup.price
foreigner_net = 0.0 # Not available for overseas foreigner_net = 0.0 # Not available for overseas
price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0")) price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0"))
# Price API may return 0/empty for certain VTS exchange codes.
# Fall back to the scanner candidate's price so order sizing still works.
if current_price <= 0:
market_candidates_lookup = scan_candidates.get(market.code, {})
cand_lookup = market_candidates_lookup.get(stock_code)
if cand_lookup and cand_lookup.price > 0:
current_price = cand_lookup.price
logger.debug(
"Price API returned 0 for %s; using scanner price %.4f",
stock_code,
current_price,
)
# Calculate daily P&L % # Calculate daily P&L %
pnl_pct = ( pnl_pct = (
((total_eval - purchase_total) / purchase_total * 100) ((total_eval - purchase_total) / purchase_total * 100)
@@ -297,6 +430,17 @@ async def trading_cycle(
{"volume_ratio": candidate.volume_ratio}, {"volume_ratio": candidate.volume_ratio},
) )
# Write pnl_pct to system_metrics (dashboard-only table, separate from AI context tree)
db_conn.execute(
"INSERT OR REPLACE INTO system_metrics (key, value, updated_at) VALUES (?, ?, ?)",
(
f"portfolio_pnl_pct_{market.code}",
json.dumps({"pnl_pct": round(pnl_pct, 4)}),
datetime.now(UTC).isoformat(),
),
)
db_conn.commit()
# Build portfolio data for global rule evaluation # Build portfolio data for global rule evaluation
portfolio_data = { portfolio_data = {
"portfolio_pnl_pct": pnl_pct, "portfolio_pnl_pct": pnl_pct,
@@ -349,6 +493,53 @@ async def trading_cycle(
) )
stock_playbook = playbook.get_stock_playbook(stock_code) stock_playbook = playbook.get_stock_playbook(stock_code)
# 2.1. Apply market_outlook-based BUY confidence threshold
if decision.action == "BUY":
base_threshold = (settings.CONFIDENCE_THRESHOLD if settings else 80)
outlook = playbook.market_outlook
if outlook == MarketOutlook.BEARISH:
min_confidence = 90
elif outlook == MarketOutlook.BULLISH:
min_confidence = 75
else:
min_confidence = base_threshold
if match.confidence < min_confidence:
logger.info(
"BUY suppressed for %s (%s): confidence %d < %d (market_outlook=%s)",
stock_code,
market.name,
match.confidence,
min_confidence,
outlook.value,
)
decision = TradeDecision(
action="HOLD",
confidence=match.confidence,
rationale=(
f"BUY confidence {match.confidence} < {min_confidence} "
f"(market_outlook={outlook.value})"
),
)
# BUY 결정 전 기존 포지션 체크 (중복 매수 방지)
if decision.action == "BUY":
existing_position = get_open_position(db_conn, stock_code, market.code)
if existing_position:
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=(
f"Already holding {stock_code} "
f"(entry={existing_position['price']:.4f}, "
f"qty={existing_position['quantity']})"
),
)
logger.info(
"BUY suppressed for %s (%s): already holding open position",
stock_code,
market.name,
)
if decision.action == "HOLD": if decision.action == "HOLD":
open_position = get_open_position(db_conn, stock_code, market.code) open_position = get_open_position(db_conn, stock_code, market.code)
if open_position: if open_position:
@@ -356,8 +547,10 @@ async def trading_cycle(
if entry_price > 0: if entry_price > 0:
loss_pct = (current_price - entry_price) / entry_price * 100 loss_pct = (current_price - entry_price) / entry_price * 100
stop_loss_threshold = -2.0 stop_loss_threshold = -2.0
take_profit_threshold = 3.0
if stock_playbook and stock_playbook.scenarios: if stock_playbook and stock_playbook.scenarios:
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
if loss_pct <= stop_loss_threshold: if loss_pct <= stop_loss_threshold:
decision = TradeDecision( decision = TradeDecision(
@@ -375,6 +568,22 @@ async def trading_cycle(
loss_pct, loss_pct,
stop_loss_threshold, stop_loss_threshold,
) )
elif loss_pct >= take_profit_threshold:
decision = TradeDecision(
action="SELL",
confidence=90,
rationale=(
f"Take-profit triggered ({loss_pct:.2f}% >= "
f"{take_profit_threshold:.2f}%)"
),
)
logger.info(
"Take-profit override for %s (%s): %.2f%% >= %.2f%%",
stock_code,
market.name,
loss_pct,
take_profit_threshold,
)
logger.info( logger.info(
"Decision for %s (%s): %s (confidence=%d)", "Decision for %s (%s): %s (confidence=%d)",
stock_code, stock_code,
@@ -435,12 +644,23 @@ async def trading_cycle(
trade_price = current_price trade_price = current_price
trade_pnl = 0.0 trade_pnl = 0.0
if decision.action in ("BUY", "SELL"): if decision.action in ("BUY", "SELL"):
broker_held_qty = (
_extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic
)
if decision.action == "SELL"
else 0
)
matched_scenario = match.matched_scenario
quantity = _determine_order_quantity( quantity = _determine_order_quantity(
action=decision.action, action=decision.action,
current_price=current_price, current_price=current_price,
total_cash=total_cash, total_cash=total_cash,
candidate=candidate, candidate=candidate,
settings=settings, settings=settings,
broker_held_qty=broker_held_qty,
playbook_allocation_pct=matched_scenario.allocation_pct if matched_scenario else None,
scenario_confidence=match.confidence,
) )
if quantity <= 0: if quantity <= 0:
logger.info( logger.info(
@@ -454,13 +674,33 @@ async def trading_cycle(
return return
order_amount = current_price * quantity order_amount = current_price * quantity
# 4. Risk check BEFORE order # 4. Check BUY cooldown (set when a prior BUY failed due to insufficient balance)
if decision.action == "BUY" and buy_cooldown is not None:
cooldown_key = f"{market.code}:{stock_code}"
cooldown_until = buy_cooldown.get(cooldown_key, 0.0)
now = asyncio.get_event_loop().time()
if now < cooldown_until:
remaining = int(cooldown_until - now)
logger.info(
"Skip BUY %s (%s): insufficient-balance cooldown active (%ds remaining)",
stock_code,
market.name,
remaining,
)
return
# 5a. Risk check BEFORE order
# SELL orders do not consume cash (they receive it), so fat-finger check
# is skipped for SELLs — only circuit breaker applies.
try: try:
risk.validate_order( if decision.action == "SELL":
current_pnl_pct=pnl_pct, risk.check_circuit_breaker(pnl_pct)
order_amount=order_amount, else:
total_cash=total_cash, risk.validate_order(
) current_pnl_pct=pnl_pct,
order_amount=order_amount,
total_cash=total_cash,
)
except FatFingerRejected as exc: except FatFingerRejected as exc:
try: try:
await telegram.notify_fat_finger( await telegram.notify_fat_finger(
@@ -474,6 +714,7 @@ async def trading_cycle(
raise # Re-raise to prevent trade raise # Re-raise to prevent trade
# 5. Send order # 5. Send order
order_succeeded = True
if market.is_domestic: if market.is_domestic:
result = await broker.send_order( result = await broker.send_order(
stock_code=stock_code, stock_code=stock_code,
@@ -482,29 +723,60 @@ async def trading_cycle(
price=0, # market order price=0, # market order
) )
else: else:
# For overseas orders:
# - KIS VTS only accepts limit orders (지정가만 가능)
# - BUY: use 0.5% premium over last price to improve fill probability
# (ask price is typically slightly above last, and VTS won't fill below ask)
# - SELL: use last price as the limit
if decision.action == "BUY":
order_price = round(current_price * 1.005, 4)
else:
order_price = current_price
result = await overseas_broker.send_overseas_order( result = await overseas_broker.send_overseas_order(
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
stock_code=stock_code, stock_code=stock_code,
order_type=decision.action, order_type=decision.action,
quantity=quantity, quantity=quantity,
price=0.0, # market order price=order_price, # limit order — KIS VTS rejects market orders
) )
# Check if KIS rejected the order (rt_cd != "0")
if result.get("rt_cd", "") != "0":
order_succeeded = False
msg1 = result.get("msg1") or ""
logger.warning(
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
stock_code,
result.get("rt_cd"),
msg1,
)
# Set BUY cooldown when the rejection is due to insufficient balance
if decision.action == "BUY" and buy_cooldown is not None and "주문가능금액" in msg1:
cooldown_key = f"{market.code}:{stock_code}"
buy_cooldown[cooldown_key] = (
asyncio.get_event_loop().time() + _BUY_COOLDOWN_SECONDS
)
logger.info(
"BUY cooldown set for %s: %.0fs (insufficient balance)",
stock_code,
_BUY_COOLDOWN_SECONDS,
)
logger.info("Order result: %s", result.get("msg1", "OK")) logger.info("Order result: %s", result.get("msg1", "OK"))
# 5.5. Notify trade execution # 5.5. Notify trade execution (only on success)
try: if order_succeeded:
await telegram.notify_trade_execution( try:
stock_code=stock_code, await telegram.notify_trade_execution(
market=market.name, stock_code=stock_code,
action=decision.action, market=market.name,
quantity=quantity, action=decision.action,
price=current_price, quantity=quantity,
confidence=decision.confidence, price=current_price,
) confidence=decision.confidence,
except Exception as exc: )
logger.warning("Telegram notification failed: %s", exc) except Exception as exc:
logger.warning("Telegram notification failed: %s", exc)
if decision.action == "SELL": if decision.action == "SELL" and order_succeeded:
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code) buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
if buy_trade and buy_trade.get("price") is not None: if buy_trade and buy_trade.get("price") is not None:
buy_price = float(buy_trade["price"]) buy_price = float(buy_trade["price"])
@@ -516,7 +788,9 @@ async def trading_cycle(
accuracy=1 if trade_pnl > 0 else 0, accuracy=1 if trade_pnl > 0 else 0,
) )
# 6. Log trade with selection context # 6. Log trade with selection context (skip if order was rejected)
if decision.action in ("BUY", "SELL") and not order_succeeded:
return
selection_context = None selection_context = None
if stock_code in market_candidates: if stock_code in market_candidates:
candidate = market_candidates[stock_code] candidate = market_candidates[stock_code]
@@ -593,6 +867,9 @@ async def run_daily_session(
logger.info("Starting daily trading session for %d markets", len(open_markets)) logger.info("Starting daily trading session for %d markets", len(open_markets))
# BUY cooldown: prevents retrying stocks rejected for insufficient balance
daily_buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp
# Process each open market # Process each open market
for market in open_markets: for market in open_markets:
# Use market-local date for playbook keying # Use market-local date for playbook keying
@@ -671,15 +948,8 @@ async def run_daily_session(
for stock_code in watchlist: for stock_code in watchlist:
try: try:
if market.is_domestic: if market.is_domestic:
orderbook = await broker.get_orderbook(stock_code) current_price, price_change_pct, foreigner_net = (
current_price = safe_float( await broker.get_current_price(stock_code)
orderbook.get("output1", {}).get("stck_prpr", "0")
)
foreigner_net = safe_float(
orderbook.get("output1", {}).get("frgn_ntby_qty", "0")
)
price_change_pct = safe_float(
orderbook.get("output1", {}).get("prdy_ctrt", "0")
) )
else: else:
price_data = await overseas_broker.get_overseas_price( price_data = await overseas_broker.get_overseas_price(
@@ -688,10 +958,30 @@ async def run_daily_session(
current_price = safe_float( current_price = safe_float(
price_data.get("output", {}).get("last", "0") price_data.get("output", {}).get("last", "0")
) )
# Fallback: if price API returns 0, use scanner candidate price
if current_price <= 0:
cand_lookup = candidate_map.get(stock_code)
if cand_lookup and cand_lookup.price > 0:
logger.debug(
"Price API returned 0 for %s; using scanner candidate price %.4f",
stock_code,
cand_lookup.price,
)
current_price = cand_lookup.price
foreigner_net = 0.0 foreigner_net = 0.0
price_change_pct = safe_float( price_change_pct = safe_float(
price_data.get("output", {}).get("rate", "0") price_data.get("output", {}).get("rate", "0")
) )
# Fall back to scanner candidate price if API returns 0.
if current_price <= 0:
cand_lookup = candidate_map.get(stock_code)
if cand_lookup and cand_lookup.price > 0:
current_price = cand_lookup.price
logger.debug(
"Price API returned 0 for %s; using scanner price %.4f",
stock_code,
current_price,
)
stock_data: dict[str, Any] = { stock_data: dict[str, Any] = {
"stock_code": stock_code, "stock_code": stock_code,
@@ -742,6 +1032,13 @@ async def run_daily_session(
purchase_total = safe_float( purchase_total = safe_float(
balance_info.get("frcr_buy_amt_smtl", "0") or "0" balance_info.get("frcr_buy_amt_smtl", "0") or "0"
) )
# Paper mode fallback: VTS overseas balance API often fails for many accounts.
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0:
total_cash = settings.PAPER_OVERSEAS_CASH
# VTS overseas balance API often returns 0; use paper fallback.
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0:
total_cash = settings.PAPER_OVERSEAS_CASH
# Calculate daily P&L % # Calculate daily P&L %
pnl_pct = ( pnl_pct = (
@@ -816,13 +1113,22 @@ async def run_daily_session(
quantity = 0 quantity = 0
trade_price = stock_data["current_price"] trade_price = stock_data["current_price"]
trade_pnl = 0.0 trade_pnl = 0.0
order_succeeded = True
if decision.action in ("BUY", "SELL"): if decision.action in ("BUY", "SELL"):
daily_broker_held_qty = (
_extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic
)
if decision.action == "SELL"
else 0
)
quantity = _determine_order_quantity( quantity = _determine_order_quantity(
action=decision.action, action=decision.action,
current_price=stock_data["current_price"], current_price=stock_data["current_price"],
total_cash=total_cash, total_cash=total_cash,
candidate=candidate_map.get(stock_code), candidate=candidate_map.get(stock_code),
settings=settings, settings=settings,
broker_held_qty=daily_broker_held_qty,
) )
if quantity <= 0: if quantity <= 0:
logger.info( logger.info(
@@ -836,13 +1142,33 @@ async def run_daily_session(
continue continue
order_amount = stock_data["current_price"] * quantity order_amount = stock_data["current_price"] * quantity
# Check BUY cooldown (insufficient balance)
if decision.action == "BUY":
daily_cooldown_key = f"{market.code}:{stock_code}"
daily_cooldown_until = daily_buy_cooldown.get(daily_cooldown_key, 0.0)
now = asyncio.get_event_loop().time()
if now < daily_cooldown_until:
remaining = int(daily_cooldown_until - now)
logger.info(
"Skip BUY %s (%s): insufficient-balance cooldown active (%ds remaining)",
stock_code,
market.name,
remaining,
)
continue
# Risk check # Risk check
# SELL orders do not consume cash (they receive it), so fat-finger
# check is skipped for SELLs — only circuit breaker applies.
try: try:
risk.validate_order( if decision.action == "SELL":
current_pnl_pct=pnl_pct, risk.check_circuit_breaker(pnl_pct)
order_amount=order_amount, else:
total_cash=total_cash, risk.validate_order(
) current_pnl_pct=pnl_pct,
order_amount=order_amount,
total_cash=total_cash,
)
except FatFingerRejected as exc: except FatFingerRejected as exc:
try: try:
await telegram.notify_fat_finger( await telegram.notify_fat_finger(
@@ -868,6 +1194,7 @@ async def run_daily_session(
raise raise
# Send order # Send order
order_succeeded = True
try: try:
if market.is_domestic: if market.is_domestic:
result = await broker.send_order( result = await broker.send_order(
@@ -877,34 +1204,59 @@ async def run_daily_session(
price=0, # market order price=0, # market order
) )
else: else:
# KIS VTS only accepts limit orders; use 0.5% premium for BUY
if decision.action == "BUY":
order_price = round(stock_data["current_price"] * 1.005, 4)
else:
order_price = stock_data["current_price"]
result = await overseas_broker.send_overseas_order( result = await overseas_broker.send_overseas_order(
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
stock_code=stock_code, stock_code=stock_code,
order_type=decision.action, order_type=decision.action,
quantity=quantity, quantity=quantity,
price=0.0, # market order price=order_price, # limit order
) )
if result.get("rt_cd", "") != "0":
order_succeeded = False
daily_msg1 = result.get("msg1") or ""
logger.warning(
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
stock_code,
result.get("rt_cd"),
daily_msg1,
)
if decision.action == "BUY" and "주문가능금액" in daily_msg1:
daily_cooldown_key = f"{market.code}:{stock_code}"
daily_buy_cooldown[daily_cooldown_key] = (
asyncio.get_event_loop().time() + _BUY_COOLDOWN_SECONDS
)
logger.info(
"BUY cooldown set for %s: %.0fs (insufficient balance)",
stock_code,
_BUY_COOLDOWN_SECONDS,
)
logger.info("Order result: %s", result.get("msg1", "OK")) logger.info("Order result: %s", result.get("msg1", "OK"))
# Notify trade execution # Notify trade execution (only on success)
try: if order_succeeded:
await telegram.notify_trade_execution( try:
stock_code=stock_code, await telegram.notify_trade_execution(
market=market.name, stock_code=stock_code,
action=decision.action, market=market.name,
quantity=quantity, action=decision.action,
price=stock_data["current_price"], quantity=quantity,
confidence=decision.confidence, price=stock_data["current_price"],
) confidence=decision.confidence,
except Exception as exc: )
logger.warning("Telegram notification failed: %s", exc) except Exception as exc:
logger.warning("Telegram notification failed: %s", exc)
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"Order execution failed for %s: %s", stock_code, exc "Order execution failed for %s: %s", stock_code, exc
) )
continue continue
if decision.action == "SELL": if decision.action == "SELL" and order_succeeded:
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code) buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
if buy_trade and buy_trade.get("price") is not None: if buy_trade and buy_trade.get("price") is not None:
buy_price = float(buy_trade["price"]) buy_price = float(buy_trade["price"])
@@ -916,7 +1268,9 @@ async def run_daily_session(
accuracy=1 if trade_pnl > 0 else 0, accuracy=1 if trade_pnl > 0 else 0,
) )
# Log trade # Log trade (skip if order was rejected by API)
if decision.action in ("BUY", "SELL") and not order_succeeded:
continue
log_trade( log_trade(
conn=db_conn, conn=db_conn,
stock_code=stock_code, stock_code=stock_code,
@@ -1044,10 +1398,18 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
if not settings.DASHBOARD_ENABLED: if not settings.DASHBOARD_ENABLED:
return None return None
# Validate dependencies before spawning the thread so startup failures are
# reported synchronously (avoids the misleading "started" → "failed" log pair).
try:
import uvicorn # noqa: F401
from src.dashboard import create_dashboard_app # noqa: F401
except ImportError as exc:
logger.warning("Dashboard server unavailable (missing dependency): %s", exc)
return None
def _serve() -> None: def _serve() -> None:
try: try:
import uvicorn import uvicorn
from src.dashboard import create_dashboard_app from src.dashboard import create_dashboard_app
app = create_dashboard_app(settings.DB_PATH) app = create_dashboard_app(settings.DB_PATH)
@@ -1058,7 +1420,7 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
log_level="info", log_level="info",
) )
except Exception as exc: except Exception as exc:
logger.warning("Dashboard server failed to start: %s", exc) logger.warning("Dashboard server stopped unexpectedly: %s", exc)
thread = threading.Thread( thread = threading.Thread(
target=_serve, target=_serve,
@@ -1117,6 +1479,15 @@ async def run(settings: Settings) -> None:
bot_token=settings.TELEGRAM_BOT_TOKEN, bot_token=settings.TELEGRAM_BOT_TOKEN,
chat_id=settings.TELEGRAM_CHAT_ID, chat_id=settings.TELEGRAM_CHAT_ID,
enabled=settings.TELEGRAM_ENABLED, enabled=settings.TELEGRAM_ENABLED,
notification_filter=NotificationFilter(
trades=settings.TELEGRAM_NOTIFY_TRADES,
market_open_close=settings.TELEGRAM_NOTIFY_MARKET_OPEN_CLOSE,
fat_finger=settings.TELEGRAM_NOTIFY_FAT_FINGER,
system_events=settings.TELEGRAM_NOTIFY_SYSTEM_EVENTS,
playbook=settings.TELEGRAM_NOTIFY_PLAYBOOK,
scenario_match=settings.TELEGRAM_NOTIFY_SCENARIO_MATCH,
errors=settings.TELEGRAM_NOTIFY_ERRORS,
),
) )
# Initialize Telegram command handler # Initialize Telegram command handler
@@ -1135,7 +1506,11 @@ async def run(settings: Settings) -> None:
"/review - Recent scorecards\n" "/review - Recent scorecards\n"
"/dashboard - Dashboard URL/status\n" "/dashboard - Dashboard URL/status\n"
"/stop - Pause trading\n" "/stop - Pause trading\n"
"/resume - Resume trading" "/resume - Resume trading\n"
"/notify - Show notification filter status\n"
"/notify [key] [on|off] - Toggle notification type\n"
" Keys: trades, market, scenario, playbook,\n"
" system, fatfinger, errors, all"
) )
await telegram.send_message(message) await telegram.send_message(message)
@@ -1388,6 +1763,63 @@ async def run(settings: Settings) -> None:
"<b>⚠️ Error</b>\n\nFailed to retrieve reviews." "<b>⚠️ Error</b>\n\nFailed to retrieve reviews."
) )
async def handle_notify(args: list[str]) -> None:
"""Handle /notify [key] [on|off] — query or change notification filters."""
status = telegram.filter_status()
# /notify — show current state
if not args:
lines = ["<b>🔔 알림 필터 현재 상태</b>\n"]
for key, enabled in status.items():
icon = "" if enabled else ""
lines.append(f"{icon} <code>{key}</code>")
lines.append("\n<i>예) /notify scenario off</i>")
lines.append("<i>예) /notify all off</i>")
await telegram.send_message("\n".join(lines))
return
# /notify [key] — missing on/off
if len(args) == 1:
key = args[0].lower()
if key == "all":
lines = ["<b>🔔 알림 필터 현재 상태</b>\n"]
for k, enabled in status.items():
icon = "" if enabled else ""
lines.append(f"{icon} <code>{k}</code>")
await telegram.send_message("\n".join(lines))
elif key in status:
icon = "" if status[key] else ""
await telegram.send_message(
f"<b>🔔 {key}</b>: {icon} {'켜짐' if status[key] else '꺼짐'}\n"
f"<i>/notify {key} on 또는 /notify {key} off</i>"
)
else:
valid = ", ".join(list(status.keys()) + ["all"])
await telegram.send_message(
f"❌ 알 수 없는 키: <code>{key}</code>\n"
f"유효한 키: {valid}"
)
return
# /notify [key] [on|off]
key, toggle = args[0].lower(), args[1].lower()
if toggle not in ("on", "off"):
await telegram.send_message("❌ on 또는 off 를 입력해 주세요.")
return
value = toggle == "on"
if telegram.set_notification(key, value):
icon = "" if value else ""
label = f"전체 알림" if key == "all" else f"<code>{key}</code> 알림"
state = "켜짐" if value else "꺼짐"
await telegram.send_message(f"{icon} {label}{state}")
logger.info("Notification filter changed via Telegram: %s=%s", key, value)
else:
valid = ", ".join(list(telegram.filter_status().keys()) + ["all"])
await telegram.send_message(
f"❌ 알 수 없는 키: <code>{key}</code>\n"
f"유효한 키: {valid}"
)
async def handle_dashboard() -> None: async def handle_dashboard() -> None:
"""Handle /dashboard command - show dashboard URL if enabled.""" """Handle /dashboard command - show dashboard URL if enabled."""
if not settings.DASHBOARD_ENABLED: if not settings.DASHBOARD_ENABLED:
@@ -1411,6 +1843,7 @@ async def run(settings: Settings) -> None:
command_handler.register_command("scenarios", handle_scenarios) command_handler.register_command("scenarios", handle_scenarios)
command_handler.register_command("review", handle_review) command_handler.register_command("review", handle_review)
command_handler.register_command("dashboard", handle_dashboard) command_handler.register_command("dashboard", handle_dashboard)
command_handler.register_command_with_args("notify", handle_notify)
# Initialize volatility hunter # Initialize volatility hunter
volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0) volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)
@@ -1428,6 +1861,9 @@ async def run(settings: Settings) -> None:
# Active stocks per market (dynamically discovered by scanner) # Active stocks per market (dynamically discovered by scanner)
active_stocks: dict[str, list[str]] = {} # market_code -> [stock_codes] active_stocks: dict[str, list[str]] = {} # market_code -> [stock_codes]
# BUY cooldown: prevents retrying a stock rejected for insufficient balance
buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp
# Initialize latency control system # Initialize latency control system
criticality_assessor = CriticalityAssessor( criticality_assessor = CriticalityAssessor(
critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0% critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0%
@@ -1701,8 +2137,38 @@ async def run(settings: Settings) -> None:
except Exception as exc: except Exception as exc:
logger.error("Smart Scanner failed for %s: %s", market.name, exc) logger.error("Smart Scanner failed for %s: %s", market.name, exc)
# Get active stocks from scanner (dynamic, no static fallback) # Get active stocks from scanner (dynamic, no static fallback).
stock_codes = active_stocks.get(market.code, []) # Also include currently-held positions so stop-loss /
# take-profit can fire even when a holding drops off the
# scanner. Broker balance is the source of truth here —
# unlike the local DB it reflects actual fills and any
# manual trades done outside the bot.
scanner_codes = active_stocks.get(market.code, [])
try:
if market.is_domestic:
held_balance = await broker.get_balance()
else:
held_balance = await overseas_broker.get_overseas_balance(
market.exchange_code
)
held_codes = _extract_held_codes_from_balance(
held_balance, is_domestic=market.is_domestic
)
except Exception as exc:
logger.warning(
"Failed to fetch holdings for %s: %s — skipping holdings merge",
market.name, exc,
)
held_codes = []
stock_codes = list(dict.fromkeys(scanner_codes + held_codes))
extra_held = [c for c in held_codes if c not in set(scanner_codes)]
if extra_held:
logger.info(
"Holdings added to loop for %s (not in scanner): %s",
market.name, extra_held,
)
if not stock_codes: if not stock_codes:
logger.debug("No active stocks for market %s", market.code) logger.debug("No active stocks for market %s", market.code)
continue continue
@@ -1740,6 +2206,7 @@ async def run(settings: Settings) -> None:
stock_code, stock_code,
scan_candidates, scan_candidates,
settings, settings,
buy_cooldown,
) )
break # Success — exit retry loop break # Success — exit retry loop
except CircuitBreakerTripped as exc: except CircuitBreakerTripped as exc:

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

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

@@ -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,280 @@ 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"
assert body["ORD_UNPR"] == "0"

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,643 @@
"""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
del os.environ["PAPER_OVERSEAS_CASH"]

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

@@ -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 == [[]]