Compare commits

...

140 Commits

Author SHA1 Message Date
f87c4dc2f0 Merge pull request 'fix: ranking API 필수 파라미터 KEYB 추가 및 GUBN 값 수정 (#258)' (#260) from feature/issue-258-ranking-api-keyb-param into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #260
2026-02-26 00:20:58 +09:00
agentson
8af5f564c3 fix: ranking API 필수 파라미터 KEYB 추가 및 GUBN 값 수정 (#258)
Some checks failed
CI / test (pull_request) Has been cancelled
KIS 공식 문서(20260221) 기준 KEYB(NEXT KEY BUFF)는 Required=Y이나
누락되어 있어 항상 rt_cd=2 오류 발생, fallback 경로로만 실행됨.

- fluctuation/volume 양쪽 params에 KEYB: '' 추가
- GUBN 주석 수정: 0=하락율, 1=상승율 (문서 기준)
- GUBN 값 0→1 수정: 상승율 기준으로 변동성 급등 종목 스캔

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 00:19:55 +09:00
06e4fc5597 Merge pull request 'fix: run_overnight.sh --mode=paper → --mode=live 수정 (#256)' (#257) from feature/issue-256-fix-overnight-live-mode into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #257
2026-02-26 00:06:50 +09:00
agentson
b697b6d515 fix: run_overnight.sh --mode=paper → --mode=live 수정 (#256)
Some checks failed
CI / test (pull_request) Has been cancelled
실전투자 API 설정(.env: 실전 BASE_URL, 계좌번호)을 사용하면서
--mode=paper로 실행하여 TR_ID 불일치 발생.

실전투자 서버에 모의투자 TR_ID(VTTS3012R)를 날려
EGW02004: 실전투자 TR 이 아닙니다. 오류로 해외 거래 전부 실패.

APP_CMD 기본값을 --mode=live로 변경하여 실전투자 TR_ID(TTTS3012R) 사용.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 00:03:51 +09:00
42db5b3cc1 Merge pull request 'chore: 모의투자 데이터 및 evolved 전략 파일 정리 (#254)' (#255) from feature/issue-254-cleanup-paper-data into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #255
2026-02-25 07:45:22 +09:00
agentson
f252a84d65 chore: 모의투자 기반 evolved 전략 파일 삭제 (#254)
Some checks failed
CI / test (pull_request) Has been cancelled
실전 전환 후 모의 데이터로 생성된 evolved 전략 파일 제거.
main.py에서 import되지 않으므로 트레이딩 로직에 영향 없음.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 07:42:24 +09:00
adc5211fd2 Merge pull request 'fix: current_price=0 stop-loss 오발동 및 해외 주문 소수점 초과 수정 (#251, #252)' (#253) from feature/issue-251-252-trading-cycle-guards into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #253
2026-02-25 02:30:00 +09:00
agentson
67e0e8df41 fix: current_price=0 stop-loss 오발동 및 해외 주문 소수점 초과 수정 (#251, #252)
Some checks failed
CI / test (pull_request) Has been cancelled
1. stop-loss/take-profit 가드에 current_price > 0 조건 추가 (#251)
   - 현재가 API 실패(0.0 반환) 시 loss_pct=-100% 계산으로 오발동되던 문제 수정
   - if entry_price > 0 → if entry_price > 0 and current_price > 0
   - LLY '주문구분 입력오류'는 이 오발동의 연쇄 결과(overseas_price=0 → ORD_DVSN='01')

2. 해외 주문 가격 소수점을 $1 이상은 2자리로 제한 (#252)
   - round(x, 4) → $1+ 종목은 round(x, 2), 페니스탁은 round(x, 4) 유지
   - KIS '1$이상 소수점 2자리까지만 가능' 오류(TQQQ) 수정

테스트:
- test_stop_loss_not_triggered_when_current_price_is_zero 추가
- test_overseas_buy_price_rounded_to_2_decimals_for_dollar_plus_stock 추가
- test_overseas_penny_stock_price_keeps_4_decimals 추가
- 기존 overseas limit price 테스트 expected_price 2자리로 갱신

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 02:28:42 +09:00
ffdb99c6c7 Merge pull request 'feat: 시스템 외 매입 종목 stop-loss/take-profit 활성화 (pchs_avg_pric 반영) (#249)' (#250) from feature/issue-249-avg-price-sync into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #250
2026-02-25 02:20:03 +09:00
agentson
ce5ea5abde feat: 시스템 외 매입 종목에 pchs_avg_pric 반영 (#249)
Some checks failed
CI / test (pull_request) Has been cancelled
sync_positions_from_broker()에서 price=0.0 하드코딩으로 인해
stop-loss/take-profit이 외부 매수 종목에 작동하지 않던 문제를 수정한다.

- _extract_avg_price_from_balance() 헬퍼 추가 (pchs_avg_pric 추출)
- sync_positions_from_broker()에서 avg_price를 price 필드에 저장
- TestExtractAvgPriceFromBalance 단위 테스트 11개 추가
- TestSyncPositionsFromBroker 통합 테스트 3개 추가 (price 검증)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 02:18:11 +09:00
5ae302b083 Merge pull request 'fix: prompt_override 시 parse_response 건너뛰어 Missing fields 경고 제거 (#247)' (#248) from feature/issue-247-skip-parse-response-on-prompt-override into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #248
2026-02-25 01:59:15 +09:00
agentson
d31a61cd0b fix: prompt_override 경로 _total_decisions 미카운트, 완료 로그 추가, 테스트 보완
Some checks failed
CI / test (pull_request) Has been cancelled
리뷰 지적 사항 반영:
- _total_decisions 카운트 제거 (플레이북 생성은 거래 결정이 아님 → 메트릭 왜곡 방지)
- "Gemini raw response received" INFO 로그 추가 (완료 추적 가능)
- test_prompt_override_takes_priority_over_optimization 신규 추가
  (enable_optimization=True 상태에서도 prompt_override 우선됨을 검증)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 17:25:15 +09:00
5a41f86112 Merge pull request 'feat: 시작 시 브로커 포지션 → DB 동기화 및 국내주식 이중 매수 방지 (#206)' (#228) from feature/issue-206-startup-position-sync into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #228
2026-02-23 17:04:01 +09:00
agentson
ff9c4d6082 feat: 시작 시 브로커 포지션 → DB 동기화 및 국내주식 이중 매수 방지 (#206)
Some checks failed
CI / test (pull_request) Has been cancelled
- sync_positions_from_broker() 함수 추가
  - 시스템 시작 시 브로커 잔고를 조회해 DB에 없는 포지션을 BUY 레코드로 삽입
  - 국내: get_balance(), 해외: get_overseas_balance(exchange_code) 순회
  - ConnectionError는 경고 로그만 남기고 계속 진행 (non-fatal)
  - 동일 exchange_code 중복 조회 방지 (seen_exchange_codes 집합)
  - run() 초기화 후 최초 한 번 자동 호출

- 국내주식 BUY 이중 방지 로직 확장
  - trading_cycle 및 run_daily_session에서 기존에 해외 전용(not market.is_domestic)
    으로만 적용하던 broker balance 체크를 국내/해외 공통으로 변경
  - _extract_held_qty_from_balance(is_domestic=market.is_domestic)

- 테스트 (827 passed)
  - TestSyncPositionsFromBroker (6개): 국내/해외 동기화, 중복 skip, 공란, ConnectionError, dedup
  - TestDomesticBuyDoublePreventionTradingCycle (1개): 국내 보유 주식 BUY 억제

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:53:41 +09:00
agentson
ac4fb00644 feat: Daily 모드 ConnectionError 재시도 로직 추가 (issue #209)
Some checks failed
CI / test (pull_request) Has been cancelled
- _retry_connection() 헬퍼 추가: MAX_CONNECTION_RETRIES(3회) 지수 백오프
  (2^attempt 초) 재시도, 읽기 전용 API 호출에만 적용 (주문 제외)
- run_daily_session(): get_current_price / get_overseas_price 호출에 적용
- run_daily_session(): get_balance / get_overseas_balance 호출에 적용
  - 잔고 조회 전체 실패 시 해당 마켓을 skip하고 다른 마켓은 계속 처리
- 테스트 5개 추가: TestRetryConnection 클래스

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #173

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

Closes #172

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

Closes #171

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

Closes #170

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

## 변경 사항

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #155

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #143

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

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

Fixes #141

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:02:52 +09:00
77d3ba967c Merge pull request 'Fix overnight runner stability and token cooldown handling' (#139) from agentson/fix/137-run-overnight-python-tmux into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #139
2026-02-18 00:05:44 +09:00
agentson
aeed881d85 fix: wait on token refresh cooldown instead of failing fast
Some checks failed
CI / test (pull_request) Has been cancelled
2026-02-18 00:03:42 +09:00
agentson
d0bbdb5dc1 fix: harden overseas ranking fallback and scanner visibility 2026-02-17 23:39:20 +09:00
44339c52d7 Merge pull request 'Fix overnight runner Python selection and tmux window targeting' (#138) from agentson/fix/137-run-overnight-python-tmux into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #138
2026-02-17 23:25:11 +09:00
agentson
22ffdafacc chore: add overnight helper scripts
Some checks failed
CI / test (pull_request) Has been cancelled
- add morning report launcher\n- add overnight stop script\n- add watchdog health monitor script\n\nRefs #137
2026-02-17 23:24:15 +09:00
agentson
c49765e951 fix: make overnight runner use venv python and tmux-safe window target
Some checks failed
CI / test (pull_request) Has been cancelled
- prefer .venv/bin/python when APP_CMD is unset\n- pass DASHBOARD_PORT into launch command (default 8080)\n- target tmux window by name instead of fixed index\n\nRefs #137
2026-02-17 23:21:04 +09:00
64000b9967 Merge pull request 'feat: unify domestic scanner and sizing; update docs' (#136) from feat/overseas-ranking-current-state into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #136
2026-02-17 06:35:43 +09:00
agentson
733e6b36e9 feat: unify domestic scanner and sizing; update docs
Some checks failed
CI / test (pull_request) Has been cancelled
2026-02-17 06:29:36 +09:00
agentson
0659cc0aca docs: reflect overseas ranking integration and volatility-first selection 2026-02-17 06:29:16 +09:00
agentson
748b9b848e feat: prioritize overseas volatility scoring over raw rankings 2026-02-17 06:25:45 +09:00
agentson
6a1ad230ee feat: add overseas ranking integration with dynamic fallback 2026-02-17 06:25:45 +09:00
90bbc78867 Merge pull request 'docs: sync V2 status and process docs (#131)' (#134) from feature/issue-131-docs-v2-status-sync into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #134
Reviewed-by: jihoson <kiparang7th@gmail.com>
2026-02-16 21:50:49 +09:00
agentson
1ef5dcb2b3 docs: README.md v2 현행화 (#131)
Some checks failed
CI / test (pull_request) Has been cancelled
- 아키텍처 다이어그램에 v2 컴포넌트 (Strategy, Context, Evolution) 추가
- 핵심 모듈 테이블: 6개 → 14개 모듈 반영
- 테스트: 35개/3파일 → 551개/25파일
- 지원 시장 10개 거래소 테이블 추가
- 텔레그램 양방향 명령어 9종 레퍼런스
- 프로젝트 구조 트리 전면 갱신
- 문서 링크 섹션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:48:49 +09:00
agentson
d105a3ff5e docs: v2 상태 반영 - 전체 문서 현행화 (#131)
Some checks failed
CI / test (pull_request) Has been cancelled
- testing.md: 54 tests/4 files → 551 tests/25 files 반영, 전체 테스트 파일 설명
- architecture.md: v2 컴포넌트 추가 (Strategy, Context, Dashboard, Decision Logger 등),
  Playbook Mode 데이터 플로우, DB 스키마 5개 테이블, v2 환경변수
- commands.md: Dashboard 실행, Telegram 명령어 9종 레퍼런스
- CLAUDE.md: Project Structure 확장, 테스트 수 업데이트, --dashboard 플래그
- skills.md: DB 파일명 trades.db로 통일, Dashboard 명령어 추가
- requirements-log.md: 2026-02-16 문서 v2 동기화 요구사항 기록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:44:59 +09:00
0424c78f6c Merge pull request 'feat: US market code 정합성, Telegram 명령 4종, 손절 모니터링 (#132)' (#135) from feature/issue-132-us-market-telegram-gaps into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #135
2026-02-16 20:25:43 +09:00
agentson
3fdb7a29d4 feat: US market code 정합성, Telegram 명령 4종, 손절 모니터링 (#132)
Some checks failed
CI / test (pull_request) Has been cancelled
- MARKET_SHORTHAND + expand_market_codes()로 config "US" → schedule "US_NASDAQ/NYSE/AMEX" 자동 확장
- /report, /scenarios, /review, /dashboard 텔레그램 명령 추가
- price_change_pct를 trading_cycle과 run_daily_session에 주입
- HOLD시 get_open_position 기반 손절 모니터링 및 자동 SELL 오버라이드
- 대시보드 /api/status 동적 market 조회로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:24:01 +09:00
31b4d0bf1e Merge pull request 'fix: daily_review 테스트 날짜 불일치 수정 (#129)' (#130) from feature/issue-129-fix-daily-review-test-date into main
Some checks failed
CI / test (push) Has been cancelled
CI / test (pull_request) Has been cancelled
Reviewed-on: #130
2026-02-16 11:30:20 +09:00
agentson
e2275a23b1 fix: daily_review 테스트에서 날짜 불일치로 인한 실패 수정 (#129)
Some checks failed
CI / test (pull_request) Has been cancelled
DecisionLogger와 log_trade가 datetime.now(UTC)로 현재 날짜를 저장하는데,
테스트에서 하드코딩된 '2026-02-14'로 조회하여 0건이 반환되던 문제 수정.
generate_scorecard 호출 시 TODAY 변수를 사용하도록 변경.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:05:17 +09:00
7522bb7e66 Merge pull request 'feat: 대시보드 실행 통합 - CLI + 환경변수 (issue #97)' (#128) from feature/issue-97-dashboard-integration into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #128
2026-02-15 00:01:57 +09:00
agentson
63fa6841a2 feat: dashboard background thread with CLI flag (issue #97)
Some checks failed
CI / test (pull_request) Has been cancelled
Add --dashboard CLI flag and DASHBOARD_ENABLED env var to start
FastAPI dashboard in a daemon thread alongside the trading loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:01:29 +09:00
ece3c5597b Merge pull request 'feat: FastAPI 읽기 전용 대시보드 (issue #96)' (#127) from feature/issue-96-evolution-main-integration into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #127
2026-02-14 23:57:17 +09:00
47 changed files with 13286 additions and 764 deletions

View File

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

View File

@@ -15,6 +15,9 @@ pytest -v --cov=src
# Run (paper trading) # Run (paper trading)
python -m src.main --mode=paper python -m src.main --mode=paper
# Run with dashboard
python -m src.main --mode=paper --dashboard
``` ```
## Telegram Notifications (Optional) ## Telegram Notifications (Optional)
@@ -43,6 +46,10 @@ Get real-time alerts for trades, circuit breakers, and system events via Telegra
- Market open/close notifications - Market open/close notifications
- 📝 System startup/shutdown status - 📝 System startup/shutdown status
### Interactive Commands
With `TELEGRAM_COMMANDS_ENABLED=true` (default), the bot supports 9 bidirectional commands: `/help`, `/status`, `/positions`, `/report`, `/scenarios`, `/review`, `/dashboard`, `/stop`, `/resume`.
**Fail-safe**: Notifications never crash the trading system. Missing credentials or API errors are logged but trading continues normally. **Fail-safe**: Notifications never crash the trading system. Missing credentials or API errors are logged but trading continues normally.
## Smart Volatility Scanner (Optional) ## Smart Volatility Scanner (Optional)
@@ -87,6 +94,7 @@ Smart Scanner runs in `TRADE_MODE=realtime` only. Daily mode uses static watchli
- **[Testing](docs/testing.md)** — Test structure, coverage requirements, writing tests - **[Testing](docs/testing.md)** — Test structure, coverage requirements, writing tests
- **[Agent Policies](docs/agents.md)** — Prime directives, constraints, prohibited actions - **[Agent Policies](docs/agents.md)** — Prime directives, constraints, prohibited actions
- **[Requirements Log](docs/requirements-log.md)** — User requirements and feedback tracking - **[Requirements Log](docs/requirements-log.md)** — User requirements and feedback tracking
- **[Live Trading Checklist](docs/live-trading-checklist.md)** — 모의→실전 전환 체크리스트
## Core Principles ## Core Principles
@@ -109,17 +117,23 @@ User requirements and feedback are tracked in [docs/requirements-log.md](docs/re
``` ```
src/ src/
├── analysis/ # Technical analysis (RSI, volatility, smart scanner) ├── analysis/ # Technical analysis (RSI, volatility, smart scanner)
├── backup/ # Disaster recovery (scheduler, cloud storage, health)
├── brain/ # Gemini AI decision engine (prompt optimizer, context selector)
├── broker/ # KIS API client (domestic + overseas) ├── broker/ # KIS API client (domestic + overseas)
├── brain/ # Gemini AI decision engine ├── context/ # L1-L7 hierarchical memory system
├── core/ # Risk manager (READ-ONLY) ├── core/ # Risk manager (READ-ONLY)
├── evolution/ # Self-improvement optimizer ├── dashboard/ # FastAPI read-only monitoring (8 API endpoints)
├── data/ # External data integration (news, market data, calendar)
├── evolution/ # Self-improvement (optimizer, daily review, scorecard)
├── logging/ # Decision logger (audit trail)
├── markets/ # Market schedules and timezone handling ├── markets/ # Market schedules and timezone handling
├── notifications/ # Telegram real-time alerts ├── notifications/ # Telegram alerts + bidirectional commands (9 commands)
├── strategy/ # Pre-market planner, scenario engine, playbook store
├── db.py # SQLite trade logging ├── db.py # SQLite trade logging
├── main.py # Trading loop orchestrator ├── main.py # Trading loop orchestrator
└── config.py # Settings (from .env) └── config.py # Settings (from .env)
tests/ # 343 tests across 14 files tests/ # 551 tests across 25 files
docs/ # Extended documentation docs/ # Extended documentation
``` ```
@@ -131,6 +145,7 @@ ruff check src/ tests/ # Lint
mypy src/ --strict # Type check mypy src/ --strict # Type check
python -m src.main --mode=paper # Paper trading python -m src.main --mode=paper # Paper trading
python -m src.main --mode=paper --dashboard # With dashboard
python -m src.main --mode=live # Live trading (⚠️ real money) python -m src.main --mode=live # Live trading (⚠️ real money)
# Gitea workflow (requires tea CLI) # Gitea workflow (requires tea CLI)
@@ -156,7 +171,7 @@ Markets auto-detected based on timezone and enabled in `ENABLED_MARKETS` env var
- `src/core/risk_manager.py` is **READ-ONLY** — changes require human approval - `src/core/risk_manager.py` is **READ-ONLY** — changes require human approval
- Circuit breaker at -3.0% P&L — may only be made **stricter** - Circuit breaker at -3.0% P&L — may only be made **stricter**
- Fat-finger protection: max 30% of cash per order — always enforced - Fat-finger protection: max 30% of cash per order — always enforced
- Confidence < 80 → force HOLD — cannot be weakened - Confidence 임계값 (market_outlook별, 낮출 수 없음): BEARISH ≥ 90, NEUTRAL/기본 ≥ 80, BULLISH ≥ 75
- All code changes → corresponding tests → coverage ≥ 80% - All code changes → corresponding tests → coverage ≥ 80%
## Contributing ## Contributing

156
README.md
View File

@@ -10,28 +10,41 @@ KIS(한국투자증권) API로 매매하고, Google Gemini로 판단하며, 자
│ (매매 실행) │ │ (거래 루프) │ │ (의사결정) │ │ (매매 실행) │ │ (거래 루프) │ │ (의사결정) │
└─────────────┘ └──────┬──────┘ └─────────────┘ └─────────────┘ └──────┬──────┘ └─────────────┘
┌────────────┐ ┌────────────┼────────────┐
│Risk Manager │ │
│ (안전장치) │ ┌──────┴──────┐ ┌──┴───┐ ┌──────┴──────┐
└──────┬──────┘ │Risk Manager │ │ DB │ │ Telegram │
│ (안전장치) │ │ │ │ (알림+명령) │
└──────┬──────┘ └──────┘ └─────────────┘
┌────────────┐ ┌────────┼────────┐
Evolution
│ (전략 진화) │ ┌────┴────┐┌──┴──┐┌────┴─────┐
└─────────────┘ │Strategy ││Ctx ││Evolution │
│(플레이북)││(메모리)││ (진화) │
└─────────┘└─────┘└──────────┘
``` ```
**v2 핵심**: "Plan Once, Execute Locally" — 장 시작 전 AI가 시나리오 플레이북을 1회 생성하고, 거래 시간에는 로컬 시나리오 매칭만 수행하여 API 비용과 지연 시간을 대폭 절감.
## 핵심 모듈 ## 핵심 모듈
| 모듈 | 파일 | 설명 | | 모듈 | 위치 | 설명 |
|------|------|------| |------|------|------|
| 설정 | `src/config.py` | Pydantic 기반 환경변수 로딩 및 타입 검증 | | 설정 | `src/config.py` | Pydantic 기반 환경변수 로딩 및 타입 검증 (35+ 변수) |
| 브로커 | `src/broker/kis_api.py` | KIS API 비동기 래퍼 (토큰 갱신, 레이트 리미터, 해시키) | | 브로커 | `src/broker/` | KIS API 비동기 래퍼 (국내 + 해외 9개 시장) |
| 두뇌 | `src/brain/gemini_client.py` | Gemini 프롬프트 구성 JSON 응답 파싱 | | 두뇌 | `src/brain/` | Gemini 프롬프트 구성, JSON 파싱, 토큰 최적화 |
| 방패 | `src/core/risk_manager.py` | 서킷 브레이커 + 팻 핑거 체크 | | 방패 | `src/core/risk_manager.py` | 서킷 브레이커 + 팻 핑거 체크 (READ-ONLY) |
| 알림 | `src/notifications/telegram_client.py` | 텔레그램 실시간 거래 알림 (선택사항) | | 전략 | `src/strategy/` | Pre-Market Planner, Scenario Engine, Playbook Store |
| 진화 | `src/evolution/optimizer.py` | 실패 패턴 분석 → 새 전략 생성 → 테스트 → PR | | 컨텍스트 | `src/context/` | L1-L7 계층형 메모리 시스템 |
| DB | `src/db.py` | SQLite 거래 로그 기록 | | 분석 | `src/analysis/` | RSI, ATR, Smart Volatility Scanner |
| 알림 | `src/notifications/` | 텔레그램 양방향 (알림 + 9개 명령어) |
| 대시보드 | `src/dashboard/` | FastAPI 읽기 전용 모니터링 (8개 API) |
| 진화 | `src/evolution/` | 전략 진화 + Daily Review + Scorecard |
| 의사결정 로그 | `src/logging/` | 전체 거래 결정 감사 추적 |
| 데이터 | `src/data/` | 뉴스, 시장 데이터, 경제 캘린더 연동 |
| 백업 | `src/backup/` | 자동 백업, S3 클라우드, 무결성 검증 |
| DB | `src/db.py` | SQLite 거래 로그 (5개 테이블) |
## 안전장치 ## 안전장치
@@ -42,6 +55,7 @@ KIS(한국투자증권) API로 매매하고, Google Gemini로 판단하며, 자
| 신뢰도 임계값 | Gemini 신뢰도 80 미만이면 강제 HOLD | | 신뢰도 임계값 | Gemini 신뢰도 80 미만이면 강제 HOLD |
| 레이트 리미터 | Leaky Bucket 알고리즘으로 API 호출 제한 | | 레이트 리미터 | Leaky Bucket 알고리즘으로 API 호출 제한 |
| 토큰 자동 갱신 | 만료 1분 전 자동으로 Access Token 재발급 | | 토큰 자동 갱신 | 만료 1분 전 자동으로 Access Token 재발급 |
| 손절 모니터링 | 플레이북 시나리오 기반 실시간 포지션 보호 |
## 빠른 시작 ## 빠른 시작
@@ -67,7 +81,11 @@ pytest -v --cov=src --cov-report=term-missing
### 4. 실행 (모의투자) ### 4. 실행 (모의투자)
```bash ```bash
# 기본 실행
python -m src.main --mode=paper python -m src.main --mode=paper
# 대시보드 활성화
python -m src.main --mode=paper --dashboard
``` ```
### 5. Docker 실행 ### 5. Docker 실행
@@ -76,7 +94,20 @@ python -m src.main --mode=paper
docker compose up -d ouroboros docker compose up -d ouroboros
``` ```
## 텔레그램 알림 (선택사항) ## 지원 시장
| 국가 | 거래소 | 코드 |
|------|--------|------|
| 🇰🇷 한국 | KRX | KR |
| 🇺🇸 미국 | NASDAQ, NYSE, AMEX | US_NASDAQ, US_NYSE, US_AMEX |
| 🇯🇵 일본 | TSE | JP |
| 🇭🇰 홍콩 | SEHK | HK |
| 🇨🇳 중국 | 상하이, 선전 | CN_SHA, CN_SZA |
| 🇻🇳 베트남 | 하노이, 호치민 | VN_HNX, VN_HSX |
`ENABLED_MARKETS` 환경변수로 활성 시장 선택 (기본: `KR,US`).
## 텔레그램 (선택사항)
거래 실행, 서킷 브레이커 발동, 시스템 상태 등을 텔레그램으로 실시간 알림 받을 수 있습니다. 거래 실행, 서킷 브레이커 발동, 시스템 상태 등을 텔레그램으로 실시간 알림 받을 수 있습니다.
@@ -102,25 +133,51 @@ docker compose up -d ouroboros
- 장 시작/종료 알림 - 장 시작/종료 알림
- 📝 시스템 시작/종료 상태 - 📝 시스템 시작/종료 상태
**안전장치**: 알림 실패해도 거래는 계속 진행됩니다. 텔레그램 API 오류나 설정 누락이 있어도 거래 시스템은 정상 작동합니다. ### 양방향 명령어
`TELEGRAM_COMMANDS_ENABLED=true` (기본값) 설정 시 9개 대화형 명령어 지원:
| 명령어 | 설명 |
|--------|------|
| `/help` | 사용 가능한 명령어 목록 |
| `/status` | 거래 상태 (모드, 시장, P&L) |
| `/positions` | 계좌 요약 (잔고, 현금, P&L) |
| `/report` | 일일 요약 (거래 수, P&L, 승률) |
| `/scenarios` | 오늘의 플레이북 시나리오 |
| `/review` | 최근 스코어카드 (L6_DAILY) |
| `/dashboard` | 대시보드 URL 표시 |
| `/stop` | 거래 일시 정지 |
| `/resume` | 거래 재개 |
**안전장치**: 알림 실패해도 거래는 계속 진행됩니다.
## 테스트 ## 테스트
35개 테스트가 TDD 방식으로 구현 전에 먼저 작성되었습니다. 551개 테스트가 25개 파일에 걸쳐 구현되어 있습니다. 최소 커버리지 80%.
``` ```
tests/test_risk.py — 서킷 브레이커, 팻 핑거, 통합 검증 (11개) tests/test_scenario_engine.py 시나리오 매칭 (44개)
tests/test_broker.py — 토큰 관리, 타임아웃, HTTP 에러, 해시키 (6개) tests/test_data_integration.py — 외부 데이터 연동 (38개)
tests/test_brain.py JSON 파싱, 신뢰도 임계값, 비정상 응답 처리 (15개) tests/test_pre_market_planner.py — 플레이북 생성 (37개)
tests/test_main.py — 거래 루프 통합 (37개)
tests/test_token_efficiency.py — 토큰 최적화 (34개)
tests/test_strategy_models.py — 전략 모델 검증 (33개)
tests/test_telegram_commands.py — 텔레그램 명령어 (31개)
tests/test_latency_control.py — 지연시간 제어 (30개)
tests/test_telegram.py — 텔레그램 알림 (25개)
... 외 16개 파일
``` ```
**상세**: [docs/testing.md](docs/testing.md)
## 기술 스택 ## 기술 스택
- **언어**: Python 3.11+ (asyncio 기반) - **언어**: Python 3.11+ (asyncio 기반)
- **브로커**: KIS Open API (REST) - **브로커**: KIS Open API (REST, 국내+해외)
- **AI**: Google Gemini Pro - **AI**: Google Gemini Pro
- **DB**: SQLite - **DB**: SQLite (5개 테이블: trades, contexts, decision_logs, playbooks, context_metadata)
- **검증**: pytest + coverage - **대시보드**: FastAPI + uvicorn
- **검증**: pytest + coverage (551 tests)
- **CI/CD**: GitHub Actions - **CI/CD**: GitHub Actions
- **배포**: Docker + Docker Compose - **배포**: Docker + Docker Compose
@@ -128,27 +185,50 @@ tests/test_brain.py — JSON 파싱, 신뢰도 임계값, 비정상 응답 처
``` ```
The-Ouroboros/ The-Ouroboros/
├── .github/workflows/ci.yml # CI 파이프라인
├── docs/ ├── docs/
│ ├── agents.md # AI 에이전트 페르소나 정의 │ ├── architecture.md # 시스템 아키텍처
── skills.md # 사용 가능한 도구 목록 ── testing.md # 테스트 가이드
│ ├── commands.md # 명령어 레퍼런스
│ ├── context-tree.md # L1-L7 메모리 시스템
│ ├── workflow.md # Git 워크플로우
│ ├── agents.md # 에이전트 정책
│ ├── skills.md # 도구 목록
│ ├── disaster_recovery.md # 백업/복구
│ └── requirements-log.md # 요구사항 기록
├── src/ ├── src/
│ ├── analysis/ # 기술적 분석 (RSI, ATR, Smart Scanner)
│ ├── backup/ # 백업 (스케줄러, S3, 무결성 검증)
│ ├── brain/ # Gemini 의사결정 (프롬프트 최적화, 컨텍스트 선택)
│ ├── broker/ # KIS API (국내 + 해외)
│ ├── context/ # L1-L7 계층 메모리
│ ├── core/ # 리스크 관리 (READ-ONLY)
│ ├── dashboard/ # FastAPI 모니터링 대시보드
│ ├── data/ # 외부 데이터 연동
│ ├── evolution/ # 전략 진화 + Daily Review
│ ├── logging/ # 의사결정 감사 추적
│ ├── markets/ # 시장 스케줄 + 타임존
│ ├── notifications/ # 텔레그램 알림 + 명령어
│ ├── strategy/ # 플레이북 (Planner, Scenario Engine)
│ ├── config.py # Pydantic 설정 │ ├── config.py # Pydantic 설정
│ ├── logging_config.py # JSON 구조화 로깅 │ ├── db.py # SQLite 데이터베이스
── db.py # SQLite 거래 기록 ── main.py # 비동기 거래 루프
│ ├── main.py # 비동기 거래 루프 ├── tests/ # 551개 테스트 (25개 파일)
│ ├── broker/kis_api.py # KIS API 클라이언트
│ ├── brain/gemini_client.py # Gemini 의사결정 엔진
│ ├── core/risk_manager.py # 리스크 관리
│ ├── notifications/telegram_client.py # 텔레그램 알림
│ ├── evolution/optimizer.py # 전략 진화 엔진
│ └── strategies/base.py # 전략 베이스 클래스
├── tests/ # TDD 테스트 스위트
├── Dockerfile # 멀티스테이지 빌드 ├── Dockerfile # 멀티스테이지 빌드
├── docker-compose.yml # 서비스 오케스트레이션 ├── docker-compose.yml # 서비스 오케스트레이션
└── pyproject.toml # 의존성 및 도구 설정 └── pyproject.toml # 의존성 및 도구 설정
``` ```
## 문서
- **[아키텍처](docs/architecture.md)** — 시스템 설계, 컴포넌트, 데이터 흐름
- **[테스트](docs/testing.md)** — 테스트 구조, 커버리지, 작성 가이드
- **[명령어](docs/commands.md)** — CLI, Dashboard, Telegram 명령어
- **[컨텍스트 트리](docs/context-tree.md)** — L1-L7 계층 메모리
- **[워크플로우](docs/workflow.md)** — Git 워크플로우 정책
- **[에이전트 정책](docs/agents.md)** — 안전 제약, 금지 행위
- **[백업/복구](docs/disaster_recovery.md)** — 재해 복구 절차
- **[요구사항](docs/requirements-log.md)** — 사용자 요구사항 추적
## 라이선스 ## 라이선스
이 프로젝트의 라이선스는 [LICENSE](LICENSE) 파일을 참조하세요. 이 프로젝트의 라이선스는 [LICENSE](LICENSE) 파일을 참조하세요.

View File

@@ -2,7 +2,9 @@
## Overview ## Overview
Self-evolving AI trading agent for global stock markets via KIS (Korea Investment & Securities) API. The main loop in `src/main.py` orchestrates four components across multiple markets with two trading modes: daily (batch API calls) or realtime (per-stock decisions). Self-evolving AI trading agent for global stock markets via KIS (Korea Investment & Securities) API. The main loop in `src/main.py` orchestrates components across multiple markets with two trading modes: daily (batch API calls) or realtime (per-stock decisions).
**v2 Proactive Playbook Architecture**: The system uses a "plan once, execute locally" approach. Pre-market, the AI generates a playbook of scenarios (one Gemini API call per market per day). During trading hours, a local scenario engine matches live market data against these pre-computed scenarios — no additional AI calls needed. This dramatically reduces API costs and latency.
## Trading Modes ## Trading Modes
@@ -46,9 +48,11 @@ High-frequency trading with individual stock analysis:
**KISBroker** (`kis_api.py`) — Async KIS API client for domestic Korean market **KISBroker** (`kis_api.py`) — Async KIS API client for domestic Korean market
- Automatic OAuth token refresh (valid for 24 hours) - Automatic OAuth token refresh (valid for 24 hours)
- Leaky-bucket rate limiter (10 requests per second) - Leaky-bucket rate limiter (configurable RPS, default 2.0)
- POST body hash-key signing for order authentication - POST body hash-key signing for order authentication
- Custom SSL context with disabled hostname verification for VTS (virtual trading) endpoint due to known certificate mismatch - Custom SSL context with disabled hostname verification for VTS (virtual trading) endpoint due to known certificate mismatch
- `fetch_market_rankings()` — Fetch volume surge rankings from KIS API
- `get_daily_prices()` — Fetch OHLCV history for technical analysis
**OverseasBroker** (`overseas.py`) — KIS overseas stock API wrapper **OverseasBroker** (`overseas.py`) — KIS overseas stock API wrapper
@@ -63,10 +67,11 @@ High-frequency trading with individual stock analysis:
- `is_market_open()` checks weekends, trading hours, lunch breaks - `is_market_open()` checks weekends, trading hours, lunch breaks
- `get_open_markets()` returns currently active markets - `get_open_markets()` returns currently active markets
- `get_next_market_open()` finds next market to open and when - `get_next_market_open()` finds next market to open and when
- 10 global markets defined (KR, US_NASDAQ, US_NYSE, US_AMEX, JP, HK, CN_SHA, CN_SZA, VN_HNX, VN_HSX)
**New API Methods** (added in v0.9.0): **Overseas Ranking API Methods** (added in v0.10.x):
- `fetch_market_rankings()` — Fetch volume surge rankings from KIS API - `fetch_overseas_rankings()` — Fetch overseas ranking universe (fluctuation / volume)
- `get_daily_prices()` — Fetch OHLCV history for technical analysis - Ranking endpoint paths and TR_IDs are configurable via environment variables
### 2. Analysis (`src/analysis/`) ### 2. Analysis (`src/analysis/`)
@@ -81,24 +86,28 @@ High-frequency trading with individual stock analysis:
**SmartVolatilityScanner** (`smart_scanner.py`) — Python-first filtering pipeline **SmartVolatilityScanner** (`smart_scanner.py`) — Python-first filtering pipeline
- **Step 1**: Fetch volume rankings from KIS API (top 30 stocks) - **Domestic (KR)**:
- **Step 2**: Calculate RSI and volume ratio for each stock - **Step 1**: Fetch domestic fluctuation ranking as primary universe
- **Step 3**: Apply filters: - **Step 2**: Fetch domestic volume ranking for liquidity bonus
- Volume ratio >= `VOL_MULTIPLIER` (default 2.0x previous day) - **Step 3**: Compute volatility-first score (max of daily change% and intraday range%)
- RSI < `RSI_OVERSOLD_THRESHOLD` (30) OR RSI > `RSI_MOMENTUM_THRESHOLD` (70) - **Step 4**: Apply liquidity bonus and return top N candidates
- **Step 4**: Score candidates by RSI extremity (60%) + volume surge (40%) - **Overseas (US/JP/HK/CN/VN)**:
- **Step 5**: Return top N candidates (default 3) for AI analysis - **Step 1**: Fetch overseas ranking universe (fluctuation rank + volume rank bonus)
- **Fallback**: Uses static watchlist if ranking API unavailable - **Step 2**: Compute volatility-first score (max of daily change% and intraday range%)
- **Step 3**: Apply liquidity bonus from volume ranking
- **Step 4**: Return top N candidates (default 3)
- **Fallback (overseas only)**: If ranking API is unavailable, uses dynamic universe
from runtime active symbols + recent traded symbols + current holdings (no static watchlist)
- **Realtime mode only**: Daily mode uses batch processing for API efficiency - **Realtime mode only**: Daily mode uses batch processing for API efficiency
**Benefits:** **Benefits:**
- Reduces Gemini API calls from 20-30 stocks to 1-3 qualified candidates - Reduces Gemini API calls from 20-30 stocks to 1-3 qualified candidates
- Fast Python-based filtering before expensive AI judgment - Fast Python-based filtering before expensive AI judgment
- Logs selection context (RSI, volume_ratio, signal, score) for Evolution system - Logs selection context (RSI-compatible proxy, volume_ratio, signal, score) for Evolution system
### 3. Brain (`src/brain/gemini_client.py`) ### 3. Brain (`src/brain/`)
**GeminiClient** — AI decision engine powered by Google Gemini **GeminiClient** (`gemini_client.py`) — AI decision engine powered by Google Gemini
- Constructs structured prompts from market data - Constructs structured prompts from market data
- Parses JSON responses into `TradeDecision` objects (`action`, `confidence`, `rationale`) - Parses JSON responses into `TradeDecision` objects (`action`, `confidence`, `rationale`)
@@ -106,11 +115,20 @@ High-frequency trading with individual stock analysis:
- Falls back to safe HOLD on any parse/API error - Falls back to safe HOLD on any parse/API error
- Handles markdown-wrapped JSON, malformed responses, invalid actions - Handles markdown-wrapped JSON, malformed responses, invalid actions
**PromptOptimizer** (`prompt_optimizer.py`) — Token efficiency optimization
- Reduces prompt size while preserving decision quality
- Caches optimized prompts
**ContextSelector** (`context_selector.py`) — Relevant context selection for prompts
- Selects appropriate context layers for current market conditions
### 4. Risk Manager (`src/core/risk_manager.py`) ### 4. Risk Manager (`src/core/risk_manager.py`)
**RiskManager** — Safety circuit breaker and order validation **RiskManager** — Safety circuit breaker and order validation
⚠️ **READ-ONLY by policy** (see [`docs/agents.md`](./agents.md)) > **READ-ONLY by policy** (see [`docs/agents.md`](./agents.md))
- **Circuit Breaker**: Halts all trading via `SystemExit` when daily P&L drops below -3.0% - **Circuit Breaker**: Halts all trading via `SystemExit` when daily P&L drops below -3.0%
- Threshold may only be made stricter, never relaxed - Threshold may only be made stricter, never relaxed
@@ -118,7 +136,79 @@ High-frequency trading with individual stock analysis:
- **Fat-Finger Protection**: Rejects orders exceeding 30% of available cash - **Fat-Finger Protection**: Rejects orders exceeding 30% of available cash
- Must always be enforced, cannot be disabled - Must always be enforced, cannot be disabled
### 5. Notifications (`src/notifications/telegram_client.py`) ### 5. Strategy (`src/strategy/`)
**Pre-Market Planner** (`pre_market_planner.py`) — AI playbook generation
- Runs before market open (configurable `PRE_MARKET_MINUTES`, default 30)
- Generates scenario-based playbooks via single Gemini API call per market
- Handles timeout (`PLANNER_TIMEOUT_SECONDS`, default 60) with defensive playbook fallback
- Persists playbooks to database for audit trail
**Scenario Engine** (`scenario_engine.py`) — Local scenario matching
- Matches live market data against pre-computed playbook scenarios
- No AI calls during trading hours — pure Python matching logic
- Returns matched scenarios with confidence scores
- Configurable `MAX_SCENARIOS_PER_STOCK` (default 5)
- Periodic rescan at `RESCAN_INTERVAL_SECONDS` (default 300)
**Playbook Store** (`playbook_store.py`) — Playbook persistence
- SQLite-backed storage for daily playbooks
- Date and market-based retrieval
- Status tracking (generated, active, expired)
**Models** (`models.py`) — Pydantic data models
- Scenario, Playbook, MatchResult, and related type definitions
### 6. Context System (`src/context/`)
**Context Store** (`store.py`) — L1-L7 hierarchical memory
- 7-layer context system (see [docs/context-tree.md](./context-tree.md)):
- L1: Tick-level (real-time price)
- L2: Intraday (session summary)
- L3: Daily (end-of-day)
- L4: Weekly (trend analysis)
- L5: Monthly (strategy review)
- L6: Daily Review (scorecard)
- L7: Evolution (long-term learning)
- Key-value storage with timeframe tagging
- SQLite persistence in `contexts` table
**Context Scheduler** (`scheduler.py`) — Periodic aggregation
- Scheduled summarization from lower to higher layers
- Configurable aggregation intervals
**Context Summarizer** (`summarizer.py`) — Layer summarization
- Aggregates lower-layer data into higher-layer summaries
### 7. Dashboard (`src/dashboard/`)
**FastAPI App** (`app.py`) — Read-only monitoring dashboard
- Runs as daemon thread when enabled (`--dashboard` CLI flag or `DASHBOARD_ENABLED=true`)
- Configurable host/port (`DASHBOARD_HOST`, `DASHBOARD_PORT`, default `127.0.0.1:8080`)
- Serves static HTML frontend
**8 API Endpoints:**
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Static HTML dashboard |
| `/api/status` | GET | Daily trading status by market |
| `/api/playbook/{date}` | GET | Playbook for specific date and market |
| `/api/scorecard/{date}` | GET | Daily scorecard from L6_DAILY context |
| `/api/performance` | GET | Trading performance metrics (by market + combined) |
| `/api/context/{layer}` | GET | Query context by layer (L1-L7) |
| `/api/decisions` | GET | Decision log entries with outcomes |
| `/api/scenarios/active` | GET | Today's matched scenarios |
### 8. Notifications (`src/notifications/telegram_client.py`)
**TelegramClient** — Real-time event notifications via Telegram Bot API **TelegramClient** — Real-time event notifications via Telegram Bot API
@@ -126,7 +216,13 @@ High-frequency trading with individual stock analysis:
- Non-blocking: failures are logged but never crash trading - Non-blocking: failures are logged but never crash trading
- Rate-limited: 1 message/second default to respect Telegram API limits - Rate-limited: 1 message/second default to respect Telegram API limits
- Auto-disabled when credentials missing - Auto-disabled when credentials missing
- Gracefully handles API errors, network timeouts, invalid tokens
**TelegramCommandHandler** — Bidirectional command interface
- Long polling from Telegram API (configurable `TELEGRAM_POLLING_INTERVAL`)
- 9 interactive commands: `/help`, `/status`, `/positions`, `/report`, `/scenarios`, `/review`, `/dashboard`, `/stop`, `/resume`
- Authorization filtering by `TELEGRAM_CHAT_ID`
- Enable/disable via `TELEGRAM_COMMANDS_ENABLED` (default: true)
**Notification Types:** **Notification Types:**
- Trade execution (BUY/SELL with confidence) - Trade execution (BUY/SELL with confidence)
@@ -134,12 +230,12 @@ High-frequency trading with individual stock analysis:
- Fat-finger protection triggers (order rejection) - Fat-finger protection triggers (order rejection)
- Market open/close events - Market open/close events
- System startup/shutdown status - System startup/shutdown status
- Playbook generation results
- Stop-loss monitoring alerts
**Setup:** See [src/notifications/README.md](../src/notifications/README.md) for bot creation and configuration. ### 9. Evolution (`src/evolution/`)
### 6. Evolution (`src/evolution/optimizer.py`) **StrategyOptimizer** (`optimizer.py`) — Self-improvement loop
**StrategyOptimizer** — Self-improvement loop
- Analyzes high-confidence losing trades from SQLite - Analyzes high-confidence losing trades from SQLite
- Asks Gemini to generate new `BaseStrategy` subclasses - Asks Gemini to generate new `BaseStrategy` subclasses
@@ -147,8 +243,122 @@ High-frequency trading with individual stock analysis:
- Simulates PR creation for human review - Simulates PR creation for human review
- Only activates strategies that pass all tests - Only activates strategies that pass all tests
**DailyReview** (`daily_review.py`) — End-of-day review
- Generates comprehensive trade performance summary
- Stores results in L6_DAILY context layer
- Tracks win rate, P&L, confidence accuracy
**DailyScorecard** (`scorecard.py`) — Performance scoring
- Calculates daily metrics (trades, P&L, win rate, avg confidence)
- Enables trend tracking across days
**Stop-Loss Monitoring** — Real-time position protection
- Monitors positions against stop-loss levels from playbook scenarios
- Sends Telegram alerts when thresholds approached or breached
### 10. Decision Logger (`src/logging/decision_logger.py`)
**DecisionLogger** — Comprehensive audit trail
- Logs every trading decision with full context snapshot
- Captures input data, rationale, confidence, and outcomes
- Supports outcome tracking (P&L, accuracy) for post-analysis
- Stored in `decision_logs` table with indexed queries
- Review workflow support (reviewed flag, review notes)
### 11. Data Integration (`src/data/`)
**External Data Sources** (optional):
- `news_api.py` — News sentiment data
- `market_data.py` — Extended market data
- `economic_calendar.py` — Economic event calendar
### 12. Backup (`src/backup/`)
**Disaster Recovery** (see [docs/disaster_recovery.md](./disaster_recovery.md)):
- `scheduler.py` — Automated backup scheduling
- `exporter.py` — Data export to various formats
- `cloud_storage.py` — S3-compatible cloud backup
- `health_monitor.py` — Backup integrity verification
## Data Flow ## Data Flow
### Playbook Mode (Daily — Primary v2 Flow)
```
┌─────────────────────────────────────────────────────────────┐
│ Pre-Market Phase (before market open) │
└─────────────────────────────────────────────────────────────┘
┌──────────────────────────────────┐
│ Pre-Market Planner │
│ - 1 Gemini API call per market │
│ - Generate scenario playbook │
│ - Store in playbooks table │
└──────────────────┬───────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Trading Hours (market open → close) │
└─────────────────────────────────────────────────────────────┘
┌──────────────────────────────────┐
│ Market Schedule Check │
│ - Get open markets │
│ - Filter by enabled markets │
└──────────────────┬───────────────┘
┌──────────────────────────────────┐
│ Scenario Engine (local) │
│ - Match live data vs playbook │
│ - No AI calls needed │
│ - Return matched scenarios │
└──────────────────┬───────────────┘
┌──────────────────────────────────┐
│ Risk Manager: Validate Order │
│ - Check circuit breaker │
│ - Check fat-finger limit │
└──────────────────┬───────────────┘
┌──────────────────────────────────┐
│ Broker: Execute Order │
│ - Domestic: send_order() │
│ - Overseas: send_overseas_order()│
└──────────────────┬───────────────┘
┌──────────────────────────────────┐
│ Decision Logger + DB │
│ - Full audit trail │
│ - Context snapshot │
│ - Telegram notification │
└──────────────────┬───────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Post-Market Phase │
└─────────────────────────────────────────────────────────────┘
┌──────────────────────────────────┐
│ Daily Review + Scorecard │
│ - Performance summary │
│ - Store in L6_DAILY context │
│ - Evolution learning │
└──────────────────────────────────┘
```
### Realtime Mode (with Smart Scanner) ### Realtime Mode (with Smart Scanner)
``` ```
@@ -162,35 +372,31 @@ High-frequency trading with individual stock analysis:
│ - Get open markets │ │ - Get open markets │
│ - Filter by enabled markets │ │ - Filter by enabled markets │
│ - Wait if all closed │ │ - Wait if all closed │
└──────────────────┬─────────────── └──────────────────┬───────────────┘
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ Smart Scanner (Python-first) │ │ Smart Scanner (Python-first) │
│ - Fetch volume rankings (KIS) │ - Domestic: fluctuation rank
- Get 20d price history per stock + volume rank bonus
- Calculate RSI(14) + vol ratio + volatility-first scoring
│ - Filter: vol>2x AND RSI extreme │ - Overseas: ranking universe
│ + volatility-first scoring │
│ - Fallback: dynamic universe │
│ - Return top 3 qualified stocks │ │ - Return top 3 qualified stocks │
└──────────────────┬─────────────── └──────────────────┬───────────────┘
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ For Each Qualified Candidate │ │ For Each Qualified Candidate │
└──────────────────┬─────────────── └──────────────────┬───────────────┘
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ Broker: Fetch Market Data │ │ Broker: Fetch Market Data │
│ - Domestic: orderbook + balance │ │ - Domestic: orderbook + balance │
│ - Overseas: price + balance │ │ - Overseas: price + balance │
└──────────────────┬─────────────── └──────────────────┬───────────────┘
┌──────────────────────────────────┐
│ Calculate P&L │
│ pnl_pct = (eval - cost) / cost │
└──────────────────┬────────────────┘
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
@@ -199,47 +405,36 @@ High-frequency trading with individual stock analysis:
│ - Call Gemini API │ │ - Call Gemini API │
│ - Parse JSON response │ │ - Parse JSON response │
│ - Return TradeDecision │ │ - Return TradeDecision │
└──────────────────┬─────────────── └──────────────────┬───────────────┘
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ Risk Manager: Validate Order │ │ Risk Manager: Validate Order │
│ - Check circuit breaker │ │ - Check circuit breaker │
│ - Check fat-finger limit │ │ - Check fat-finger limit │
│ - Raise if validation fails │ └──────────────────┬───────────────┘
└──────────────────┬────────────────┘
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ Broker: Execute Order │ │ Broker: Execute Order │
│ - Domestic: send_order() │ │ - Domestic: send_order() │
│ - Overseas: send_overseas_order()│ │ - Overseas: send_overseas_order()│
└──────────────────┬─────────────── └──────────────────┬───────────────┘
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
Notifications: Send Alert Decision Logger + Notifications
│ - Trade execution notification │ - Log trade to SQLite
│ - Non-blocking (errors logged) │ - selection_context (JSON)
│ - Rate-limited to 1/sec │ - Telegram notification
└──────────────────────────────────┘ └──────────────────────────────────┘
┌──────────────────────────────────┐
│ Database: Log Trade │
│ - SQLite (data/trades.db) │
│ - Track: action, confidence, │
│ rationale, market, exchange │
│ - NEW: selection_context (JSON) │
│ - RSI, volume_ratio, signal │
│ - For Evolution optimization │
└───────────────────────────────────┘
``` ```
## Database Schema ## Database Schema
**SQLite** (`src/db.py`) **SQLite** (`src/db.py`) — Database: `data/trades.db`
### trades
```sql ```sql
CREATE TABLE trades ( CREATE TABLE trades (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -251,25 +446,73 @@ CREATE TABLE trades (
quantity INTEGER, quantity INTEGER,
price REAL, price REAL,
pnl REAL DEFAULT 0.0, pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR', -- KR | US_NASDAQ | JP | etc. market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX', -- KRX | NASD | NYSE | etc. exchange_code TEXT DEFAULT 'KRX',
selection_context TEXT -- JSON: {rsi, volume_ratio, signal, score} selection_context TEXT, -- JSON: {rsi, volume_ratio, signal, score}
decision_id TEXT -- Links to decision_logs
); );
``` ```
**Selection Context** (new in v0.9.0): Stores scanner selection criteria as JSON: ### contexts
```json ```sql
{ CREATE TABLE contexts (
"rsi": 28.5, id INTEGER PRIMARY KEY AUTOINCREMENT,
"volume_ratio": 2.7, layer TEXT NOT NULL, -- L1 through L7
"signal": "oversold", timeframe TEXT,
"score": 85.2 key TEXT NOT NULL,
} value TEXT NOT NULL, -- JSON data
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Indices: idx_contexts_layer, idx_contexts_timeframe, idx_contexts_updated
``` ```
Enables Evolution system to analyze correlation between selection criteria and trade outcomes. ### decision_logs
```sql
CREATE TABLE decision_logs (
decision_id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
stock_code TEXT,
market TEXT,
exchange_code TEXT,
action TEXT,
confidence INTEGER,
rationale TEXT,
context_snapshot TEXT, -- JSON: full context at decision time
input_data TEXT, -- JSON: market data used
outcome_pnl REAL,
outcome_accuracy REAL,
reviewed INTEGER DEFAULT 0,
review_notes TEXT
);
-- Indices: idx_decision_logs_timestamp, idx_decision_logs_reviewed, idx_decision_logs_confidence
```
Auto-migration: Adds `market`, `exchange_code`, and `selection_context` columns if missing for backward compatibility. ### playbooks
```sql
CREATE TABLE playbooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
market TEXT NOT NULL,
status TEXT DEFAULT 'generated',
playbook_json TEXT NOT NULL, -- Full playbook with scenarios
generated_at TEXT NOT NULL,
token_count INTEGER,
scenario_count INTEGER,
match_count INTEGER DEFAULT 0
);
-- Indices: idx_playbooks_date, idx_playbooks_market
```
### context_metadata
```sql
CREATE TABLE context_metadata (
layer TEXT PRIMARY KEY,
description TEXT,
retention_days INTEGER,
aggregation_source TEXT
);
```
## Configuration ## Configuration
@@ -284,29 +527,81 @@ KIS_APP_SECRET=your_app_secret
KIS_ACCOUNT_NO=XXXXXXXX-XX KIS_ACCOUNT_NO=XXXXXXXX-XX
GEMINI_API_KEY=your_gemini_key GEMINI_API_KEY=your_gemini_key
# Optional # Optional — Trading Mode
MODE=paper # paper | live MODE=paper # paper | live
DB_PATH=data/trades.db
CONFIDENCE_THRESHOLD=80
MAX_LOSS_PCT=3.0
MAX_ORDER_PCT=30.0
ENABLED_MARKETS=KR,US_NASDAQ # Comma-separated market codes
# Trading Mode (API efficiency)
TRADE_MODE=daily # daily | realtime TRADE_MODE=daily # daily | realtime
DAILY_SESSIONS=4 # Sessions per day (daily mode only) DAILY_SESSIONS=4 # Sessions per day (daily mode only)
SESSION_INTERVAL_HOURS=6 # Hours between sessions (daily mode only) SESSION_INTERVAL_HOURS=6 # Hours between sessions (daily mode only)
# Telegram Notifications (optional) # Optional — Database
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz DB_PATH=data/trades.db
TELEGRAM_CHAT_ID=123456789
TELEGRAM_ENABLED=true
# Smart Scanner (optional, realtime mode only) # Optional — Risk
CONFIDENCE_THRESHOLD=80
MAX_LOSS_PCT=3.0
MAX_ORDER_PCT=30.0
# Optional — Markets
ENABLED_MARKETS=KR,US # Comma-separated market codes
RATE_LIMIT_RPS=2.0 # KIS API requests per second
# Optional — Pre-Market Planner (v2)
PRE_MARKET_MINUTES=30 # Minutes before market open to generate playbook
MAX_SCENARIOS_PER_STOCK=5 # Max scenarios per stock in playbook
PLANNER_TIMEOUT_SECONDS=60 # Timeout for playbook generation
DEFENSIVE_PLAYBOOK_ON_FAILURE=true # Fallback on AI failure
RESCAN_INTERVAL_SECONDS=300 # Scenario rescan interval during trading
# Optional — Smart Scanner (realtime mode only)
RSI_OVERSOLD_THRESHOLD=30 # 0-50, oversold threshold RSI_OVERSOLD_THRESHOLD=30 # 0-50, oversold threshold
RSI_MOMENTUM_THRESHOLD=70 # 50-100, momentum threshold RSI_MOMENTUM_THRESHOLD=70 # 50-100, momentum threshold
VOL_MULTIPLIER=2.0 # Minimum volume ratio (2.0 = 200%) VOL_MULTIPLIER=2.0 # Minimum volume ratio (2.0 = 200%)
SCANNER_TOP_N=3 # Max qualified candidates per scan SCANNER_TOP_N=3 # Max qualified candidates per scan
# Optional — Dashboard
DASHBOARD_ENABLED=false # Enable FastAPI dashboard
DASHBOARD_HOST=127.0.0.1 # Dashboard bind address
DASHBOARD_PORT=8080 # Dashboard port (1-65535)
# Optional — Telegram
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
TELEGRAM_CHAT_ID=123456789
TELEGRAM_ENABLED=true
TELEGRAM_COMMANDS_ENABLED=true # Enable bidirectional commands
TELEGRAM_POLLING_INTERVAL=1.0 # Command polling interval (seconds)
# Optional — Backup
BACKUP_ENABLED=false
BACKUP_DIR=data/backups
S3_ENDPOINT_URL=...
S3_ACCESS_KEY=...
S3_SECRET_KEY=...
S3_BUCKET_NAME=...
S3_REGION=...
# Optional — External Data
NEWS_API_KEY=...
NEWS_API_PROVIDER=...
MARKET_DATA_API_KEY=...
# Position Sizing (optional)
POSITION_SIZING_ENABLED=true
POSITION_BASE_ALLOCATION_PCT=5.0
POSITION_MIN_ALLOCATION_PCT=1.0
POSITION_MAX_ALLOCATION_PCT=10.0
POSITION_VOLATILITY_TARGET_SCORE=50.0
# Legacy/compat scanner thresholds (kept for backward compatibility)
RSI_OVERSOLD_THRESHOLD=30
RSI_MOMENTUM_THRESHOLD=70
VOL_MULTIPLIER=2.0
# Overseas Ranking API (optional override; account-dependent)
OVERSEAS_RANKING_ENABLED=true
OVERSEAS_RANKING_FLUCT_TR_ID=HHDFS76200100
OVERSEAS_RANKING_VOLUME_TR_ID=HHDFS76200200
OVERSEAS_RANKING_FLUCT_PATH=/uapi/overseas-price/v1/quotations/inquire-updown-rank
OVERSEAS_RANKING_VOLUME_PATH=/uapi/overseas-price/v1/quotations/inquire-volume-rank
``` ```
Tests use in-memory SQLite (`DB_PATH=":memory:"`) and dummy credentials via `tests/conftest.py`. Tests use in-memory SQLite (`DB_PATH=":memory:"`) and dummy credentials via `tests/conftest.py`.
@@ -340,4 +635,9 @@ Tests use in-memory SQLite (`DB_PATH=":memory:"`) and dummy credentials via `tes
- Invalid token → log error, trading unaffected - Invalid token → log error, trading unaffected
- Rate limit exceeded → queued via rate limiter - Rate limit exceeded → queued via rate limiter
**Guarantee**: Notification failures never interrupt trading operations. ### Playbook Generation Failure
- Timeout → fall back to defensive playbook (`DEFENSIVE_PLAYBOOK_ON_FAILURE`)
- API error → use previous day's playbook if available
- No playbook → skip pre-market phase, fall back to direct AI calls
**Guarantee**: Notification and dashboard failures never interrupt trading operations.

View File

@@ -119,7 +119,7 @@ No decorator needed for async tests.
# Install all dependencies (production + dev) # Install all dependencies (production + dev)
pip install -e ".[dev]" pip install -e ".[dev]"
# Run full test suite with coverage # Run full test suite with coverage (551 tests across 25 files)
pytest -v --cov=src --cov-report=term-missing pytest -v --cov=src --cov-report=term-missing
# Run a single test file # Run a single test file
@@ -137,11 +137,82 @@ mypy src/ --strict
# Run the trading agent # Run the trading agent
python -m src.main --mode=paper python -m src.main --mode=paper
# Run with dashboard enabled
python -m src.main --mode=paper --dashboard
# Docker # Docker
docker compose up -d ouroboros # Run agent docker compose up -d ouroboros # Run agent
docker compose --profile test up test # Run tests in container docker compose --profile test up test # Run tests in container
``` ```
## Dashboard
The FastAPI dashboard provides read-only monitoring of the trading system.
### Starting the Dashboard
```bash
# Via CLI flag
python -m src.main --mode=paper --dashboard
# Via environment variable
DASHBOARD_ENABLED=true python -m src.main --mode=paper
```
Dashboard runs as a daemon thread on `DASHBOARD_HOST:DASHBOARD_PORT` (default: `127.0.0.1:8080`).
### API Endpoints
| Endpoint | Description |
|----------|-------------|
| `GET /` | HTML dashboard UI |
| `GET /api/status` | Daily trading status by market |
| `GET /api/playbook/{date}` | Playbook for specific date (query: `market`) |
| `GET /api/scorecard/{date}` | Daily scorecard from L6_DAILY context |
| `GET /api/performance` | Performance metrics by market and combined |
| `GET /api/context/{layer}` | Context data by layer L1-L7 (query: `timeframe`) |
| `GET /api/decisions` | Decision log entries (query: `limit`, `market`) |
| `GET /api/scenarios/active` | Today's matched scenarios |
## Telegram Commands
When `TELEGRAM_COMMANDS_ENABLED=true` (default), the bot accepts these interactive commands:
| Command | Description |
|---------|-------------|
| `/help` | List available commands |
| `/status` | Show trading status (mode, markets, P&L) |
| `/positions` | Display account summary (balance, cash, P&L) |
| `/report` | Daily summary metrics (trades, P&L, win rate) |
| `/scenarios` | Show today's playbook scenarios |
| `/review` | Display recent scorecards (L6_DAILY layer) |
| `/dashboard` | Show dashboard URL if enabled |
| `/stop` | Pause trading |
| `/resume` | Resume trading |
Commands are only processed from the authorized `TELEGRAM_CHAT_ID`.
## KIS API TR_ID 참조 문서
**TR_ID를 추가하거나 수정할 때 반드시 공식 문서를 먼저 확인할 것.**
공식 문서: `docs/한국투자증권_오픈API_전체문서_20260221_030000.xlsx`
> ⚠️ 커뮤니티 블로그, GitHub 예제 등 비공식 자료의 TR_ID는 오래되거나 틀릴 수 있음.
> 실제로 `VTTT1006U`(미국 매도 — 잘못됨)가 오랫동안 코드에 남아있던 사례가 있음 (Issue #189).
### 주요 TR_ID 목록
| 구분 | 모의투자 TR_ID | 실전투자 TR_ID | 시트명 |
|------|---------------|---------------|--------|
| 해외주식 매수 (미국) | `VTTT1002U` | `TTTT1002U` | 해외주식 주문 |
| 해외주식 매도 (미국) | `VTTT1001U` | `TTTT1006U` | 해외주식 주문 |
새로운 TR_ID가 필요할 때:
1. 위 xlsx 파일에서 해당 거래 유형의 시트를 찾는다.
2. 모의투자(`VTTT`) / 실전투자(`TTTT`) 컬럼을 구분하여 정확한 값을 사용한다.
3. 코드에 출처 주석을 남긴다: `# Source: 한국투자증권_오픈API_전체문서 — '<시트명>' 시트`
## Environment Setup ## Environment Setup
```bash ```bash

View File

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

View File

@@ -7,6 +7,32 @@
--- ---
## 2026-02-21
### 거래 상태 확인 중 발견된 버그 (#187)
- 거래 상태 점검 요청 → SELL 주문(손절/익절)이 Fat Finger에 막혀 전혀 실행 안 됨 발견
- **#187 (Critical)**: SELL 주문에서 Fat Finger 오탐 — `order_amount/total_cash > 30%`가 SELL에도 적용되어 대형 포지션 매도 불가
- JELD stop-loss -6.20% → 차단, RXT take-profit +46.13% → 차단
- 수정: SELL은 `check_circuit_breaker`만 호출, `validate_order`(Fat Finger 포함) 미호출
---
## 2026-02-20
### 지속적 모니터링 및 개선점 도출 (이슈 #178~#182)
- Dashboard 포함해서 실행하며 간헐적 문제 모니터링 및 개선점 자동 도출 요청
- 모니터링 결과 발견된 이슈 목록:
- **#178**: uvicorn 미설치 → dashboard 미작동 + 오해의 소지 있는 시작 로그 → uvicorn 설치 완료
- **#179 (Critical)**: 잔액 부족 주문 실패 후 매 사이클마다 무한 재시도 (MLECW 20분 이상 반복)
- **#180**: 다중 인스턴스 실행 시 Telegram 409 충돌
- **#181**: implied_rsi 공식 포화 문제 (change_rate≥12.5% → RSI=100)
- **#182 (Critical)**: 보유 종목이 SmartScanner 변동성 필터에 걸려 SELL 신호 미생성 → SELL 체결 0건, 잔고 소진
- 요구사항: 모니터링 자동화 및 주기적 개선점 리포트 도출
---
## 2026-02-05 ## 2026-02-05
### API 효율화 ### API 효율화
@@ -86,3 +112,246 @@
- Plan Consistency (필수), Safety & Constraints, Quality, Workflow 4개 카테고리 - Plan Consistency (필수), Safety & Constraints, Quality, Workflow 4개 카테고리
**이슈/PR:** #114 **이슈/PR:** #114
---
## 2026-02-16
### 문서 v2 동기화 (전체 문서 현행화)
**배경:**
- v2 기능 구현 완료 후 문서가 실제 코드 상태와 크게 괴리
- 문서에는 54 tests / 4 files로 기록되었으나 실제로는 551 tests / 25 files
- v2 핵심 기능(Playbook, Scenario Engine, Dashboard, Telegram Commands, Daily Review, Context System, Backup) 문서화 누락
**요구사항:**
1. `docs/testing.md` — 551 tests / 25 files 반영, 전체 테스트 파일 설명
2. `docs/architecture.md` — v2 컴포넌트(Strategy, Context, Dashboard, Decision Logger 등) 추가, Playbook Mode 데이터 플로우, DB 스키마 5개 테이블, v2 환경변수
3. `docs/commands.md` — Dashboard 실행 명령어, Telegram 명령어 9종 레퍼런스
4. `CLAUDE.md` — Project Structure 트리 확장, 테스트 수 업데이트, `--dashboard` 플래그
5. `docs/skills.md` — DB 파일명 `trades.db`로 통일, Dashboard 명령어 추가
6. 기존에 유효한 트러블슈팅, 코드 예제 등은 유지
**구현 결과:**
- 6개 문서 파일 업데이트
- 이전 시도(2개 커밋)는 기존 내용을 과도하게 삭제하여 폐기, main 기준으로 재작업
**이슈/PR:** #131, PR #134
### 해외 스캐너 개선: 랭킹 연동 + 변동성 우선 선별
**배경:**
- `run_overnight` 실운영에서 미국장 동안 거래가 0건 지속
- 원인: 해외 시장에서도 국내 랭킹/일봉 API 경로를 사용하던 구조적 불일치
**요구사항:**
1. 해외 시장도 랭킹 API 기반 유니버스 탐색 지원
2. 단순 상승률/거래대금 상위가 아니라, **변동성이 큰 종목**을 우선 선별
3. 고정 티커 fallback 금지
**구현 결과:**
- `src/broker/overseas.py`
- `fetch_overseas_rankings()` 추가 (fluctuation / volume)
- 해외 랭킹 API 경로/TR_ID를 설정값으로 오버라이드 가능하게 구현
- `src/analysis/smart_scanner.py`
- market-aware 스캔(국내/해외 분리)
- 해외: 랭킹 API 유니버스 + 변동성 우선 점수(일변동률 vs 장중 고저폭)
- 거래대금/거래량 랭킹은 유동성 보정 점수로 활용
- 랭킹 실패 시에는 동적 유니버스(active/recent/holdings)만 사용
- `src/config.py`
- `OVERSEAS_RANKING_*` 설정 추가
**효과:**
- 해외 시장에서 스캐너 후보 0개로 정지되는 상황 완화
- 종목 선정 기준이 단순 상승률 중심에서 변동성 중심으로 개선
- 고정 티커 없이도 시장 주도 변동 종목 탐지 가능
### 국내 스캐너/주문수량 정렬: 변동성 우선 + 리스크 타기팅
**배경:**
- 해외만 변동성 우선으로 동작하고, 국내는 RSI/거래량 필터 중심으로 동작해 시장 간 전략 일관성이 낮았음
- 매수 수량이 고정 1주라서 변동성 구간별 익스포저 관리가 어려웠음
**요구사항:**
1. 국내 스캐너도 변동성 우선 선별로 해외와 통일
2. 고변동 종목일수록 포지션 크기를 줄이는 수량 산식 적용
**구현 결과:**
- `src/analysis/smart_scanner.py`
- 국내: `fluctuation ranking + volume ranking bonus` 기반 점수화로 전환
- 점수는 `max(abs(change_rate), intraday_range_pct)` 중심으로 계산
- 국내 랭킹 응답 스키마 키(`price`, `change_rate`, `volume`) 파싱 보강
- `src/main.py`
- `_determine_order_quantity()` 추가
- BUY 시 변동성 점수 기반 동적 수량 산정 적용
- `trading_cycle`, `run_daily_session` 경로 모두 동일 수량 로직 사용
- `src/config.py`
- `POSITION_SIZING_*` 설정 추가
**효과:**
- 국내/해외 스캐너 기준이 변동성 중심으로 일관화
- 고변동 구간에서 자동 익스포저 축소, 저변동 구간에서 과소진입 완화
## 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
---
## 2026-02-23
### 국내주식 지정가 전환 및 미체결 처리 (#232)
**배경:**
- 해외주식은 #211에서 지정가로 전환했으나 국내주식은 여전히 `price=0` (시장가)
- KRX도 지정가 주문 사용 시 동일한 미체결 위험이 존재
- 지정가 전환 + 미체결 처리를 함께 구현
**구현 내용:**
1. `src/broker/kis_api.py`
- `get_domestic_pending_orders()`: 모의 즉시 `[]`, 실전 `TTTC0084R` GET
- `cancel_domestic_order()`: 실전 `TTTC0013U` / 모의 `VTTC0013U`, hashkey 필수
2. `src/main.py`
- import `kr_round_down` 추가
- `trading_cycle`, `run_daily_session` 국내 주문 `price=0` → 지정가:
BUY +0.2% / SELL -0.2%, `kr_round_down` KRX 틱 반올림 적용
- `handle_domestic_pending_orders` 함수: BUY→취소+쿨다운, SELL→취소+재주문(-0.4%, 최대1회)
- daily/realtime 두 모드에서 domestic pending 체크 호출 추가
3. 테스트 14개 추가:
- `TestGetDomesticPendingOrders` (3), `TestCancelDomesticOrder` (5)
- `TestHandleDomesticPendingOrders` (4), `TestDomesticLimitOrderPrice` (2)
**이슈/PR:** #232, PR #233
---
## 2026-02-24
### 해외잔고 ghost position 수정 — '모의투자 잔고내역이 없습니다' 반복 방지 (#235)
**배경:**
- 모의투자 실행 시 MLECW, KNRX, NBY, SNSE 등 만료/정지된 종목에 대해
`모의투자 잔고내역이 없습니다` 오류가 매 사이클 반복됨
**근본 원인:**
1. `ovrs_cblc_qty` (해외잔고수량, 총 보유) vs `ord_psbl_qty` (주문가능수량, 실제 매도 가능)
- 기존 코드: `ovrs_cblc_qty` 우선 사용 → 만료 Warrant가 `ovrs_cblc_qty=289456`이지만 실제 `ord_psbl_qty=0`
- startup sync / build_overseas_symbol_universe가 이 종목들을 포지션으로 기록
2. SELL 실패 시 DB 포지션이 닫히지 않아 다음 사이클에서도 재시도 (무한 반복)
**구현 내용:**
1. `src/main.py``_extract_held_codes_from_balance`, `_extract_held_qty_from_balance`
- 해외 잔고 필드 우선순위 변경: `ord_psbl_qty``ovrs_cblc_qty``hldg_qty` (fallback 유지)
- KIS 공식 문서(VTTS3012R) 기준: `ord_psbl_qty`가 실제 매도 가능 수량
2. `src/main.py``trading_cycle` ghost-close 처리
- 해외 SELL이 `잔고내역이 없습니다`로 실패 시 DB 포지션을 `[ghost-close]` SELL로 종료
- exchange code 불일치 등 예외 상황에서 무한 반복 방지
3. 테스트 7개 추가:
- `TestExtractHeldQtyFromBalance` 3개: ord_psbl_qty 우선, 0이면 0 반환, fallback
- `TestExtractHeldCodesFromBalance` 2개: ord_psbl_qty=0인 종목 제외, fallback
- `TestOverseasGhostPositionClose` 2개: ghost-close 로그 확인, 일반 오류 무시
**이슈/PR:** #235, PR #236

View File

@@ -34,6 +34,12 @@ python -m src.main --mode=paper
``` ```
Runs the agent in paper-trading mode (no real orders). Runs the agent in paper-trading mode (no real orders).
### Start Trading Agent with Dashboard
```bash
python -m src.main --mode=paper --dashboard
```
Runs the agent with FastAPI dashboard on `127.0.0.1:8080` (configurable via `DASHBOARD_HOST`/`DASHBOARD_PORT`).
### Start Trading Agent (Production) ### Start Trading Agent (Production)
```bash ```bash
docker compose up -d ouroboros docker compose up -d ouroboros
@@ -59,7 +65,7 @@ Analyze the last 30 days of trade logs and generate performance metrics.
python -m src.evolution.optimizer --evolve python -m src.evolution.optimizer --evolve
``` ```
Triggers the evolution engine to: Triggers the evolution engine to:
1. Analyze `trade_logs.db` for failing patterns 1. Analyze `trades.db` for failing patterns
2. Ask Gemini to generate a new strategy 2. Ask Gemini to generate a new strategy
3. Run tests on the new strategy 3. Run tests on the new strategy
4. Create a PR if tests pass 4. Create a PR if tests pass
@@ -91,12 +97,12 @@ curl http://localhost:8080/health
### View Trade Logs ### View Trade Logs
```bash ```bash
sqlite3 data/trade_logs.db "SELECT * FROM trades ORDER BY timestamp DESC LIMIT 20;" sqlite3 data/trades.db "SELECT * FROM trades ORDER BY timestamp DESC LIMIT 20;"
``` ```
### Export Trade History ### Export Trade History
```bash ```bash
sqlite3 -header -csv data/trade_logs.db "SELECT * FROM trades;" > trades_export.csv sqlite3 -header -csv data/trades.db "SELECT * FROM trades;" > trades_export.csv
``` ```
## Safety Checklist (Pre-Deploy) ## Safety Checklist (Pre-Deploy)

View File

@@ -2,51 +2,29 @@
## Test Structure ## Test Structure
**54 tests** across four files. `asyncio_mode = "auto"` in pyproject.toml — async tests need no special decorator. **551 tests** across **25 files**. `asyncio_mode = "auto"` in pyproject.toml — async tests need no special decorator.
The `settings` fixture in `conftest.py` provides safe defaults with test credentials and in-memory DB. The `settings` fixture in `conftest.py` provides safe defaults with test credentials and in-memory DB.
### Test Files ### Test Files
#### `tests/test_risk.py` (11 tests) #### Core Components
- Circuit breaker boundaries
- Fat-finger edge cases ##### `tests/test_risk.py` (14 tests)
- Circuit breaker boundaries and exact threshold triggers
- Fat-finger edge cases and percentage validation
- P&L calculation edge cases - P&L calculation edge cases
- Order validation logic - Order validation logic
**Example:** ##### `tests/test_broker.py` (11 tests)
```python
def test_circuit_breaker_exact_threshold(risk_manager):
"""Circuit breaker should trip at exactly -3.0%."""
with pytest.raises(CircuitBreakerTripped):
risk_manager.validate_order(
current_pnl_pct=-3.0,
order_amount=1000,
total_cash=10000
)
```
#### `tests/test_broker.py` (6 tests)
- OAuth token lifecycle - OAuth token lifecycle
- Rate limiting enforcement - Rate limiting enforcement
- Hash key generation - Hash key generation
- Network error handling - Network error handling
- SSL context configuration - SSL context configuration
**Example:** ##### `tests/test_brain.py` (24 tests)
```python - Valid JSON parsing and markdown-wrapped JSON handling
async def test_rate_limiter(broker):
"""Rate limiter should delay requests to stay under 10 RPS."""
start = time.monotonic()
for _ in range(15): # 15 requests
await broker._rate_limiter.acquire()
elapsed = time.monotonic() - start
assert elapsed >= 1.0 # Should take at least 1 second
```
#### `tests/test_brain.py` (18 tests)
- Valid JSON parsing
- Markdown-wrapped JSON handling
- Malformed JSON fallback - Malformed JSON fallback
- Missing fields handling - Missing fields handling
- Invalid action validation - Invalid action validation
@@ -54,33 +32,143 @@ async def test_rate_limiter(broker):
- Empty response handling - Empty response handling
- Prompt construction for different markets - Prompt construction for different markets
**Example:** ##### `tests/test_market_schedule.py` (24 tests)
```python
async def test_confidence_below_threshold_forces_hold(brain):
"""Decisions below confidence threshold should force HOLD."""
decision = brain.parse_response('{"action":"BUY","confidence":70,"rationale":"test"}')
assert decision.action == "HOLD"
assert decision.confidence == 70
```
#### `tests/test_market_schedule.py` (19 tests)
- Market open/close logic - Market open/close logic
- Timezone handling (UTC, Asia/Seoul, America/New_York, etc.) - Timezone handling (UTC, Asia/Seoul, America/New_York, etc.)
- DST (Daylight Saving Time) transitions - DST (Daylight Saving Time) transitions
- Weekend handling - Weekend handling and lunch break logic
- Lunch break logic
- Multiple market filtering - Multiple market filtering
- Next market open calculation - Next market open calculation
**Example:** ##### `tests/test_db.py` (3 tests)
```python - Database initialization and table creation
def test_is_market_open_during_trading_hours(): - Trade logging with all fields (market, exchange_code, decision_id)
"""Market should be open during regular trading hours.""" - Query and retrieval operations
# KRX: 9:00-15:30 KST, no lunch break
market = MARKETS["KR"] ##### `tests/test_main.py` (37 tests)
trading_time = datetime(2026, 2, 3, 10, 0, tzinfo=ZoneInfo("Asia/Seoul")) # Monday 10:00 - Trading loop orchestration
assert is_market_open(market, trading_time) is True - Market iteration and stock processing
``` - Dashboard integration (`--dashboard` flag)
- Telegram command handler wiring
- Error handling and graceful shutdown
#### Strategy & Playbook (v2)
##### `tests/test_pre_market_planner.py` (37 tests)
- Pre-market playbook generation
- Gemini API integration for scenario creation
- Timeout handling and defensive playbook fallback
- Multi-market playbook generation
##### `tests/test_scenario_engine.py` (44 tests)
- Scenario matching against live market data
- Confidence scoring and threshold filtering
- Multiple scenario type handling
- Edge cases (no match, partial match, expired scenarios)
##### `tests/test_playbook_store.py` (23 tests)
- Playbook persistence to SQLite
- Date-based retrieval and market filtering
- Playbook status management (generated, active, expired)
- JSON serialization/deserialization
##### `tests/test_strategy_models.py` (33 tests)
- Pydantic model validation for scenarios, playbooks, decisions
- Field constraints and default values
- Serialization round-trips
#### Analysis & Scanning
##### `tests/test_volatility.py` (24 tests)
- ATR and RSI calculation accuracy
- Volume surge ratio computation
- Momentum scoring
- Breakout/breakdown pattern detection
- Market scanner watchlist management
##### `tests/test_smart_scanner.py` (13 tests)
- Python-first filtering pipeline
- RSI and volume ratio filter logic
- Candidate scoring and ranking
- Fallback to static watchlist
#### Context & Memory
##### `tests/test_context.py` (18 tests)
- L1-L7 layer storage and retrieval
- Context key-value CRUD operations
- Timeframe-based queries
- Layer metadata management
##### `tests/test_context_scheduler.py` (5 tests)
- Periodic context aggregation scheduling
- Layer summarization triggers
#### Evolution & Review
##### `tests/test_evolution.py` (24 tests)
- Strategy optimization loop
- High-confidence losing trade analysis
- Generated strategy validation
##### `tests/test_daily_review.py` (10 tests)
- End-of-day review generation
- Trade performance summarization
- Context layer (L6_DAILY) integration
##### `tests/test_scorecard.py` (3 tests)
- Daily scorecard metrics calculation
- Win rate, P&L, confidence tracking
#### Notifications & Commands
##### `tests/test_telegram.py` (25 tests)
- Message sending and formatting
- Rate limiting (leaky bucket)
- Error handling (network timeout, invalid token)
- Auto-disable on missing credentials
- Notification types (trade, circuit breaker, fat-finger, market events)
##### `tests/test_telegram_commands.py` (31 tests)
- 9 command handlers (/help, /status, /positions, /report, /scenarios, /review, /dashboard, /stop, /resume)
- Long polling and command dispatch
- Authorization filtering by chat_id
- Command response formatting
#### Dashboard
##### `tests/test_dashboard.py` (14 tests)
- FastAPI endpoint responses (8 API routes)
- Status, playbook, scorecard, performance, context, decisions, scenarios
- Query parameter handling (market, date, limit)
#### Performance & Quality
##### `tests/test_token_efficiency.py` (34 tests)
- Gemini token usage optimization
- Prompt size reduction verification
- Cache effectiveness
##### `tests/test_latency_control.py` (30 tests)
- API call latency measurement
- Rate limiter timing accuracy
- Async operation overhead
##### `tests/test_decision_logger.py` (9 tests)
- Decision audit trail completeness
- Context snapshot capture
- Outcome tracking (P&L, accuracy)
##### `tests/test_data_integration.py` (38 tests)
- External data source integration
- News API, market data, economic calendar
- Error handling for API failures
##### `tests/test_backup.py` (23 tests)
- Backup scheduler and execution
- Cloud storage (S3) upload
- Health monitoring
- Data export functionality
## Coverage Requirements ## Coverage Requirements
@@ -91,20 +179,6 @@ Check coverage:
pytest -v --cov=src --cov-report=term-missing pytest -v --cov=src --cov-report=term-missing
``` ```
Expected output:
```
Name Stmts Miss Cover Missing
-----------------------------------------------------------
src/brain/gemini_client.py 85 5 94% 165-169
src/broker/kis_api.py 120 12 90% ...
src/core/risk_manager.py 35 2 94% ...
src/db.py 25 1 96% ...
src/main.py 150 80 47% (excluded from CI)
src/markets/schedule.py 95 3 97% ...
-----------------------------------------------------------
TOTAL 510 103 80%
```
**Note:** `main.py` has lower coverage as it contains the main loop which is tested via integration/manual testing. **Note:** `main.py` has lower coverage as it contains the main loop which is tested via integration/manual testing.
## Test Configuration ## Test Configuration

View File

@@ -10,6 +10,7 @@ dependencies = [
"google-genai>=1.0,<2", "google-genai>=1.0,<2",
"scipy>=1.11,<2", "scipy>=1.11,<2",
"fastapi>=0.110,<1", "fastapi>=0.110,<1",
"uvicorn>=0.29,<1",
] ]
[project.optional-dependencies] [project.optional-dependencies]

54
scripts/morning_report.sh Executable file
View File

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

87
scripts/run_overnight.sh Executable file
View File

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

76
scripts/stop_overnight.sh Executable file
View File

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

42
scripts/watchdog.sh Executable file
View File

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

View File

@@ -1,8 +1,4 @@
"""Smart Volatility Scanner with RSI and volume filters. """Smart Volatility Scanner with volatility-first market ranking logic."""
Fetches market rankings from KIS API and applies technical filters
to identify high-probability trading candidates.
"""
from __future__ import annotations from __future__ import annotations
@@ -12,7 +8,9 @@ from typing import Any
from src.analysis.volatility import VolatilityAnalyzer from src.analysis.volatility import VolatilityAnalyzer
from src.broker.kis_api import KISBroker from src.broker.kis_api import KISBroker
from src.broker.overseas import OverseasBroker
from src.config import Settings from src.config import Settings
from src.markets.schedule import MarketInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,19 +30,19 @@ class ScanCandidate:
class SmartVolatilityScanner: class SmartVolatilityScanner:
"""Scans market rankings and applies RSI/volume filters. """Scans market rankings and applies volatility-first filters.
Flow: Flow:
1. Fetch volume rankings from KIS API 1. Fetch fluctuation rankings as primary universe
2. For each ranked stock, fetch daily prices 2. Fetch volume rankings for liquidity bonus
3. Calculate RSI and volume ratio 3. Score by volatility first, liquidity second
4. Apply filters: volume > VOL_MULTIPLIER AND (RSI < 30 OR RSI > 70) 4. Return top N qualified candidates
5. Return top N qualified candidates
""" """
def __init__( def __init__(
self, self,
broker: KISBroker, broker: KISBroker,
overseas_broker: OverseasBroker | None,
volatility_analyzer: VolatilityAnalyzer, volatility_analyzer: VolatilityAnalyzer,
settings: Settings, settings: Settings,
) -> None: ) -> None:
@@ -56,6 +54,7 @@ class SmartVolatilityScanner:
settings: Application settings settings: Application settings
""" """
self.broker = broker self.broker = broker
self.overseas_broker = overseas_broker
self.analyzer = volatility_analyzer self.analyzer = volatility_analyzer
self.settings = settings self.settings = settings
@@ -67,108 +66,130 @@ class SmartVolatilityScanner:
async def scan( async def scan(
self, self,
market: MarketInfo | None = None,
fallback_stocks: list[str] | None = None, fallback_stocks: list[str] | None = None,
) -> list[ScanCandidate]: ) -> list[ScanCandidate]:
"""Execute smart scan and return qualified candidates. """Execute smart scan and return qualified candidates.
Args: Args:
market: Target market info (domestic vs overseas behavior)
fallback_stocks: Stock codes to use if ranking API fails fallback_stocks: Stock codes to use if ranking API fails
Returns: Returns:
List of ScanCandidate, sorted by score, up to top_n items List of ScanCandidate, sorted by score, up to top_n items
""" """
# Step 1: Fetch rankings if market and not market.is_domestic:
return await self._scan_overseas(market, fallback_stocks)
return await self._scan_domestic(fallback_stocks)
async def _scan_domestic(
self,
fallback_stocks: list[str] | None = None,
) -> list[ScanCandidate]:
"""Scan domestic market using volatility-first ranking + liquidity bonus."""
# 1) Primary universe from fluctuation ranking.
try: try:
rankings = await self.broker.fetch_market_rankings( fluct_rows = await self.broker.fetch_market_rankings(
ranking_type="volume", ranking_type="fluctuation",
limit=30, # Fetch more than needed for filtering limit=50,
) )
logger.info("Fetched %d stocks from volume rankings", len(rankings))
except ConnectionError as exc: except ConnectionError as exc:
logger.warning("Ranking API failed, using fallback: %s", exc) logger.warning("Domestic fluctuation ranking failed: %s", exc)
if fallback_stocks: fluct_rows = []
# Create minimal ranking data for fallback
rankings = [ # 2) Liquidity bonus from volume ranking.
try:
volume_rows = await self.broker.fetch_market_rankings(
ranking_type="volume",
limit=50,
)
except ConnectionError as exc:
logger.warning("Domestic volume ranking failed: %s", exc)
volume_rows = []
if not fluct_rows and fallback_stocks:
logger.info(
"Domestic ranking unavailable; using fallback symbols (%d)",
len(fallback_stocks),
)
fluct_rows = [
{ {
"stock_code": code, "stock_code": code,
"name": code, "name": code,
"price": 0, "price": 0.0,
"volume": 0, "volume": 0.0,
"change_rate": 0, "change_rate": 0.0,
"volume_increase_rate": 0, "volume_increase_rate": 0.0,
} }
for code in fallback_stocks for code in fallback_stocks
] ]
else:
if not fluct_rows:
return [] return []
# Step 2: Analyze each stock volume_rank_bonus: dict[str, float] = {}
candidates: list[ScanCandidate] = [] for idx, row in enumerate(volume_rows):
code = _extract_stock_code(row)
if not code:
continue
volume_rank_bonus[code] = max(0.0, 15.0 - idx * 0.3)
for stock in rankings: candidates: list[ScanCandidate] = []
stock_code = stock["stock_code"] for stock in fluct_rows:
stock_code = _extract_stock_code(stock)
if not stock_code: if not stock_code:
continue continue
try: try:
# Fetch daily prices for RSI calculation price = _extract_last_price(stock)
daily_prices = await self.broker.get_daily_prices(stock_code, days=20) change_rate = _extract_change_rate_pct(stock)
volume = _extract_volume(stock)
if len(daily_prices) < 15: # Need at least 14+1 for RSI intraday_range_pct = 0.0
logger.debug("Insufficient price history for %s", stock_code) volume_ratio = _safe_float(stock.get("volume_increase_rate"), 0.0) / 100.0 + 1.0
# Use daily chart to refine range/volume when available.
daily_prices = await self.broker.get_daily_prices(stock_code, days=2)
if daily_prices:
latest = daily_prices[-1]
latest_close = _safe_float(latest.get("close"), default=price)
if price <= 0:
price = latest_close
latest_high = _safe_float(latest.get("high"))
latest_low = _safe_float(latest.get("low"))
if latest_close > 0 and latest_high > 0 and latest_low > 0 and latest_high >= latest_low:
intraday_range_pct = (latest_high - latest_low) / latest_close * 100.0
if volume <= 0:
volume = _safe_float(latest.get("volume"))
if len(daily_prices) >= 2:
prev_day_volume = _safe_float(daily_prices[-2].get("volume"))
if prev_day_volume > 0:
volume_ratio = max(volume_ratio, volume / prev_day_volume)
volatility_pct = max(abs(change_rate), intraday_range_pct)
if price <= 0 or volatility_pct < 0.8:
continue continue
# Calculate RSI volatility_score = min(volatility_pct / 10.0, 1.0) * 85.0
close_prices = [p["close"] for p in daily_prices] liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
rsi = self.analyzer.calculate_rsi(close_prices, period=14) score = min(100.0, volatility_score + liquidity_score)
signal = "momentum" if change_rate >= 0 else "oversold"
# Calculate volume ratio (today vs previous day avg) implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
if len(daily_prices) >= 2:
prev_day_volume = daily_prices[-2]["volume"]
current_volume = stock.get("volume", 0) or daily_prices[-1]["volume"]
volume_ratio = (
current_volume / prev_day_volume if prev_day_volume > 0 else 1.0
)
else:
volume_ratio = stock.get("volume_increase_rate", 0) / 100 + 1 # Fallback
# Apply filters
volume_qualified = volume_ratio >= self.vol_multiplier
rsi_oversold = rsi < self.rsi_oversold
rsi_momentum = rsi > self.rsi_momentum
if volume_qualified and (rsi_oversold or rsi_momentum):
signal = "oversold" if rsi_oversold else "momentum"
# Calculate composite score
# Higher score for: extreme RSI + high volume
rsi_extremity = abs(rsi - 50) / 50 # 0-1 scale
volume_score = min(volume_ratio / 5, 1.0) # Cap at 5x
score = (rsi_extremity * 0.6 + volume_score * 0.4) * 100
candidates.append( candidates.append(
ScanCandidate( ScanCandidate(
stock_code=stock_code, stock_code=stock_code,
name=stock.get("name", stock_code), name=stock.get("name", stock_code),
price=stock.get("price", daily_prices[-1]["close"]), price=price,
volume=current_volume, volume=volume,
volume_ratio=volume_ratio, volume_ratio=max(1.0, volume_ratio, volatility_pct / 2.0),
rsi=rsi, rsi=implied_rsi,
signal=signal, signal=signal,
score=score, score=score,
) )
) )
logger.info(
"Qualified: %s (%s) RSI=%.1f vol=%.1fx signal=%s score=%.1f",
stock_code,
stock.get("name", ""),
rsi,
volume_ratio,
signal,
score,
)
except ConnectionError as exc: except ConnectionError as exc:
logger.warning("Failed to analyze %s: %s", stock_code, exc) logger.warning("Failed to analyze %s: %s", stock_code, exc)
continue continue
@@ -176,10 +197,171 @@ class SmartVolatilityScanner:
logger.error("Unexpected error analyzing %s: %s", stock_code, exc) logger.error("Unexpected error analyzing %s: %s", stock_code, exc)
continue continue
# Sort by score and return top N logger.info("Domestic ranking scan found %d candidates", len(candidates))
candidates.sort(key=lambda c: c.score, reverse=True) candidates.sort(key=lambda c: c.score, reverse=True)
return candidates[: self.top_n] return candidates[: self.top_n]
async def _scan_overseas(
self,
market: MarketInfo,
fallback_stocks: list[str] | None = None,
) -> list[ScanCandidate]:
"""Scan overseas symbols using ranking API first, then fallback universe."""
if self.overseas_broker is None:
logger.warning(
"Overseas scanner unavailable for %s: overseas broker not configured",
market.name,
)
return []
candidates = await self._scan_overseas_from_rankings(market)
if not candidates:
candidates = await self._scan_overseas_from_symbols(market, fallback_stocks)
candidates.sort(key=lambda c: c.score, reverse=True)
return candidates[: self.top_n]
async def _scan_overseas_from_rankings(
self,
market: MarketInfo,
) -> list[ScanCandidate]:
"""Build overseas candidates from ranking APIs using volatility-first scoring."""
assert self.overseas_broker is not None
try:
fluct_rows = await self.overseas_broker.fetch_overseas_rankings(
exchange_code=market.exchange_code,
ranking_type="fluctuation",
limit=50,
)
except Exception as exc:
logger.warning(
"Overseas fluctuation ranking failed for %s: %s", market.code, exc
)
fluct_rows = []
if not fluct_rows:
return []
volume_rank_bonus: dict[str, float] = {}
try:
volume_rows = await self.overseas_broker.fetch_overseas_rankings(
exchange_code=market.exchange_code,
ranking_type="volume",
limit=50,
)
except Exception as exc:
logger.warning(
"Overseas volume ranking failed for %s: %s", market.code, exc
)
volume_rows = []
for idx, row in enumerate(volume_rows):
code = _extract_stock_code(row)
if not code:
continue
# Top-ranked by traded value/volume gets higher liquidity bonus.
volume_rank_bonus[code] = max(0.0, 15.0 - idx * 0.3)
candidates: list[ScanCandidate] = []
for row in fluct_rows:
stock_code = _extract_stock_code(row)
if not stock_code:
continue
price = _extract_last_price(row)
change_rate = _extract_change_rate_pct(row)
volume = _extract_volume(row)
intraday_range_pct = _extract_intraday_range_pct(row, price)
volatility_pct = max(abs(change_rate), intraday_range_pct)
# Volatility-first filter (not simple gainers/value ranking).
if price <= 0 or volatility_pct < 0.8:
continue
volatility_score = min(volatility_pct / 10.0, 1.0) * 85.0
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
score = min(100.0, volatility_score + liquidity_score)
signal = "momentum" if change_rate >= 0 else "oversold"
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
candidates.append(
ScanCandidate(
stock_code=stock_code,
name=str(row.get("name") or row.get("ovrs_item_name") or stock_code),
price=price,
volume=volume,
volume_ratio=max(1.0, volatility_pct / 2.0),
rsi=implied_rsi,
signal=signal,
score=score,
)
)
if candidates:
logger.info(
"Overseas ranking scan found %d candidates for %s",
len(candidates),
market.name,
)
return candidates
async def _scan_overseas_from_symbols(
self,
market: MarketInfo,
symbols: list[str] | None,
) -> list[ScanCandidate]:
"""Fallback overseas scan from dynamic symbol universe."""
assert self.overseas_broker is not None
if not symbols:
logger.info("Overseas scanner: no symbol universe for %s", market.name)
return []
logger.info(
"Overseas scanner: scanning %d fallback symbols for %s",
len(symbols),
market.name,
)
candidates: list[ScanCandidate] = []
for stock_code in symbols:
try:
price_data = await self.overseas_broker.get_overseas_price(
market.exchange_code, stock_code
)
output = price_data.get("output", {})
price = _extract_last_price(output)
change_rate = _extract_change_rate_pct(output)
volume = _extract_volume(output)
intraday_range_pct = _extract_intraday_range_pct(output, price)
volatility_pct = max(abs(change_rate), intraday_range_pct)
if price <= 0 or volatility_pct < 0.8:
continue
score = min(volatility_pct / 10.0, 1.0) * 100.0
signal = "momentum" if change_rate >= 0 else "oversold"
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
candidates.append(
ScanCandidate(
stock_code=stock_code,
name=stock_code,
price=price,
volume=volume,
volume_ratio=max(1.0, volatility_pct / 2.0),
rsi=implied_rsi,
signal=signal,
score=score,
)
)
except ConnectionError as exc:
logger.warning("Failed to analyze overseas %s: %s", stock_code, exc)
except Exception as exc:
logger.error("Unexpected error analyzing overseas %s: %s", stock_code, exc)
logger.info(
"Overseas symbol fallback scan found %d candidates for %s",
len(candidates),
market.name,
)
return candidates
def get_stock_codes(self, candidates: list[ScanCandidate]) -> list[str]: def get_stock_codes(self, candidates: list[ScanCandidate]) -> list[str]:
"""Extract stock codes from candidates for watchlist update. """Extract stock codes from candidates for watchlist update.
@@ -190,3 +372,78 @@ class SmartVolatilityScanner:
List of stock codes List of stock codes
""" """
return [c.stock_code for c in candidates] return [c.stock_code for c in candidates]
def _safe_float(value: Any, default: float = 0.0) -> float:
"""Convert arbitrary values to float safely."""
if value in (None, ""):
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _extract_stock_code(row: dict[str, Any]) -> str:
"""Extract normalized stock code from various API schemas."""
return (
str(
row.get("symb")
or row.get("ovrs_pdno")
or row.get("stock_code")
or row.get("pdno")
or ""
)
.strip()
.upper()
)
def _extract_last_price(row: dict[str, Any]) -> float:
"""Extract last/close-like price from API schema variants."""
return _safe_float(
row.get("last")
or row.get("ovrs_nmix_prpr")
or row.get("stck_prpr")
or row.get("price")
or row.get("close")
)
def _extract_change_rate_pct(row: dict[str, Any]) -> float:
"""Extract daily change rate (%) from API schema variants."""
return _safe_float(
row.get("rate")
or row.get("change_rate")
or row.get("prdy_ctrt")
or row.get("evlu_pfls_rt")
or row.get("chg_rt")
)
def _extract_volume(row: dict[str, Any]) -> float:
"""Extract volume/traded-amount proxy from schema variants."""
return _safe_float(
row.get("tvol") or row.get("acml_vol") or row.get("vol") or row.get("volume")
)
def _extract_intraday_range_pct(row: dict[str, Any], price: float) -> float:
"""Estimate intraday range percentage from high/low fields."""
if price <= 0:
return 0.0
high = _safe_float(
row.get("high")
or row.get("ovrs_hgpr")
or row.get("stck_hgpr")
or row.get("day_hgpr")
)
low = _safe_float(
row.get("low")
or row.get("ovrs_lwpr")
or row.get("stck_lwpr")
or row.get("day_lwpr")
)
if high <= 0 or low <= 0 or high < low:
return 0.0
return (high - low) / price * 100.0

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import ssl import ssl
from typing import Any from typing import Any, cast
import aiohttp import aiohttp
@@ -20,6 +20,39 @@ _KIS_VTS_HOST = "openapivts.koreainvestment.com"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def kr_tick_unit(price: float) -> int:
"""Return KRX tick size for the given price level.
KRX price tick rules (domestic stocks):
price < 2,000 → 1원
2,000 ≤ price < 5,000 → 5원
5,000 ≤ price < 20,000 → 10원
20,000 ≤ price < 50,000 → 50원
50,000 ≤ price < 200,000 → 100원
200,000 ≤ price < 500,000 → 500원
500,000 ≤ price → 1,000원
"""
if price < 2_000:
return 1
if price < 5_000:
return 5
if price < 20_000:
return 10
if price < 50_000:
return 50
if price < 200_000:
return 100
if price < 500_000:
return 500
return 1_000
def kr_round_down(price: float) -> int:
"""Round *down* price to the nearest KRX tick unit."""
tick = kr_tick_unit(price)
return int(price // tick * tick)
class LeakyBucket: class LeakyBucket:
"""Simple leaky-bucket rate limiter for async code.""" """Simple leaky-bucket rate limiter for async code."""
@@ -104,12 +137,14 @@ class KISBroker:
time_since_last_attempt = now - self._last_refresh_attempt time_since_last_attempt = now - self._last_refresh_attempt
if time_since_last_attempt < self._refresh_cooldown: if time_since_last_attempt < self._refresh_cooldown:
remaining = self._refresh_cooldown - time_since_last_attempt remaining = self._refresh_cooldown - time_since_last_attempt
error_msg = ( # Do not fail fast here. If token is unavailable, upstream calls
f"Token refresh on cooldown. " # will all fail for up to a minute and scanning returns no trades.
f"Retry in {remaining:.1f}s (KIS allows 1/minute)" logger.warning(
"Token refresh on cooldown. Waiting %.1fs before retry (KIS allows 1/minute)",
remaining,
) )
logger.warning(error_msg) await asyncio.sleep(remaining)
raise ConnectionError(error_msg) now = asyncio.get_event_loop().time()
logger.info("Refreshing KIS access token") logger.info("Refreshing KIS access token")
self._last_refresh_attempt = now self._last_refresh_attempt = now
@@ -196,12 +231,64 @@ class KISBroker:
except (TimeoutError, aiohttp.ClientError) as exc: except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc
async def get_current_price(
self, stock_code: str
) -> tuple[float, float, float]:
"""Fetch current price data for a domestic stock.
Uses the ``inquire-price`` API (FHKST01010100), which works in both
real and VTS environments and returns the actual last-traded price.
Returns:
(current_price, prdy_ctrt, frgn_ntby_qty)
- current_price: Last traded price in KRW.
- prdy_ctrt: Day change rate (%).
- frgn_ntby_qty: Foreigner net buy quantity.
"""
await self._rate_limiter.acquire()
session = self._get_session()
headers = await self._auth_headers("FHKST01010100")
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
}
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-price"
def _f(val: str | None) -> float:
try:
return float(val or "0")
except ValueError:
return 0.0
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_current_price failed ({resp.status}): {text}"
)
data = await resp.json()
out = data.get("output", {})
return (
_f(out.get("stck_prpr")),
_f(out.get("prdy_ctrt")),
_f(out.get("frgn_ntby_qty")),
)
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching current price: {exc}"
) from exc
async def get_balance(self) -> dict[str, Any]: async def get_balance(self) -> dict[str, Any]:
"""Fetch current account balance and holdings.""" """Fetch current account balance and holdings."""
await self._rate_limiter.acquire() await self._rate_limiter.acquire()
session = self._get_session() session = self._get_session()
headers = await self._auth_headers("VTTC8434R") # 모의투자 잔고조회 # TR_ID: 실전 TTTC8434R, 모의 VTTC8434R
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '국내주식 잔고조회' 시트
tr_id = "TTTC8434R" if self._settings.MODE == "live" else "VTTC8434R"
headers = await self._auth_headers(tr_id)
params = { params = {
"CANO": self._account_no, "CANO": self._account_no,
"ACNT_PRDT_CD": self._product_cd, "ACNT_PRDT_CD": self._product_cd,
@@ -246,14 +333,30 @@ class KISBroker:
await self._rate_limiter.acquire() await self._rate_limiter.acquire()
session = self._get_session() session = self._get_session()
tr_id = "VTTC0802U" if order_type == "BUY" else "VTTC0801U" # TR_ID: 실전 BUY=TTTC0012U SELL=TTTC0011U, 모의 BUY=VTTC0012U SELL=VTTC0011U
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '주식주문(현금)' 시트
# ※ TTTC0802U/VTTC0802U는 미수매수(증거금40% 계좌 전용) — 현금주문에 사용 금지
if self._settings.MODE == "live":
tr_id = "TTTC0012U" if order_type == "BUY" else "TTTC0011U"
else:
tr_id = "VTTC0012U" if order_type == "BUY" else "VTTC0011U"
# KRX requires limit orders to be rounded down to the tick unit.
# ORD_DVSN: "00"=지정가, "01"=시장가
if price > 0:
ord_dvsn = "00" # 지정가
ord_price = kr_round_down(price)
else:
ord_dvsn = "01" # 시장가
ord_price = 0
body = { body = {
"CANO": self._account_no, "CANO": self._account_no,
"ACNT_PRDT_CD": self._product_cd, "ACNT_PRDT_CD": self._product_cd,
"PDNO": stock_code, "PDNO": stock_code,
"ORD_DVSN": "01" if price > 0 else "06", # 01=지정가, 06=시장가 "ORD_DVSN": ord_dvsn,
"ORD_QTY": str(quantity), "ORD_QTY": str(quantity),
"ORD_UNPR": str(price), "ORD_UNPR": str(ord_price),
} }
hash_key = await self._get_hash_key(body) hash_key = await self._get_hash_key(body)
@@ -302,25 +405,45 @@ 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
headers = await self._auth_headers(tr_id) tr_id = "FHPST01710000"
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
params = { params: dict[str, str] = {
"FID_COND_MRKT_DIV_CODE": "J", # Stock/ETF/ETN "FID_COND_MRKT_DIV_CODE": "J",
"FID_COND_SCR_DIV_CODE": "20001", # Volume surge "FID_COND_SCR_DIV_CODE": "20171",
"FID_INPUT_ISCD": "0000", # All stocks "FID_INPUT_ISCD": "0000",
"FID_DIV_CLS_CODE": "0", # All types "FID_DIV_CLS_CODE": "0",
"FID_BLNG_CLS_CODE": "0", "FID_BLNG_CLS_CODE": "0",
"FID_TRGT_CLS_CODE": "111111111", "FID_TRGT_CLS_CODE": "111111111",
"FID_TRGT_EXLS_CLS_CODE": "000000", "FID_TRGT_EXLS_CLS_CODE": "0000000000",
"FID_INPUT_PRICE_1": "0", "FID_INPUT_PRICE_1": "0",
"FID_INPUT_PRICE_2": "0", "FID_INPUT_PRICE_2": "0",
"FID_VOL_CNT": "0", "FID_VOL_CNT": "0",
"FID_INPUT_DATE_1": "", "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": "0",
"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",
}
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank" headers = await self._auth_headers(tr_id)
try: try:
async with session.get(url, headers=headers, params=params) as resp: async with session.get(url, headers=headers, params=params) as resp:
@@ -343,7 +466,7 @@ class KISBroker:
rankings = [] rankings = []
for item in data.get("output", [])[:limit]: for item in data.get("output", [])[:limit]:
rankings.append({ rankings.append({
"stock_code": item.get("mksc_shrn_iscd", ""), "stock_code": item.get("stck_shrn_iscd") or item.get("mksc_shrn_iscd", ""),
"name": item.get("hts_kor_isnm", ""), "name": item.get("hts_kor_isnm", ""),
"price": _safe_float(item.get("stck_prpr", "0")), "price": _safe_float(item.get("stck_prpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")), "volume": _safe_float(item.get("acml_vol", "0")),
@@ -355,6 +478,112 @@ class KISBroker:
except (TimeoutError, aiohttp.ClientError) as exc: except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching rankings: {exc}") from exc raise ConnectionError(f"Network error fetching rankings: {exc}") from exc
async def get_domestic_pending_orders(self) -> list[dict[str, Any]]:
"""Fetch unfilled (pending) domestic limit orders.
The KIS pending-orders API (TTTC0084R) is unsupported in paper (VTS)
mode, so this method returns an empty list immediately when MODE is
not "live".
Returns:
List of pending order dicts from the KIS ``output`` field.
Each dict includes keys such as ``odno``, ``orgn_odno``,
``ord_gno_brno``, ``psbl_qty``, ``sll_buy_dvsn_cd``, ``pdno``.
"""
if self._settings.MODE != "live":
logger.debug(
"get_domestic_pending_orders: paper mode — TTTC0084R unsupported, returning []"
)
return []
await self._rate_limiter.acquire()
session = self._get_session()
# TR_ID: 실전 TTTC0084R (모의 미지원)
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '주식 미체결조회' 시트
headers = await self._auth_headers("TTTC0084R")
params = {
"CANO": self._account_no,
"ACNT_PRDT_CD": self._product_cd,
"INQR_DVSN_1": "0",
"INQR_DVSN_2": "0",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
}
url = f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl"
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_domestic_pending_orders failed ({resp.status}): {text}"
)
data = await resp.json()
return data.get("output", []) or []
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching domestic pending orders: {exc}"
) from exc
async def cancel_domestic_order(
self,
stock_code: str,
orgn_odno: str,
krx_fwdg_ord_orgno: str,
qty: int,
) -> dict[str, Any]:
"""Cancel an unfilled domestic limit order.
Args:
stock_code: 6-digit domestic stock code (``pdno``).
orgn_odno: Original order number from pending-orders response
(``orgn_odno`` field).
krx_fwdg_ord_orgno: KRX forwarding order branch number from
pending-orders response (``ord_gno_brno`` field).
qty: Quantity to cancel (use ``psbl_qty`` from pending order).
Returns:
Raw KIS API response dict (check ``rt_cd == "0"`` for success).
"""
await self._rate_limiter.acquire()
session = self._get_session()
# TR_ID: 실전 TTTC0013U, 모의 VTTC0013U
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '주식주문(정정취소)' 시트
tr_id = "TTTC0013U" if self._settings.MODE == "live" else "VTTC0013U"
body = {
"CANO": self._account_no,
"ACNT_PRDT_CD": self._product_cd,
"KRX_FWDG_ORD_ORGNO": krx_fwdg_ord_orgno,
"ORGN_ODNO": orgn_odno,
"ORD_DVSN": "00",
"ORD_QTY": str(qty),
"ORD_UNPR": "0",
"RVSE_CNCL_DVSN_CD": "02",
"QTY_ALL_ORD_YN": "Y",
}
hash_key = await self._get_hash_key(body)
headers = await self._auth_headers(tr_id)
headers["hashkey"] = hash_key
url = f"{self._base_url}/uapi/domestic-stock/v1/trading/order-rvsecncl"
try:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"cancel_domestic_order failed ({resp.status}): {text}"
)
return cast(dict[str, Any], await resp.json())
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error cancelling domestic order: {exc}"
) from exc
async def get_daily_prices( async def get_daily_prices(
self, self,
stock_code: str, stock_code: str,

View File

@@ -12,6 +12,38 @@ 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
# Cancel order TR_IDs per exchange code — (live_tr_id, paper_tr_id).
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 주문취소' 시트
_CANCEL_TR_ID_MAP: dict[str, tuple[str, str]] = {
"NASD": ("TTTT1004U", "VTTT1004U"),
"NYSE": ("TTTT1004U", "VTTT1004U"),
"AMEX": ("TTTT1004U", "VTTT1004U"),
"SEHK": ("TTTS1003U", "VTTS1003U"),
"TSE": ("TTTS0309U", "VTTS0309U"),
"SHAA": ("TTTS0302U", "VTTS0302U"),
"SZAA": ("TTTS0306U", "VTTS0306U"),
"HNX": ("TTTS0312U", "VTTS0312U"),
"HSX": ("TTTS0312U", "VTTS0312U"),
}
class OverseasBroker: class OverseasBroker:
"""KIS Overseas Stock API wrapper that reuses KISBroker infrastructure.""" """KIS Overseas Stock API wrapper that reuses KISBroker infrastructure."""
@@ -44,9 +76,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"
@@ -64,6 +98,83 @@ class OverseasBroker:
f"Network error fetching overseas price: {exc}" f"Network error fetching overseas price: {exc}"
) from exc ) from exc
async def fetch_overseas_rankings(
self,
exchange_code: str,
ranking_type: str = "fluctuation",
limit: int = 30,
) -> list[dict[str, Any]]:
"""Fetch overseas rankings (price change or volume surge).
Ranking API specs may differ by account/product. Endpoint paths and
TR_IDs are configurable via settings and can be overridden in .env.
"""
if not self._broker._settings.OVERSEAS_RANKING_ENABLED:
return []
await self._broker._rate_limiter.acquire()
session = self._broker._get_session()
ranking_excd = _RANKING_EXCHANGE_MAP.get(exchange_code, exchange_code)
if ranking_type == "volume":
tr_id = self._broker._settings.OVERSEAS_RANKING_VOLUME_TR_ID
path = self._broker._settings.OVERSEAS_RANKING_VOLUME_PATH
params: dict[str, str] = {
"KEYB": "", # NEXT KEY BUFF — Required, 공백
"AUTH": "",
"EXCD": ranking_excd,
"MIXN": "0",
"VOL_RANG": "0",
}
else:
tr_id = self._broker._settings.OVERSEAS_RANKING_FLUCT_TR_ID
path = self._broker._settings.OVERSEAS_RANKING_FLUCT_PATH
params = {
"KEYB": "", # NEXT KEY BUFF — Required, 공백
"AUTH": "",
"EXCD": ranking_excd,
"NDAY": "0",
"GUBN": "1", # 0=하락율, 1=상승율 — 변동성 스캐너는 급등 종목 우선
"VOL_RANG": "0",
}
headers = await self._broker._auth_headers(tr_id)
url = f"{self._broker._base_url}{path}"
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
if resp.status == 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 ({resp.status}): {text}"
)
data = await resp.json()
rows = self._extract_ranking_rows(data)
if rows:
return rows[:limit]
logger.debug(
"Overseas ranking returned empty for %s/%s (keys=%s)",
exchange_code,
ranking_type,
list(data.keys()),
)
return []
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching overseas rankings: {exc}"
) from exc
async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]: async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]:
""" """
Fetch overseas account balance. Fetch overseas account balance.
@@ -80,8 +191,12 @@ class OverseasBroker:
await self._broker._rate_limiter.acquire() await self._broker._rate_limiter.acquire()
session = self._broker._get_session() session = self._broker._get_session()
# Virtual trading TR_ID for overseas balance inquiry # TR_ID: 실전 TTTS3012R, 모의 VTTS3012R
headers = await self._broker._auth_headers("VTTS3012R") # Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 잔고조회' 시트
balance_tr_id = (
"TTTS3012R" if self._broker._settings.MODE == "live" else "VTTS3012R"
)
headers = await self._broker._auth_headers(balance_tr_id)
params = { params = {
"CANO": self._broker._account_no, "CANO": self._broker._account_no,
"ACNT_PRDT_CD": self._broker._product_cd, "ACNT_PRDT_CD": self._broker._product_cd,
@@ -134,8 +249,12 @@ class OverseasBroker:
await self._broker._rate_limiter.acquire() await self._broker._rate_limiter.acquire()
session = self._broker._get_session() session = self._broker._get_session()
# Virtual trading TR_IDs for overseas orders # TR_ID: 실전 BUY=TTTT1002U SELL=TTTT1006U, 모의 BUY=VTTT1002U SELL=VTTT1001U
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1006U" # Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 주문' 시트
if self._broker._settings.MODE == "live":
tr_id = "TTTT1002U" if order_type == "BUY" else "TTTT1006U"
else:
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1001U"
body = { body = {
"CANO": self._broker._account_no, "CANO": self._broker._account_no,
@@ -162,6 +281,9 @@ 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()
rt_cd = data.get("rt_cd", "")
msg1 = data.get("msg1", "")
if rt_cd == "0":
logger.info( logger.info(
"Overseas order submitted", "Overseas order submitted",
extra={ extra={
@@ -170,12 +292,147 @@ class OverseasBroker:
"action": order_type, "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(
f"Network error sending overseas order: {exc}" f"Network error sending overseas order: {exc}"
) from exc ) from exc
async def get_overseas_pending_orders(
self, exchange_code: str
) -> list[dict[str, Any]]:
"""Fetch unfilled (pending) overseas orders for a given exchange.
Args:
exchange_code: Exchange code (e.g., "NASD", "SEHK").
For US markets, NASD returns all US pending orders (NASD/NYSE/AMEX).
Returns:
List of pending order dicts with fields: odno, pdno, sll_buy_dvsn_cd,
ft_ord_qty, nccs_qty, ft_ord_unpr3, ovrs_excg_cd.
Always returns [] in paper mode (TTTS3018R is live-only).
Raises:
ConnectionError: On network or API errors (live mode only).
"""
if self._broker._settings.MODE != "live":
logger.debug(
"Pending orders API (TTTS3018R) not supported in paper mode; returning []"
)
return []
await self._broker._rate_limiter.acquire()
session = self._broker._get_session()
# TTTS3018R: 해외주식 미체결내역조회 (실전 전용)
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 미체결조회' 시트
headers = await self._broker._auth_headers("TTTS3018R")
params = {
"CANO": self._broker._account_no,
"ACNT_PRDT_CD": self._broker._product_cd,
"OVRS_EXCG_CD": exchange_code,
"SORT_SQN": "DS",
"CTX_AREA_FK200": "",
"CTX_AREA_NK200": "",
}
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-nccs"
)
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_overseas_pending_orders failed ({resp.status}): {text}"
)
data = await resp.json()
output = data.get("output", [])
if isinstance(output, list):
return output
return []
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching pending orders: {exc}"
) from exc
async def cancel_overseas_order(
self,
exchange_code: str,
stock_code: str,
odno: str,
qty: int,
) -> dict[str, Any]:
"""Cancel an overseas limit order.
Args:
exchange_code: Exchange code (e.g., "NASD", "SEHK").
stock_code: Stock ticker symbol.
odno: Original order number to cancel.
qty: Unfilled quantity to cancel.
Returns:
API response dict containing rt_cd and msg1.
Raises:
ValueError: If exchange_code has no cancel TR_ID mapping.
ConnectionError: On network or API errors.
"""
tr_ids = _CANCEL_TR_ID_MAP.get(exchange_code)
if tr_ids is None:
raise ValueError(f"No cancel TR_ID mapping for exchange: {exchange_code}")
live_tr_id, paper_tr_id = tr_ids
tr_id = live_tr_id if self._broker._settings.MODE == "live" else paper_tr_id
await self._broker._rate_limiter.acquire()
session = self._broker._get_session()
# RVSE_CNCL_DVSN_CD="02" means cancel (not revision).
# OVRS_ORD_UNPR must be "0" for cancellations.
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 정정취소주문' 시트
body = {
"CANO": self._broker._account_no,
"ACNT_PRDT_CD": self._broker._product_cd,
"OVRS_EXCG_CD": exchange_code,
"PDNO": stock_code,
"ORGN_ODNO": odno,
"RVSE_CNCL_DVSN_CD": "02",
"ORD_QTY": str(qty),
"OVRS_ORD_UNPR": "0",
"ORD_SVR_DVSN_CD": "0",
}
hash_key = await self._broker._get_hash_key(body)
headers = await self._broker._auth_headers(tr_id)
headers["hashkey"] = hash_key
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/order-rvsecncl"
)
try:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"cancel_overseas_order failed ({resp.status}): {text}"
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error cancelling overseas order: {exc}"
) from exc
def _get_currency_code(self, exchange_code: str) -> str: def _get_currency_code(self, exchange_code: str) -> str:
""" """
Map exchange code to currency code. Map exchange code to currency code.
@@ -198,3 +455,11 @@ class OverseasBroker:
"HSX": "VND", "HSX": "VND",
} }
return currency_map.get(exchange_code, "USD") return currency_map.get(exchange_code, "USD")
def _extract_ranking_rows(self, data: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract list rows from ranking response across schema variants."""
candidates = [data.get("output"), data.get("output1"), data.get("output2")]
for value in candidates:
if isinstance(value, list):
return [row for row in value if isinstance(row, dict)]
return []

View File

@@ -13,11 +13,11 @@ class Settings(BaseSettings):
KIS_APP_KEY: str KIS_APP_KEY: str
KIS_APP_SECRET: str KIS_APP_SECRET: str
KIS_ACCOUNT_NO: str # format: "XXXXXXXX-XX" KIS_ACCOUNT_NO: str # format: "XXXXXXXX-XX"
KIS_BASE_URL: str = "https://openapivts.koreainvestment.com:9443" KIS_BASE_URL: str = "https://openapivts.koreainvestment.com:29443"
# Google Gemini # Google Gemini
GEMINI_API_KEY: str GEMINI_API_KEY: str
GEMINI_MODEL: str = "gemini-pro" GEMINI_MODEL: str = "gemini-2.0-flash"
# External Data APIs (optional — for data-driven decisions) # External Data APIs (optional — for data-driven decisions)
NEWS_API_KEY: str | None = None NEWS_API_KEY: str | None = None
@@ -38,6 +38,11 @@ class Settings(BaseSettings):
RSI_MOMENTUM_THRESHOLD: int = Field(default=70, ge=50, le=100) RSI_MOMENTUM_THRESHOLD: int = Field(default=70, ge=50, le=100)
VOL_MULTIPLIER: float = Field(default=2.0, gt=1.0, le=10.0) VOL_MULTIPLIER: float = Field(default=2.0, gt=1.0, le=10.0)
SCANNER_TOP_N: int = Field(default=3, ge=1, le=10) SCANNER_TOP_N: int = Field(default=3, ge=1, le=10)
POSITION_SIZING_ENABLED: bool = True
POSITION_BASE_ALLOCATION_PCT: float = Field(default=5.0, gt=0.0, le=30.0)
POSITION_MIN_ALLOCATION_PCT: float = Field(default=1.0, gt=0.0, le=20.0)
POSITION_MAX_ALLOCATION_PCT: float = Field(default=10.0, gt=0.0, le=50.0)
POSITION_VOLATILITY_TARGET_SCORE: float = Field(default=50.0, gt=0.0, le=100.0)
# Database # Database
DB_PATH: str = "data/trade_logs.db" DB_PATH: str = "data/trade_logs.db"
@@ -50,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)
@@ -83,6 +93,33 @@ 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)
# Override these from .env if your account uses different specs.
OVERSEAS_RANKING_ENABLED: bool = True
OVERSEAS_RANKING_FLUCT_TR_ID: str = "HHDFS76290000"
OVERSEAS_RANKING_VOLUME_TR_ID: str = "HHDFS76270000"
OVERSEAS_RANKING_FLUCT_PATH: str = (
"/uapi/overseas-stock/v1/ranking/updown-rate"
)
OVERSEAS_RANKING_VOLUME_PATH: str = (
"/uapi/overseas-stock/v1/ranking/volume-surge"
)
# Dashboard (optional)
DASHBOARD_ENABLED: bool = False
DASHBOARD_HOST: str = "127.0.0.1"
DASHBOARD_PORT: int = Field(default=8080, ge=1, le=65535)
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@property @property
@@ -96,4 +133,7 @@ class Settings(BaseSettings):
@property @property
def enabled_market_list(self) -> list[str]: def enabled_market_list(self) -> list[str]:
"""Parse ENABLED_MARKETS into list of market codes.""" """Parse ENABLED_MARKETS into list of market codes."""
return [m.strip() for m in self.ENABLED_MARKETS.split(",") if m.strip()] from src.markets.schedule import expand_market_codes
raw = [m.strip() for m in self.ENABLED_MARKETS.split(",") if m.strip()]
return expand_market_codes(raw)

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
@@ -12,10 +13,11 @@ from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
def create_dashboard_app(db_path: str) -> FastAPI: def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI:
"""Create dashboard FastAPI app bound to a SQLite database path.""" """Create dashboard FastAPI app bound to a SQLite database path."""
app = FastAPI(title="The Ouroboros Dashboard", version="1.0.0") app = FastAPI(title="The Ouroboros Dashboard", version="1.0.0")
app.state.db_path = db_path app.state.db_path = db_path
app.state.mode = mode
@app.get("/") @app.get("/")
def index() -> FileResponse: def index() -> FileResponse:
@@ -26,7 +28,19 @@ def create_dashboard_app(db_path: str) -> FastAPI:
def get_status() -> dict[str, Any]: def get_status() -> dict[str, Any]:
today = datetime.now(UTC).date().isoformat() today = datetime.now(UTC).date().isoformat()
with _connect(db_path) as conn: with _connect(db_path) as conn:
markets = ["KR", "US"] market_rows = conn.execute(
"""
SELECT DISTINCT market FROM (
SELECT market FROM trades WHERE DATE(timestamp) = ?
UNION
SELECT market FROM decision_logs WHERE DATE(timestamp) = ?
UNION
SELECT market FROM playbooks WHERE date = ?
) ORDER BY market
""",
(today, today, today),
).fetchall()
markets = [row[0] for row in market_rows] if market_rows else []
market_status: dict[str, Any] = {} market_status: dict[str, Any] = {}
total_trades = 0 total_trades = 0
total_pnl = 0.0 total_pnl = 0.0
@@ -67,14 +81,49 @@ 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,
"mode": mode,
"markets": market_status, "markets": market_status,
"totals": { "totals": {
"trade_count": total_trades, "trade_count": total_trades,
"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}")
@@ -247,6 +296,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"),
@@ -285,12 +378,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,787 @@
--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); }
.mode-badge {
padding: 3px 10px; border-radius: 5px; font-size: 12px; font-weight: 700;
letter-spacing: 0.5px;
}
.mode-badge.live {
background: rgba(224, 85, 85, 0.15); color: var(--red);
border: 1px solid rgba(224, 85, 85, 0.4);
animation: pulse-warn 2s ease-in-out infinite;
}
.mode-badge.paper {
background: rgba(232, 160, 64, 0.15); color: var(--warn);
border: 1px solid rgba(232, 160, 64, 0.4);
}
/* CB Gauge */
.cb-gauge-wrap {
display: flex; align-items: center; gap: 8px;
font-size: 11px; color: var(--muted);
}
.cb-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.cb-dot.ok { background: var(--accent); }
.cb-dot.warning { background: var(--warn); animation: pulse-warn 1.2s ease-in-out infinite; }
.cb-dot.tripped { background: var(--red); animation: pulse-warn 0.6s ease-in-out infinite; }
.cb-dot.unknown { background: var(--border); }
@keyframes pulse-warn {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.cb-bar-wrap { width: 64px; height: 5px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
.cb-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s, background 0.4s; }
/* Summary cards */
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
@media (max-width: 700px) { .cards { grid-template-columns: repeat(2, 1fr); } }
.card { .card {
background: color-mix(in oklab, var(--panel), black 12%); background: var(--panel);
border: 1px solid #28455f; border: 1px solid var(--border);
border-radius: 12px; border-radius: 10px;
padding: 20px; padding: 16px;
} }
h1 { .card-label { color: var(--muted); font-size: 11px; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
margin-top: 0; .card-value { font-size: 22px; font-weight: 700; }
.card-sub { color: var(--muted); font-size: 11px; margin-top: 4px; }
.positive { color: var(--accent); }
.negative { color: var(--red); }
.neutral { color: var(--fg); }
/* Chart panel */
.chart-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 20px;
} }
code { .panel-header {
color: var(--accent); display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
} }
li { .panel-title { font-size: 13px; color: var(--muted); font-weight: 600; }
margin: 6px 0; .chart-container { position: relative; height: 180px; }
color: var(--muted); .chart-error { color: var(--muted); text-align: center; padding: 40px 0; font-size: 12px; }
/* Days selector */
.days-selector { display: flex; gap: 4px; }
.day-btn {
background: none; border: 1px solid var(--border); color: var(--muted);
padding: 3px 8px; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 11px;
} }
.day-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(60, 179, 113, 0.08); }
/* Decisions panel */
.decisions-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
}
.market-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
.tab-btn {
background: none; border: 1px solid var(--border); color: var(--muted);
padding: 4px 10px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 11px;
}
.tab-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(60, 179, 113, 0.08); }
.decisions-table { width: 100%; border-collapse: collapse; margin-top: 14px; }
.decisions-table th {
text-align: left; color: var(--muted); font-size: 11px; font-weight: 600;
padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap;
}
.decisions-table td {
padding: 8px 8px; border-bottom: 1px solid rgba(40, 69, 95, 0.5);
vertical-align: middle; white-space: nowrap;
}
.decisions-table tr:last-child td { border-bottom: none; }
.decisions-table tr:hover td { background: rgba(255,255,255,0.02); }
.badge {
display: inline-block; padding: 2px 7px; border-radius: 4px;
font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
}
.badge-buy { background: rgba(60, 179, 113, 0.15); color: var(--accent); }
.badge-sell { background: rgba(224, 85, 85, 0.15); color: var(--red); }
.badge-hold { background: rgba(159, 179, 200, 0.12); color: var(--muted); }
.conf-bar-wrap { display: flex; align-items: center; gap: 6px; min-width: 90px; }
.conf-bar { flex: 1; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
.conf-fill { height: 100%; border-radius: 3px; background: var(--accent); transition: width 0.3s; }
.conf-val { color: var(--muted); font-size: 11px; min-width: 26px; text-align: right; }
.rationale-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
.empty-row td { text-align: center; color: var(--muted); padding: 24px; }
/* Positions panel */
.positions-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 20px;
}
.positions-table { width: 100%; border-collapse: collapse; margin-top: 14px; }
.positions-table th {
text-align: left; color: var(--muted); font-size: 11px; font-weight: 600;
padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap;
}
.positions-table td {
padding: 8px 8px; border-bottom: 1px solid rgba(40, 69, 95, 0.5);
vertical-align: middle; white-space: nowrap;
}
.positions-table tr:last-child td { border-bottom: none; }
.positions-table tr:hover td { background: rgba(255,255,255,0.02); }
.pos-empty { color: var(--muted); text-align: center; padding: 20px 0; font-size: 12px; }
.pos-count {
display: inline-block; background: rgba(60, 179, 113, 0.12);
color: var(--accent); font-size: 11px; font-weight: 700;
padding: 2px 8px; border-radius: 10px; margin-left: 8px;
}
/* Spinner */
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Generic panel */
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-top: 20px;
}
/* Playbook panel - details/summary accordion */
.playbook-panel details { border: 1px solid var(--border); border-radius: 4px; margin-bottom: 6px; }
.playbook-panel summary { padding: 8px 12px; cursor: pointer; font-weight: 600; background: var(--bg); color: var(--fg); }
.playbook-panel summary:hover { color: var(--accent); }
.playbook-panel pre { margin: 0; padding: 12px; background: var(--bg); overflow-x: auto;
font-size: 11px; color: #a0c4ff; white-space: pre-wrap; }
/* Scorecard KPI card grid */
.scorecard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
.kpi-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px; text-align: center; }
.kpi-card .kpi-label { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
.kpi-card .kpi-value { font-size: 20px; font-weight: 700; color: var(--fg); }
/* Scenarios table */
.scenarios-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.scenarios-table th { background: var(--bg); padding: 8px; text-align: left; border-bottom: 1px solid var(--border);
color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; }
.scenarios-table td { padding: 7px 8px; border-bottom: 1px solid rgba(40,69,95,0.5); }
.scenarios-table tr:hover td { background: rgba(255,255,255,0.02); }
/* Context table */
.context-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.context-table th { background: var(--bg); padding: 8px; text-align: left; border-bottom: 1px solid var(--border);
color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; }
.context-table td { padding: 6px 8px; border-bottom: 1px solid rgba(40,69,95,0.5); vertical-align: top; }
.context-value { max-height: 60px; overflow-y: auto; color: #a0c4ff; word-break: break-all; }
/* Common panel select controls */
.panel-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.panel-controls select, .panel-controls input[type="number"] {
background: var(--bg); color: var(--fg); border: 1px solid var(--border);
border-radius: 4px; padding: 4px 8px; font-size: 13px; font-family: inherit;
}
.panel-date { color: var(--muted); font-size: 12px; }
.empty-msg { color: var(--muted); text-align: center; padding: 20px 0; font-size: 12px; }
</style> </style>
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<!-- Header -->
<header>
<h1>&#x1F40D; The Ouroboros</h1>
<div class="header-right">
<span class="mode-badge" id="mode-badge">--</span>
<div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker">
<span class="cb-dot unknown" id="cb-dot"></span>
<span id="cb-label">CB --</span>
<div class="cb-bar-wrap">
<div class="cb-bar-fill" id="cb-bar" style="width:0%;background:var(--accent)"></div>
</div>
</div>
<span id="last-updated">--</span>
<button class="refresh-btn" onclick="refreshAll()">&#x21BA; 새로고침</button>
</div>
</header>
<!-- Summary cards -->
<div class="cards">
<div class="card"> <div class="card">
<h1>The Ouroboros Dashboard API</h1> <div class="card-label">오늘 거래</div>
<p>Use the following endpoints:</p> <div class="card-value neutral" id="card-trades">--</div>
<ul> <div class="card-sub" id="card-trades-sub">거래 건수</div>
<li><code>/api/status</code></li> </div>
<li><code>/api/playbook/{date}?market=KR</code></li> <div class="card">
<li><code>/api/scorecard/{date}?market=KR</code></li> <div class="card-label">오늘 P&amp;L</div>
<li><code>/api/performance?market=all</code></li> <div class="card-value" id="card-pnl">--</div>
<li><code>/api/context/{layer}</code></li> <div class="card-sub" id="card-pnl-sub">실현 손익</div>
<li><code>/api/decisions?market=KR</code></li> </div>
<li><code>/api/scenarios/active?market=US</code></li> <div class="card">
</ul> <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>
</div> </div>
<!-- Open Positions -->
<div class="positions-panel">
<div class="panel-header">
<span class="panel-title">
현재 보유 포지션
<span class="pos-count" id="positions-count">0</span>
</span>
</div>
<table class="positions-table">
<thead>
<tr>
<th>종목</th>
<th>시장</th>
<th>수량</th>
<th>진입가</th>
<th>보유 시간</th>
</tr>
</thead>
<tbody id="positions-body">
<tr><td colspan="5" class="pos-empty"><span class="spinner"></span></td></tr>
</tbody>
</table>
</div>
<!-- P&L Chart -->
<div class="chart-panel">
<div class="panel-header">
<span class="panel-title">P&amp;L 추이</span>
<div class="days-selector">
<button class="day-btn active" data-days="7" onclick="selectDays(this)">7일</button>
<button class="day-btn" data-days="30" onclick="selectDays(this)">30일</button>
<button class="day-btn" data-days="90" onclick="selectDays(this)">90일</button>
</div>
</div>
<div class="chart-container">
<canvas id="pnl-chart"></canvas>
<div class="chart-error" id="chart-error" style="display:none">데이터 없음</div>
</div>
</div>
<!-- Decisions log -->
<div class="decisions-panel">
<div class="panel-header">
<span class="panel-title">최근 결정 로그</span>
<div class="market-tabs" id="market-tabs">
<button class="tab-btn active" data-market="KR" onclick="selectMarket(this)">KR</button>
<button class="tab-btn" data-market="US_NASDAQ" onclick="selectMarket(this)">US_NASDAQ</button>
<button class="tab-btn" data-market="US_NYSE" onclick="selectMarket(this)">US_NYSE</button>
<button class="tab-btn" data-market="JP" onclick="selectMarket(this)">JP</button>
<button class="tab-btn" data-market="HK" onclick="selectMarket(this)">HK</button>
</div>
</div>
<table class="decisions-table">
<thead>
<tr>
<th>시각</th>
<th>종목</th>
<th>액션</th>
<th>신뢰도</th>
<th>사유</th>
</tr>
</thead>
<tbody id="decisions-body">
<tr class="empty-row"><td colspan="5"><span class="spinner"></span></td></tr>
</tbody>
</table>
</div>
<!-- playbook panel -->
<div class="panel playbook-panel">
<div class="panel-header">
<span class="panel-title">&#x1F4CB; 프리마켓 플레이북</span>
<div class="panel-controls">
<select id="pb-market-select" onchange="fetchPlaybook()">
<option value="KR">KR</option>
<option value="US_NASDAQ">US_NASDAQ</option>
<option value="US_NYSE">US_NYSE</option>
</select>
<span id="pb-date" class="panel-date"></span>
</div>
</div>
<div id="playbook-content"><p class="empty-msg">데이터 없음</p></div>
</div>
<!-- scorecard panel -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">&#x1F4CA; 일간 스코어카드</span>
<div class="panel-controls">
<select id="sc-market-select" onchange="fetchScorecard()">
<option value="KR">KR</option>
<option value="US_NASDAQ">US_NASDAQ</option>
</select>
<span id="sc-date" class="panel-date"></span>
</div>
</div>
<div id="scorecard-grid" class="scorecard-grid"><p class="empty-msg">데이터 없음</p></div>
</div>
<!-- scenarios panel -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">&#x1F3AF; 활성 시나리오 매칭</span>
<div class="panel-controls">
<select id="scen-market-select" onchange="fetchScenarios()">
<option value="KR">KR</option>
<option value="US_NASDAQ">US_NASDAQ</option>
</select>
</div>
</div>
<div id="scenarios-content"><p class="empty-msg">데이터 없음</p></div>
</div>
<!-- context layer panel -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">&#x1F9E0; 컨텍스트 트리</span>
<div class="panel-controls">
<select id="ctx-layer-select" onchange="fetchContext()">
<option value="L7_REALTIME">L7_REALTIME</option>
<option value="L6_DAILY">L6_DAILY</option>
<option value="L5_WEEKLY">L5_WEEKLY</option>
<option value="L4_MONTHLY">L4_MONTHLY</option>
<option value="L3_QUARTERLY">L3_QUARTERLY</option>
<option value="L2_YEARLY">L2_YEARLY</option>
<option value="L1_LIFETIME">L1_LIFETIME</option>
</select>
<input id="ctx-limit" type="number" value="20" min="1" max="200"
style="width:60px;" onchange="fetchContext()">
</div>
</div>
<div id="context-content"><p class="empty-msg">데이터 없음</p></div>
</div>
</div>
<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);
renderModeBadge(d.mode);
} catch {}
}
function renderModeBadge(mode) {
const el = document.getElementById('mode-badge');
if (!el) return;
if (mode === 'live') {
el.textContent = '🔴 실전투자';
el.className = 'mode-badge live';
} else {
el.textContent = '🟡 모의투자';
el.className = 'mode-badge paper';
}
}
async function fetchPerformance() {
try {
const r = await fetch('/api/performance?market=all');
if (!r.ok) return;
const d = await r.json();
const c = d.combined || {};
document.getElementById('card-winrate').textContent = c.win_rate !== undefined ? `${c.win_rate}%` : '--';
document.getElementById('card-total').textContent = c.total_trades ?? '--';
} catch {}
}
async function fetchPnlHistory(days) {
try {
const r = await fetch(`/api/pnl/history?days=${days}`);
if (!r.ok) throw new Error('fetch failed');
const d = await r.json();
renderChart(d);
} catch {
document.getElementById('chart-error').style.display = 'block';
}
}
function renderChart(data) {
const errEl = document.getElementById('chart-error');
if (!data.labels || data.labels.length === 0) {
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
const colors = data.pnl.map(v => v >= 0 ? 'rgba(60,179,113,0.75)' : 'rgba(224,85,85,0.75)');
const borderColors = data.pnl.map(v => v >= 0 ? '#3cb371' : '#e05555');
if (pnlChart) { pnlChart.destroy(); pnlChart = null; }
const ctx = document.getElementById('pnl-chart').getContext('2d');
pnlChart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Daily P&L',
data: data.pnl,
backgroundColor: colors,
borderColor: borderColors,
borderWidth: 1,
borderRadius: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => {
const v = ctx.parsed.y;
const sign = v >= 0 ? '+' : '';
const trades = data.trades[ctx.dataIndex];
return [`P&L: ${sign}${v.toFixed(2)}`, `거래: ${trades}`];
}
}
}
},
scales: {
x: {
ticks: { color: '#9fb3c8', font: { size: 10 }, maxRotation: 0 },
grid: { color: 'rgba(40,69,95,0.4)' }
},
y: {
ticks: { color: '#9fb3c8', font: { size: 10 } },
grid: { color: 'rgba(40,69,95,0.4)' }
}
}
}
});
}
async function fetchDecisions(market) {
const tbody = document.getElementById('decisions-body');
tbody.innerHTML = '<tr class="empty-row"><td colspan="5"><span class="spinner"></span></td></tr>';
try {
const r = await fetch(`/api/decisions?market=${market}&limit=50`);
if (!r.ok) throw new Error('fetch failed');
const d = await r.json();
if (!d.decisions || d.decisions.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">결정 로그 없음</td></tr>';
return;
}
tbody.innerHTML = d.decisions.map(dec => `
<tr>
<td>${fmt(dec.timestamp)}</td>
<td>${dec.stock_code || '--'}</td>
<td>${badge(dec.action)}</td>
<td>${confBar(dec.confidence)}</td>
<td class="rationale-cell" title="${(dec.rationale || '').replace(/"/g, '&quot;')}">${dec.rationale || '--'}</td>
</tr>
`).join('');
} catch {
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">데이터 로드 실패</td></tr>';
}
}
function selectDays(btn) {
document.querySelectorAll('.day-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentDays = parseInt(btn.dataset.days, 10);
fetchPnlHistory(currentDays);
}
function selectMarket(btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentMarket = btn.dataset.market;
fetchDecisions(currentMarket);
}
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function fetchJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
async function fetchPlaybook() {
const market = document.getElementById('pb-market-select').value;
const date = todayStr();
document.getElementById('pb-date').textContent = date;
const el = document.getElementById('playbook-content');
try {
const data = await fetchJSON(`/api/playbook/${date}?market=${market}`);
const stocks = data.stock_playbooks ?? [];
if (stocks.length === 0) {
el.innerHTML = '<p class="empty-msg">오늘 플레이북 없음</p>';
return;
}
el.innerHTML = stocks.map(sp =>
`<details><summary>${esc(sp.stock_code ?? '?')}${esc(sp.signal ?? '')}</summary>` +
`<pre>${esc(JSON.stringify(sp, null, 2))}</pre></details>`
).join('');
} catch {
el.innerHTML = '<p class="empty-msg">플레이북 없음 (오늘 미생성 또는 API 오류)</p>';
}
}
async function fetchScorecard() {
const market = document.getElementById('sc-market-select').value;
const date = todayStr();
document.getElementById('sc-date').textContent = date;
const el = document.getElementById('scorecard-grid');
try {
const data = await fetchJSON(`/api/scorecard/${date}?market=${market}`);
const sc = data.scorecard ?? {};
const entries = Object.entries(sc);
if (entries.length === 0) {
el.innerHTML = '<p class="empty-msg">스코어카드 없음</p>';
return;
}
el.className = 'scorecard-grid';
el.innerHTML = entries.map(([k, v]) => `
<div class="kpi-card">
<div class="kpi-label">${esc(k)}</div>
<div class="kpi-value">${typeof v === 'number' ? v.toFixed(2) : esc(String(v))}</div>
</div>`).join('');
} catch {
el.innerHTML = '<p class="empty-msg">스코어카드 없음 (오늘 미생성 또는 API 오류)</p>';
}
}
async function fetchScenarios() {
const market = document.getElementById('scen-market-select').value;
const date = todayStr();
const el = document.getElementById('scenarios-content');
try {
const data = await fetchJSON(`/api/scenarios/active?market=${market}&date_str=${date}&limit=50`);
const matches = data.matches ?? [];
if (matches.length === 0) {
el.innerHTML = '<p class="empty-msg">활성 시나리오 없음</p>';
return;
}
el.innerHTML = `<table class="scenarios-table">
<thead><tr><th>종목</th><th>신호</th><th>신뢰도</th><th>매칭 조건</th></tr></thead>
<tbody>${matches.map(m => `
<tr>
<td>${esc(m.stock_code)}</td>
<td>${esc(m.signal ?? '-')}</td>
<td>${esc(m.confidence ?? '-')}</td>
<td><code style="font-size:11px">${esc(JSON.stringify(m.scenario_match ?? {}))}</code></td>
</tr>`).join('')}
</tbody></table>`;
} catch {
el.innerHTML = '<p class="empty-msg">데이터 없음</p>';
}
}
async function fetchContext() {
const layer = document.getElementById('ctx-layer-select').value;
const limit = Math.min(Math.max(parseInt(document.getElementById('ctx-limit').value, 10) || 20, 1), 200);
const el = document.getElementById('context-content');
try {
const data = await fetchJSON(`/api/context/${layer}?limit=${limit}`);
const entries = data.entries ?? [];
if (entries.length === 0) {
el.innerHTML = '<p class="empty-msg">컨텍스트 없음</p>';
return;
}
el.innerHTML = `<table class="context-table">
<thead><tr><th>timeframe</th><th>key</th><th>value</th><th>updated</th></tr></thead>
<tbody>${entries.map(e => `
<tr>
<td>${esc(e.timeframe)}</td>
<td>${esc(e.key)}</td>
<td><div class="context-value">${esc(JSON.stringify(e.value ?? e.raw_value))}</div></td>
<td style="font-size:11px;color:var(--muted)">${esc((e.updated_at ?? '').slice(0, 16))}</td>
</tr>`).join('')}
</tbody></table>`;
} catch {
el.innerHTML = '<p class="empty-msg">데이터 없음</p>';
}
}
async function refreshAll() {
document.getElementById('last-updated').textContent = '업데이트 중...';
await Promise.all([
fetchStatus(),
fetchPerformance(),
fetchPositions(),
fetchPnlHistory(currentDays),
fetchDecisions(currentMarket),
fetchPlaybook(),
fetchScorecard(),
fetchScenarios(),
fetchContext(),
]);
const now = new Date();
const timeStr = now.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
document.getElementById('last-updated').textContent = `마지막 업데이트: ${timeStr}`;
}
// Initial load
refreshAll();
// Auto-refresh every 30 seconds
setInterval(refreshAll, 30000);
</script>
</body> </body>
</html> </html>

View File

@@ -14,6 +14,11 @@ def init_db(db_path: str) -> sqlite3.Connection:
if db_path != ":memory:": if db_path != ":memory:":
Path(db_path).parent.mkdir(parents=True, exist_ok=True) Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
# Enable WAL mode for concurrent read/write (dashboard + trading loop).
# WAL does not apply to in-memory databases.
if db_path != ":memory:":
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS trades ( CREATE TABLE IF NOT EXISTS trades (
@@ -28,12 +33,13 @@ def init_db(db_path: str) -> sqlite3.Connection:
pnl REAL DEFAULT 0.0, pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR', market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX', exchange_code TEXT DEFAULT 'KRX',
decision_id TEXT decision_id TEXT,
mode TEXT DEFAULT 'paper'
) )
""" """
) )
# Migration: Add market and exchange_code columns if they don't exist # Migration: Add columns if they don't exist (backward-compatible schema upgrades)
cursor = conn.execute("PRAGMA table_info(trades)") cursor = conn.execute("PRAGMA table_info(trades)")
columns = {row[1] for row in cursor.fetchall()} columns = {row[1] for row in cursor.fetchall()}
@@ -45,6 +51,8 @@ def init_db(db_path: str) -> sqlite3.Connection:
conn.execute("ALTER TABLE trades ADD COLUMN selection_context TEXT") conn.execute("ALTER TABLE trades ADD COLUMN selection_context TEXT")
if "decision_id" not in columns: if "decision_id" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN decision_id TEXT") conn.execute("ALTER TABLE trades ADD COLUMN decision_id TEXT")
if "mode" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN mode TEXT DEFAULT 'paper'")
# Context tree tables for multi-layered memory management # Context tree tables for multi-layered memory management
conn.execute( conn.execute(
@@ -131,6 +139,25 @@ def init_db(db_path: str) -> sqlite3.Connection:
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_decision_logs_confidence ON decision_logs(confidence)" "CREATE INDEX IF NOT EXISTS idx_decision_logs_confidence ON decision_logs(confidence)"
) )
# Index for open-position queries (partition by stock_code, market, ordered by timestamp)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_trades_stock_market_ts"
" ON trades (stock_code, market, timestamp DESC)"
)
# Lightweight key-value store for trading system runtime metrics (dashboard use only)
# Intentionally separate from the AI context tree to preserve separation of concerns.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS system_metrics (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.commit() conn.commit()
return conn return conn
@@ -148,6 +175,7 @@ def log_trade(
exchange_code: str = "KRX", exchange_code: str = "KRX",
selection_context: dict[str, any] | None = None, selection_context: dict[str, any] | None = None,
decision_id: str | None = None, decision_id: str | None = None,
mode: str = "paper",
) -> None: ) -> None:
"""Insert a trade record into the database. """Insert a trade record into the database.
@@ -163,6 +191,8 @@ def log_trade(
market: Market code market: Market code
exchange_code: Exchange code exchange_code: Exchange code
selection_context: Scanner selection data (RSI, volume_ratio, signal, score) selection_context: Scanner selection data (RSI, volume_ratio, signal, score)
decision_id: Unique decision identifier for audit linking
mode: Trading mode ('paper' or 'live') for data separation
""" """
# Serialize selection context to JSON # Serialize selection context to JSON
context_json = json.dumps(selection_context) if selection_context else None context_json = json.dumps(selection_context) if selection_context else None
@@ -171,9 +201,10 @@ def log_trade(
""" """
INSERT INTO trades ( INSERT INTO trades (
timestamp, stock_code, action, confidence, rationale, timestamp, stock_code, action, confidence, rationale,
quantity, price, pnl, market, exchange_code, selection_context, decision_id quantity, price, pnl, market, exchange_code, selection_context, decision_id,
mode
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
datetime.now(UTC).isoformat(), datetime.now(UTC).isoformat(),
@@ -188,6 +219,7 @@ def log_trade(
exchange_code, exchange_code,
context_json, context_json,
decision_id, decision_id,
mode,
), ),
) )
conn.commit() conn.commit()
@@ -214,3 +246,42 @@ def get_latest_buy_trade(
if not row: if not row:
return None return None
return {"decision_id": row[0], "price": row[1], "quantity": row[2]} return {"decision_id": row[0], "price": row[1], "quantity": row[2]}
def get_open_position(
conn: sqlite3.Connection, stock_code: str, market: str
) -> dict[str, Any] | None:
"""Return open position if latest trade is BUY, else None."""
cursor = conn.execute(
"""
SELECT action, decision_id, price, quantity
FROM trades
WHERE stock_code = ?
AND market = ?
ORDER BY timestamp DESC
LIMIT 1
""",
(stock_code, market),
)
row = cursor.fetchone()
if not row or row[0] != "BUY":
return None
return {"decision_id": row[1], "price": row[2], "quantity": row[3]}
def get_recent_symbols(
conn: sqlite3.Connection, market: str, limit: int = 30
) -> list[str]:
"""Return recent unique symbols for a market, newest first."""
cursor = conn.execute(
"""
SELECT stock_code, MAX(timestamp) AS last_ts
FROM trades
WHERE market = ?
GROUP BY stock_code
ORDER BY last_ts DESC
LIMIT ?
""",
(market, limit),
)
return [row[0] for row in cursor.fetchall() if row and row[0]]

File diff suppressed because it is too large Load Diff

View File

@@ -123,6 +123,23 @@ MARKETS: dict[str, MarketInfo] = {
), ),
} }
MARKET_SHORTHAND: dict[str, list[str]] = {
"US": ["US_NASDAQ", "US_NYSE", "US_AMEX"],
"CN": ["CN_SHA", "CN_SZA"],
"VN": ["VN_HAN", "VN_HCM"],
}
def expand_market_codes(codes: list[str]) -> list[str]:
"""Expand shorthand market codes into concrete exchange market codes."""
expanded: list[str] = []
for code in codes:
if code in MARKET_SHORTHAND:
expanded.extend(MARKET_SHORTHAND[code])
else:
expanded.append(code)
return expanded
def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool: def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool:
""" """

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

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

View File

@@ -2,6 +2,10 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.brain.gemini_client import GeminiClient from src.brain.gemini_client import GeminiClient
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -89,9 +93,21 @@ class TestMalformedJsonHandling:
def test_json_with_missing_fields_returns_hold(self, settings): def test_json_with_missing_fields_returns_hold(self, settings):
client = GeminiClient(settings) client = GeminiClient(settings)
decision = client.parse_response('{"action": "BUY"}') raw = '{"action": "BUY"}'
decision = client.parse_response(raw)
assert decision.action == "HOLD" assert decision.action == "HOLD"
assert decision.confidence == 0 assert decision.confidence == 0
# rationale preserves raw so prompt_override callers (e.g. pre_market_planner)
# can extract non-TradeDecision JSON from decision.rationale (#245)
assert decision.rationale == raw
def test_non_trade_decision_json_preserves_raw_in_rationale(self, settings):
"""Playbook JSON (no action/confidence/rationale) must be preserved for planner."""
client = GeminiClient(settings)
playbook_json = '{"market_outlook": "neutral", "stocks": []}'
decision = client.parse_response(playbook_json)
assert decision.action == "HOLD"
assert decision.rationale == playbook_json
def test_json_with_invalid_action_returns_hold(self, settings): def test_json_with_invalid_action_returns_hold(self, settings):
client = GeminiClient(settings) client = GeminiClient(settings)
@@ -270,3 +286,132 @@ 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."
playbook_json = '{"market_outlook": "neutral", "stocks": []}'
mock_response = MagicMock()
mock_response.text = playbook_json
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,
}
decision = 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
# Raw response preserved in rationale without parse_response (#247)
assert decision.rationale == playbook_json
@pytest.mark.asyncio
async def test_prompt_override_skips_parse_response(self, settings):
"""prompt_override bypasses parse_response — no Missing fields warning, raw preserved."""
client = GeminiClient(settings)
client._enable_optimization = True
custom_prompt = "Custom playbook prompt"
playbook_json = '{"market_outlook": "bullish", "stocks": [{"stock_code": "AAPL"}]}'
mock_response = MagicMock()
mock_response.text = playbook_json
with patch.object(
client._client.aio.models,
"generate_content",
new_callable=AsyncMock,
return_value=mock_response,
):
with patch.object(client, "parse_response") as mock_parse:
market_data = {
"stock_code": "PLANNER",
"current_price": 0,
"prompt_override": custom_prompt,
}
decision = await client.decide(market_data)
# parse_response must NOT be called for prompt_override
mock_parse.assert_not_called()
# Raw playbook JSON preserved in rationale
assert decision.rationale == playbook_json
@pytest.mark.asyncio
async def test_prompt_override_takes_priority_over_optimization(self, settings):
"""prompt_override must win over enable_optimization=True."""
client = GeminiClient(settings)
client._enable_optimization = True
custom_prompt = "Explicit playbook prompt"
mock_response = MagicMock()
mock_response.text = '{"market_outlook": "neutral", "stocks": []}'
with patch.object(
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
)
# The custom prompt must be used, not the compressed prompt
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,647 @@ 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"
# 실전 API는 4자리("0000") 거부 — 1자리("0")여야 한다 (#240)
assert params.get("fid_rank_sort_cls_code") == "0"
@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
@pytest.mark.asyncio
async def test_fluctuation_parses_stck_shrn_iscd(self, broker: KISBroker) -> None:
"""실전 API는 mksc_shrn_iscd 대신 stck_shrn_iscd를 반환한다 (#240)."""
items = [
{
"stck_shrn_iscd": "015260",
"hts_kor_isnm": "에이엔피",
"stck_prpr": "794",
"acml_vol": "4896196",
"prdy_ctrt": "29.74",
"vol_inrt": "0",
}
]
mock_resp = _make_ranking_mock(items)
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
result = await broker.fetch_market_rankings(ranking_type="fluctuation")
assert len(result) == 1
assert result[0]["stock_code"] == "015260"
assert result[0]["change_rate"] == 29.74
# ---------------------------------------------------------------------------
# KRX tick unit / round-down helpers (issue #157)
# ---------------------------------------------------------------------------
from src.broker.kis_api import kr_tick_unit, kr_round_down # noqa: E402
class TestKrTickUnit:
"""kr_tick_unit and kr_round_down must implement KRX price tick rules."""
@pytest.mark.parametrize(
"price, expected_tick",
[
(1999, 1),
(2000, 5),
(4999, 5),
(5000, 10),
(19999, 10),
(20000, 50),
(49999, 50),
(50000, 100),
(199999, 100),
(200000, 500),
(499999, 500),
(500000, 1000),
(1000000, 1000),
],
)
def test_tick_unit_boundaries(self, price: int, expected_tick: int) -> None:
assert kr_tick_unit(price) == expected_tick
@pytest.mark.parametrize(
"price, expected_rounded",
[
(188150, 188100), # 100원 단위, 50원 잔여 → 내림
(188100, 188100), # 이미 정렬됨
(75050, 75000), # 100원 단위, 50원 잔여 → 내림
(49950, 49950), # 50원 단위 정렬됨
(49960, 49950), # 50원 단위, 10원 잔여 → 내림
(1999, 1999), # 1원 단위 → 그대로
(5003, 5000), # 10원 단위, 3원 잔여 → 내림
],
)
def test_round_down_to_tick(self, price: int, expected_rounded: int) -> None:
assert kr_round_down(price) == expected_rounded
# ---------------------------------------------------------------------------
# get_current_price (issue #157)
# ---------------------------------------------------------------------------
class TestGetCurrentPrice:
"""get_current_price must use inquire-price API and return (price, change, foreigner)."""
@pytest.fixture
def broker(self, settings) -> KISBroker:
b = KISBroker(settings)
b._access_token = "tok"
b._token_expires_at = float("inf")
b._rate_limiter.acquire = AsyncMock()
return b
@pytest.mark.asyncio
async def test_returns_correct_fields(self, broker: KISBroker) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={
"rt_cd": "0",
"output": {
"stck_prpr": "188600",
"prdy_ctrt": "3.97",
"frgn_ntby_qty": "12345",
},
}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
price, change_pct, foreigner = await broker.get_current_price("005930")
assert price == 188600.0
assert change_pct == 3.97
assert foreigner == 12345.0
call_kwargs = mock_get.call_args
url = call_kwargs[0][0] if call_kwargs[0] else call_kwargs[1].get("url", "")
headers = call_kwargs[1].get("headers", {})
assert "inquire-price" in url
assert headers.get("tr_id") == "FHKST01010100"
@pytest.mark.asyncio
async def test_http_error_raises_connection_error(self, broker: KISBroker) -> None:
mock_resp = AsyncMock()
mock_resp.status = 500
mock_resp.text = AsyncMock(return_value="Internal Server Error")
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
with pytest.raises(ConnectionError, match="get_current_price failed"):
await broker.get_current_price("005930")
# ---------------------------------------------------------------------------
# send_order tick rounding and ORD_DVSN (issue #157)
# ---------------------------------------------------------------------------
class TestSendOrderTickRounding:
"""send_order must apply KRX tick rounding and correct ORD_DVSN codes."""
@pytest.fixture
def broker(self, settings) -> KISBroker:
b = KISBroker(settings)
b._access_token = "tok"
b._token_expires_at = float("inf")
b._rate_limiter.acquire = AsyncMock()
return b
@pytest.mark.asyncio
async def test_limit_order_rounds_down_to_tick(self, broker: KISBroker) -> None:
"""Price 188150 (not on 100-won tick) must be rounded to 188100."""
mock_hash = AsyncMock()
mock_hash.status = 200
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
mock_hash.__aexit__ = AsyncMock(return_value=False)
mock_order = AsyncMock()
mock_order.status = 200
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "BUY", 1, price=188150)
order_call = mock_post.call_args_list[1]
body = order_call[1].get("json", {})
assert body["ORD_UNPR"] == "188100" # rounded down
assert body["ORD_DVSN"] == "00" # 지정가
@pytest.mark.asyncio
async def test_limit_order_ord_dvsn_is_00(self, broker: KISBroker) -> None:
"""send_order with price>0 must use ORD_DVSN='00' (지정가)."""
mock_hash = AsyncMock()
mock_hash.status = 200
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
mock_hash.__aexit__ = AsyncMock(return_value=False)
mock_order = AsyncMock()
mock_order.status = 200
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "BUY", 1, price=50000)
order_call = mock_post.call_args_list[1]
body = order_call[1].get("json", {})
assert body["ORD_DVSN"] == "00"
@pytest.mark.asyncio
async def test_market_order_ord_dvsn_is_01(self, broker: KISBroker) -> None:
"""send_order with price=0 must use ORD_DVSN='01' (시장가)."""
mock_hash = AsyncMock()
mock_hash.status = 200
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
mock_hash.__aexit__ = AsyncMock(return_value=False)
mock_order = AsyncMock()
mock_order.status = 200
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "SELL", 1, price=0)
order_call = mock_post.call_args_list[1]
body = order_call[1].get("json", {})
assert body["ORD_DVSN"] == "01"
# ---------------------------------------------------------------------------
# TR_ID live/paper branching (issues #201, #202, #203)
# ---------------------------------------------------------------------------
class TestTRIDBranchingDomestic:
"""get_balance and send_order must use correct TR_ID for live vs paper mode."""
def _make_broker(self, settings, mode: str) -> KISBroker:
from src.config import Settings
s = Settings(
KIS_APP_KEY=settings.KIS_APP_KEY,
KIS_APP_SECRET=settings.KIS_APP_SECRET,
KIS_ACCOUNT_NO=settings.KIS_ACCOUNT_NO,
GEMINI_API_KEY=settings.GEMINI_API_KEY,
DB_PATH=":memory:",
ENABLED_MARKETS="KR",
MODE=mode,
)
b = KISBroker(s)
b._access_token = "tok"
b._token_expires_at = float("inf")
b._rate_limiter.acquire = AsyncMock()
return b
@pytest.mark.asyncio
async def test_get_balance_paper_uses_vttc8434r(self, settings) -> None:
broker = self._make_broker(settings, "paper")
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={"output1": [], "output2": {}}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
await broker.get_balance()
headers = mock_get.call_args[1].get("headers", {})
assert headers["tr_id"] == "VTTC8434R"
@pytest.mark.asyncio
async def test_get_balance_live_uses_tttc8434r(self, settings) -> None:
broker = self._make_broker(settings, "live")
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={"output1": [], "output2": {}}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
await broker.get_balance()
headers = mock_get.call_args[1].get("headers", {})
assert headers["tr_id"] == "TTTC8434R"
@pytest.mark.asyncio
async def test_send_order_buy_paper_uses_vttc0012u(self, settings) -> None:
broker = self._make_broker(settings, "paper")
mock_hash = AsyncMock()
mock_hash.status = 200
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
mock_hash.__aexit__ = AsyncMock(return_value=False)
mock_order = AsyncMock()
mock_order.status = 200
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "BUY", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "VTTC0012U"
@pytest.mark.asyncio
async def test_send_order_buy_live_uses_tttc0012u(self, settings) -> None:
broker = self._make_broker(settings, "live")
mock_hash = AsyncMock()
mock_hash.status = 200
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
mock_hash.__aexit__ = AsyncMock(return_value=False)
mock_order = AsyncMock()
mock_order.status = 200
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "BUY", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "TTTC0012U"
@pytest.mark.asyncio
async def test_send_order_sell_paper_uses_vttc0011u(self, settings) -> None:
broker = self._make_broker(settings, "paper")
mock_hash = AsyncMock()
mock_hash.status = 200
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
mock_hash.__aexit__ = AsyncMock(return_value=False)
mock_order = AsyncMock()
mock_order.status = 200
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "SELL", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "VTTC0011U"
@pytest.mark.asyncio
async def test_send_order_sell_live_uses_tttc0011u(self, settings) -> None:
broker = self._make_broker(settings, "live")
mock_hash = AsyncMock()
mock_hash.status = 200
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
mock_hash.__aexit__ = AsyncMock(return_value=False)
mock_order = AsyncMock()
mock_order.status = 200
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "SELL", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "TTTC0011U"
# ---------------------------------------------------------------------------
# Domestic Pending Orders (get_domestic_pending_orders)
# ---------------------------------------------------------------------------
class TestGetDomesticPendingOrders:
"""get_domestic_pending_orders must return [] in paper mode and call TTTC0084R in live."""
def _make_broker(self, settings, mode: str) -> KISBroker:
from src.config import Settings
s = Settings(
KIS_APP_KEY=settings.KIS_APP_KEY,
KIS_APP_SECRET=settings.KIS_APP_SECRET,
KIS_ACCOUNT_NO=settings.KIS_ACCOUNT_NO,
GEMINI_API_KEY=settings.GEMINI_API_KEY,
DB_PATH=":memory:",
ENABLED_MARKETS="KR",
MODE=mode,
)
b = KISBroker(s)
b._access_token = "tok"
b._token_expires_at = float("inf")
b._rate_limiter.acquire = AsyncMock()
return b
@pytest.mark.asyncio
async def test_paper_mode_returns_empty(self, settings) -> None:
"""Paper mode must return [] immediately without any API call."""
broker = self._make_broker(settings, "paper")
with patch("aiohttp.ClientSession.get") as mock_get:
result = await broker.get_domestic_pending_orders()
assert result == []
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_live_mode_calls_tttc0084r_with_correct_params(
self, settings
) -> None:
"""Live mode must call TTTC0084R with INQR_DVSN_1/2 and paging params."""
broker = self._make_broker(settings, "live")
pending = [{"odno": "001", "pdno": "005930", "psbl_qty": "10"}]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": pending})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
result = await broker.get_domestic_pending_orders()
assert result == pending
headers = mock_get.call_args[1].get("headers", {})
assert headers["tr_id"] == "TTTC0084R"
params = mock_get.call_args[1].get("params", {})
assert params["INQR_DVSN_1"] == "0"
assert params["INQR_DVSN_2"] == "0"
@pytest.mark.asyncio
async def test_live_mode_connection_error(self, settings) -> None:
"""Network error must raise ConnectionError."""
import aiohttp as _aiohttp
broker = self._make_broker(settings, "live")
with patch(
"aiohttp.ClientSession.get",
side_effect=_aiohttp.ClientError("timeout"),
):
with pytest.raises(ConnectionError):
await broker.get_domestic_pending_orders()
# ---------------------------------------------------------------------------
# Domestic Order Cancellation (cancel_domestic_order)
# ---------------------------------------------------------------------------
class TestCancelDomesticOrder:
"""cancel_domestic_order must use correct TR_ID and build body correctly."""
def _make_broker(self, settings, mode: str) -> KISBroker:
from src.config import Settings
s = Settings(
KIS_APP_KEY=settings.KIS_APP_KEY,
KIS_APP_SECRET=settings.KIS_APP_SECRET,
KIS_ACCOUNT_NO=settings.KIS_ACCOUNT_NO,
GEMINI_API_KEY=settings.GEMINI_API_KEY,
DB_PATH=":memory:",
ENABLED_MARKETS="KR",
MODE=mode,
)
b = KISBroker(s)
b._access_token = "tok"
b._token_expires_at = float("inf")
b._rate_limiter.acquire = AsyncMock()
return b
def _make_post_mocks(self, order_payload: dict) -> tuple:
mock_hash = AsyncMock()
mock_hash.status = 200
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
mock_hash.__aexit__ = AsyncMock(return_value=False)
mock_order = AsyncMock()
mock_order.status = 200
mock_order.json = AsyncMock(return_value=order_payload)
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
return mock_hash, mock_order
@pytest.mark.asyncio
async def test_live_uses_tttc0013u(self, settings) -> None:
"""Live mode must use TR_ID TTTC0013U."""
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "TTTC0013U"
@pytest.mark.asyncio
async def test_paper_uses_vttc0013u(self, settings) -> None:
"""Paper mode must use TR_ID VTTC0013U."""
broker = self._make_broker(settings, "paper")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert order_headers["tr_id"] == "VTTC0013U"
@pytest.mark.asyncio
async def test_cancel_sets_rvse_cncl_dvsn_cd_02(self, settings) -> None:
"""Body must have RVSE_CNCL_DVSN_CD='02' (취소) and QTY_ALL_ORD_YN='Y'."""
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5)
body = mock_post.call_args_list[1][1].get("json", {})
assert body["RVSE_CNCL_DVSN_CD"] == "02"
assert body["QTY_ALL_ORD_YN"] == "Y"
assert body["ORD_UNPR"] == "0"
@pytest.mark.asyncio
async def test_cancel_sets_krx_fwdg_ord_orgno_in_body(self, settings) -> None:
"""Body must include KRX_FWDG_ORD_ORGNO and ORGN_ODNO from arguments."""
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD123", "BRN456", 3)
body = mock_post.call_args_list[1][1].get("json", {})
assert body["KRX_FWDG_ORD_ORGNO"] == "BRN456"
assert body["ORGN_ODNO"] == "ORD123"
assert body["ORD_QTY"] == "3"
@pytest.mark.asyncio
async def test_cancel_sets_hashkey_header(self, settings) -> None:
"""Request must include hashkey header (same pattern as send_order)."""
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 2)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
assert "hashkey" in order_headers
assert order_headers["hashkey"] == "h"

View File

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

View File

@@ -16,6 +16,10 @@ from src.evolution.daily_review import DailyReviewer
from src.evolution.scorecard import DailyScorecard from src.evolution.scorecard import DailyScorecard
from src.logging.decision_logger import DecisionLogger from src.logging.decision_logger import DecisionLogger
from datetime import UTC, datetime
TODAY = datetime.now(UTC).strftime("%Y-%m-%d")
@pytest.fixture @pytest.fixture
def db_conn() -> sqlite3.Connection: def db_conn() -> sqlite3.Connection:
@@ -116,7 +120,7 @@ def test_generate_scorecard_market_scoped(
exchange_code="NASDAQ", exchange_code="NASDAQ",
) )
scorecard = reviewer.generate_scorecard("2026-02-14", "KR") scorecard = reviewer.generate_scorecard(TODAY, "KR")
assert scorecard.market == "KR" assert scorecard.market == "KR"
assert scorecard.total_decisions == 2 assert scorecard.total_decisions == 2
@@ -158,7 +162,7 @@ def test_generate_scorecard_top_winners_and_losers(
decision_id=decision_id, decision_id=decision_id,
) )
scorecard = reviewer.generate_scorecard("2026-02-14", "KR") scorecard = reviewer.generate_scorecard(TODAY, "KR")
assert scorecard.top_winners == ["005930", "000660"] assert scorecard.top_winners == ["005930", "000660"]
assert scorecard.top_losers == ["035420", "051910"] assert scorecard.top_losers == ["035420", "051910"]
@@ -167,7 +171,7 @@ def test_generate_scorecard_empty_day(
db_conn: sqlite3.Connection, context_store: ContextStore, db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None: ) -> None:
reviewer = DailyReviewer(db_conn, context_store) reviewer = DailyReviewer(db_conn, context_store)
scorecard = reviewer.generate_scorecard("2026-02-14", "KR") scorecard = reviewer.generate_scorecard(TODAY, "KR")
assert scorecard.total_decisions == 0 assert scorecard.total_decisions == 0
assert scorecard.total_pnl == 0.0 assert scorecard.total_pnl == 0.0

View File

@@ -1,21 +1,25 @@
"""Tests for FastAPI dashboard endpoints.""" """Tests for dashboard endpoint handlers."""
from __future__ import annotations from __future__ import annotations
import json import json
import sqlite3 import sqlite3
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any
import pytest import pytest
from fastapi import HTTPException
pytest.importorskip("fastapi") from fastapi.responses import FileResponse
from fastapi.testclient import TestClient
from src.dashboard.app import create_dashboard_app from src.dashboard.app import create_dashboard_app
from src.db import init_db from src.db import init_db
def _seed_db(conn: sqlite3.Connection) -> None: def _seed_db(conn: sqlite3.Connection) -> None:
today = datetime.now(UTC).date().isoformat()
conn.execute( conn.execute(
""" """
INSERT INTO playbooks ( INSERT INTO playbooks (
@@ -34,6 +38,24 @@ def _seed_db(conn: sqlite3.Connection) -> None:
1, 1,
), ),
) )
conn.execute(
"""
INSERT INTO playbooks (
date, market, status, playbook_json, generated_at,
token_count, scenario_count, match_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
today,
"US_NASDAQ",
"ready",
json.dumps({"market": "US_NASDAQ", "stock_playbooks": []}),
f"{today}T08:30:00+00:00",
100,
1,
0,
),
)
conn.execute( conn.execute(
""" """
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at) INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
@@ -71,7 +93,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
""", """,
( (
"d-kr-1", "d-kr-1",
"2026-02-14T09:10:00+00:00", f"{today}T09:10:00+00:00",
"005930", "005930",
"KR", "KR",
"KRX", "KRX",
@@ -91,9 +113,9 @@ def _seed_db(conn: sqlite3.Connection) -> None:
""", """,
( (
"d-us-1", "d-us-1",
"2026-02-14T21:10:00+00:00", f"{today}T21:10:00+00:00",
"AAPL", "AAPL",
"US", "US_NASDAQ",
"NASDAQ", "NASDAQ",
"SELL", "SELL",
80, 80,
@@ -110,7 +132,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
"2026-02-14T09:11:00+00:00", f"{today}T09:11:00+00:00",
"005930", "005930",
"BUY", "BUY",
85, 85,
@@ -132,7 +154,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
"2026-02-14T21:11:00+00:00", f"{today}T21:11:00+00:00",
"AAPL", "AAPL",
"SELL", "SELL",
80, 80,
@@ -140,7 +162,7 @@ def _seed_db(conn: sqlite3.Connection) -> None:
1, 1,
200, 200,
-1.0, -1.0,
"US", "US_NASDAQ",
"NASDAQ", "NASDAQ",
None, None,
"d-us-1", "d-us-1",
@@ -149,122 +171,281 @@ def _seed_db(conn: sqlite3.Connection) -> None:
conn.commit() conn.commit()
def _client(tmp_path: Path) -> TestClient: def _app(tmp_path: Path) -> Any:
db_path = tmp_path / "dashboard_test.db" db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path)) conn = init_db(str(db_path))
_seed_db(conn) _seed_db(conn)
conn.close() conn.close()
app = create_dashboard_app(str(db_path)) return create_dashboard_app(str(db_path))
return TestClient(app)
def _endpoint(app: Any, path: str) -> Callable[..., Any]:
for route in app.routes:
if getattr(route, "path", None) == path:
return route.endpoint
raise AssertionError(f"route not found: {path}")
def test_index_serves_html(tmp_path: Path) -> None: def test_index_serves_html(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/") index = _endpoint(app, "/")
assert resp.status_code == 200 resp = index()
assert "The Ouroboros Dashboard API" in resp.text assert isinstance(resp, FileResponse)
assert "index.html" in str(resp.path)
def test_status_endpoint(tmp_path: Path) -> None: def test_status_endpoint(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/status") get_status = _endpoint(app, "/api/status")
assert resp.status_code == 200 body = get_status()
body = resp.json()
assert "KR" in body["markets"] assert "KR" in body["markets"]
assert "US" in body["markets"] assert "US_NASDAQ" in body["markets"]
assert "totals" in body assert "totals" in body
def test_playbook_found(tmp_path: Path) -> None: def test_playbook_found(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/playbook/2026-02-14?market=KR") get_playbook = _endpoint(app, "/api/playbook/{date_str}")
assert resp.status_code == 200 body = get_playbook("2026-02-14", market="KR")
assert resp.json()["market"] == "KR" assert body["market"] == "KR"
def test_playbook_not_found(tmp_path: Path) -> None: def test_playbook_not_found(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/playbook/2026-02-15?market=KR") get_playbook = _endpoint(app, "/api/playbook/{date_str}")
assert resp.status_code == 404 with pytest.raises(HTTPException, match="playbook not found"):
get_playbook("2026-02-15", market="KR")
def test_scorecard_found(tmp_path: Path) -> None: def test_scorecard_found(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/scorecard/2026-02-14?market=KR") get_scorecard = _endpoint(app, "/api/scorecard/{date_str}")
assert resp.status_code == 200 body = get_scorecard("2026-02-14", market="KR")
assert resp.json()["scorecard"]["total_pnl"] == 1.5 assert body["scorecard"]["total_pnl"] == 1.5
def test_scorecard_not_found(tmp_path: Path) -> None: def test_scorecard_not_found(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/scorecard/2026-02-15?market=KR") get_scorecard = _endpoint(app, "/api/scorecard/{date_str}")
assert resp.status_code == 404 with pytest.raises(HTTPException, match="scorecard not found"):
get_scorecard("2026-02-15", market="KR")
def test_performance_all(tmp_path: Path) -> None: def test_performance_all(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/performance?market=all") get_performance = _endpoint(app, "/api/performance")
assert resp.status_code == 200 body = get_performance(market="all")
body = resp.json()
assert body["market"] == "all" assert body["market"] == "all"
assert body["combined"]["total_trades"] == 2 assert body["combined"]["total_trades"] == 2
assert len(body["by_market"]) == 2 assert len(body["by_market"]) == 2
def test_performance_market_filter(tmp_path: Path) -> None: def test_performance_market_filter(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/performance?market=KR") get_performance = _endpoint(app, "/api/performance")
assert resp.status_code == 200 body = get_performance(market="KR")
body = resp.json()
assert body["market"] == "KR" assert body["market"] == "KR"
assert body["metrics"]["total_trades"] == 1 assert body["metrics"]["total_trades"] == 1
def test_performance_empty_market(tmp_path: Path) -> None: def test_performance_empty_market(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/performance?market=JP") get_performance = _endpoint(app, "/api/performance")
assert resp.status_code == 200 body = get_performance(market="JP")
assert resp.json()["metrics"]["total_trades"] == 0 assert body["metrics"]["total_trades"] == 0
def test_context_layer_all(tmp_path: Path) -> None: def test_context_layer_all(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/context/L7_REALTIME") get_context_layer = _endpoint(app, "/api/context/{layer}")
assert resp.status_code == 200 body = get_context_layer("L7_REALTIME", timeframe=None, limit=100)
body = resp.json()
assert body["layer"] == "L7_REALTIME" assert body["layer"] == "L7_REALTIME"
assert body["count"] == 1 assert body["count"] == 1
def test_context_layer_timeframe_filter(tmp_path: Path) -> None: def test_context_layer_timeframe_filter(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/context/L6_DAILY?timeframe=2026-02-14") get_context_layer = _endpoint(app, "/api/context/{layer}")
assert resp.status_code == 200 body = get_context_layer("L6_DAILY", timeframe="2026-02-14", limit=100)
body = resp.json()
assert body["count"] == 1 assert body["count"] == 1
assert body["entries"][0]["key"] == "scorecard_KR" assert body["entries"][0]["key"] == "scorecard_KR"
def test_decisions_endpoint(tmp_path: Path) -> None: def test_decisions_endpoint(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/decisions?market=KR") get_decisions = _endpoint(app, "/api/decisions")
assert resp.status_code == 200 body = get_decisions(market="KR", limit=50)
body = resp.json()
assert body["count"] == 1 assert body["count"] == 1
assert body["decisions"][0]["decision_id"] == "d-kr-1" assert body["decisions"][0]["decision_id"] == "d-kr-1"
def test_scenarios_active_filters_non_matched(tmp_path: Path) -> None: def test_scenarios_active_filters_non_matched(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/scenarios/active?market=KR&date_str=2026-02-14") get_active_scenarios = _endpoint(app, "/api/scenarios/active")
assert resp.status_code == 200 body = get_active_scenarios(
body = resp.json() market="KR",
date_str=datetime.now(UTC).date().isoformat(),
limit=50,
)
assert body["count"] == 1 assert body["count"] == 1
assert body["matches"][0]["stock_code"] == "005930" assert body["matches"][0]["stock_code"] == "005930"
def test_scenarios_active_empty_when_no_matches(tmp_path: Path) -> None: def test_scenarios_active_empty_when_no_matches(tmp_path: Path) -> None:
client = _client(tmp_path) app = _app(tmp_path)
resp = client.get("/api/scenarios/active?market=US&date_str=2026-02-14") get_active_scenarios = _endpoint(app, "/api/scenarios/active")
assert resp.status_code == 200 body = get_active_scenarios(market="US", date_str="2026-02-14", limit=50)
assert resp.json()["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
def test_status_mode_paper(tmp_path: Path) -> None:
"""mode=paper로 생성하면 status 응답에 mode=paper가 포함돼야 한다."""
db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path))
_seed_db(conn)
conn.close()
app = create_dashboard_app(str(db_path), mode="paper")
get_status = _endpoint(app, "/api/status")
body = get_status()
assert body["mode"] == "paper"
def test_status_mode_live(tmp_path: Path) -> None:
"""mode=live로 생성하면 status 응답에 mode=live가 포함돼야 한다."""
db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path))
_seed_db(conn)
conn.close()
app = create_dashboard_app(str(db_path), mode="live")
get_status = _endpoint(app, "/api/status")
body = get_status()
assert body["mode"] == "live"
def test_status_mode_default_paper(tmp_path: Path) -> None:
"""mode 파라미터 미전달 시 기본값은 paper여야 한다."""
db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path))
_seed_db(conn)
conn.close()
app = create_dashboard_app(str(db_path))
get_status = _endpoint(app, "/api/status")
body = get_status()
assert body["mode"] == "paper"

195
tests/test_db.py Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import pytest
from src.markets.schedule import ( from src.markets.schedule import (
MARKETS, MARKETS,
expand_market_codes,
get_next_market_open, get_next_market_open,
get_open_markets, get_open_markets,
is_market_open, is_market_open,
@@ -199,3 +200,28 @@ class TestGetNextMarketOpen:
enabled_markets=["INVALID", "KR"], now=test_time enabled_markets=["INVALID", "KR"], now=test_time
) )
assert market.code == "KR" assert market.code == "KR"
class TestExpandMarketCodes:
"""Test shorthand market expansion."""
def test_expand_us_shorthand(self) -> None:
assert expand_market_codes(["US"]) == ["US_NASDAQ", "US_NYSE", "US_AMEX"]
def test_expand_cn_shorthand(self) -> None:
assert expand_market_codes(["CN"]) == ["CN_SHA", "CN_SZA"]
def test_expand_vn_shorthand(self) -> None:
assert expand_market_codes(["VN"]) == ["VN_HAN", "VN_HCM"]
def test_expand_mixed_codes(self) -> None:
assert expand_market_codes(["KR", "US", "JP"]) == [
"KR",
"US_NASDAQ",
"US_NYSE",
"US_AMEX",
"JP",
]
def test_expand_preserves_unknown_code(self) -> None:
assert expand_market_codes(["KR", "UNKNOWN"]) == ["KR", "UNKNOWN"]

File diff suppressed because it is too large Load Diff

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

@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
from src.analysis.volatility import VolatilityAnalyzer from src.analysis.volatility import VolatilityAnalyzer
from src.broker.kis_api import KISBroker from src.broker.kis_api import KISBroker
from src.broker.overseas import OverseasBroker
from src.config import Settings from src.config import Settings
@@ -43,61 +44,70 @@ def scanner(mock_broker: MagicMock, mock_settings: Settings) -> SmartVolatilityS
analyzer = VolatilityAnalyzer() analyzer = VolatilityAnalyzer()
return SmartVolatilityScanner( return SmartVolatilityScanner(
broker=mock_broker, broker=mock_broker,
overseas_broker=None,
volatility_analyzer=analyzer, volatility_analyzer=analyzer,
settings=mock_settings, settings=mock_settings,
) )
@pytest.fixture
def mock_overseas_broker() -> MagicMock:
"""Create mock overseas broker."""
broker = MagicMock(spec=OverseasBroker)
broker.get_overseas_price = AsyncMock()
broker.fetch_overseas_rankings = AsyncMock(return_value=[])
return broker
class TestSmartVolatilityScanner: class TestSmartVolatilityScanner:
"""Test suite for SmartVolatilityScanner.""" """Test suite for SmartVolatilityScanner."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scan_finds_oversold_candidates( async def test_scan_domestic_prefers_volatility_with_liquidity_bonus(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None: ) -> None:
"""Test that scanner identifies oversold stocks with high volume.""" """Domestic scan should score by volatility first and volume rank second."""
# Mock rankings fluctuation_rows = [
mock_broker.fetch_market_rankings.return_value = [
{ {
"stock_code": "005930", "stock_code": "005930",
"name": "Samsung", "name": "Samsung",
"price": 70000, "price": 70000,
"volume": 5000000, "volume": 5000000,
"change_rate": -3.5, "change_rate": -5.0,
"volume_increase_rate": 250, "volume_increase_rate": 250,
}, },
{
"stock_code": "035420",
"name": "NAVER",
"price": 250000,
"volume": 3000000,
"change_rate": 3.0,
"volume_increase_rate": 200,
},
]
volume_rows = [
{"stock_code": "035420", "name": "NAVER", "price": 250000, "volume": 3000000},
{"stock_code": "005930", "name": "Samsung", "price": 70000, "volume": 5000000},
]
mock_broker.fetch_market_rankings.side_effect = [fluctuation_rows, volume_rows]
mock_broker.get_daily_prices.return_value = [
{"open": 1, "high": 1, "low": 1, "close": 1, "volume": 1000000},
{"open": 1, "high": 1, "low": 1, "close": 1, "volume": 1000000},
] ]
# Mock daily prices - trending down (oversold)
prices = []
for i in range(20):
prices.append({
"date": f"2026020{i:02d}",
"open": 75000 - i * 200,
"high": 75500 - i * 200,
"low": 74500 - i * 200,
"close": 75000 - i * 250, # Steady decline
"volume": 2000000,
})
mock_broker.get_daily_prices.return_value = prices
candidates = await scanner.scan() candidates = await scanner.scan()
# Should find at least one candidate (depending on exact RSI calculation) assert len(candidates) >= 1
mock_broker.fetch_market_rankings.assert_called_once() # Samsung has higher absolute move, so it should lead despite lower volume rank bonus.
mock_broker.get_daily_prices.assert_called_once_with("005930", days=20) assert candidates[0].stock_code == "005930"
assert candidates[0].signal == "oversold"
# If qualified, should have oversold signal
if candidates:
assert candidates[0].signal in ["oversold", "momentum"]
assert candidates[0].volume_ratio >= scanner.vol_multiplier
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scan_finds_momentum_candidates( async def test_scan_domestic_finds_momentum_candidate(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None: ) -> None:
"""Test that scanner identifies momentum stocks with high volume.""" """Positive change should be represented as momentum signal."""
mock_broker.fetch_market_rankings.return_value = [ fluctuation_rows = [
{ {
"stock_code": "035420", "stock_code": "035420",
"name": "NAVER", "name": "NAVER",
@@ -107,124 +117,67 @@ class TestSmartVolatilityScanner:
"volume_increase_rate": 300, "volume_increase_rate": 300,
}, },
] ]
mock_broker.fetch_market_rankings.side_effect = [fluctuation_rows, fluctuation_rows]
# Mock daily prices - trending up (momentum) mock_broker.get_daily_prices.return_value = [
prices = [] {"open": 1, "high": 1, "low": 1, "close": 1, "volume": 1000000},
for i in range(20): {"open": 1, "high": 1, "low": 1, "close": 1, "volume": 1000000},
prices.append({ ]
"date": f"2026020{i:02d}",
"open": 230000 + i * 500,
"high": 231000 + i * 500,
"low": 229000 + i * 500,
"close": 230500 + i * 500, # Steady rise
"volume": 1000000,
})
mock_broker.get_daily_prices.return_value = prices
candidates = await scanner.scan() candidates = await scanner.scan()
mock_broker.fetch_market_rankings.assert_called_once() assert [c.stock_code for c in candidates] == ["035420"]
assert candidates[0].signal == "momentum"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scan_filters_low_volume( async def test_scan_domestic_filters_low_volatility(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None: ) -> None:
"""Test that stocks with low volume ratio are filtered out.""" """Domestic scan should drop symbols below volatility threshold."""
mock_broker.fetch_market_rankings.return_value = [ fluctuation_rows = [
{ {
"stock_code": "000660", "stock_code": "000660",
"name": "SK Hynix", "name": "SK Hynix",
"price": 150000, "price": 150000,
"volume": 500000, "volume": 500000,
"change_rate": -5.0, "change_rate": 0.2,
"volume_increase_rate": 50, # Only 50% increase (< 200%) "volume_increase_rate": 50,
}, },
] ]
mock_broker.fetch_market_rankings.side_effect = [fluctuation_rows, fluctuation_rows]
# Low volume mock_broker.get_daily_prices.return_value = [
prices = [] {"open": 1, "high": 150100, "low": 149900, "close": 150000, "volume": 1000000},
for i in range(20): {"open": 1, "high": 150100, "low": 149900, "close": 150000, "volume": 1000000},
prices.append({ ]
"date": f"2026020{i:02d}",
"open": 150000 - i * 100,
"high": 151000 - i * 100,
"low": 149000 - i * 100,
"close": 150000 - i * 150, # Declining (would be oversold)
"volume": 1000000, # Current 500k < 2x prev day 1M
})
mock_broker.get_daily_prices.return_value = prices
candidates = await scanner.scan() candidates = await scanner.scan()
# Should be filtered out due to low volume ratio
assert len(candidates) == 0
@pytest.mark.asyncio
async def test_scan_filters_neutral_rsi(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None:
"""Test that stocks with neutral RSI are filtered out."""
mock_broker.fetch_market_rankings.return_value = [
{
"stock_code": "051910",
"name": "LG Chem",
"price": 500000,
"volume": 3000000,
"change_rate": 0.5,
"volume_increase_rate": 300, # High volume
},
]
# Flat prices (neutral RSI ~50)
prices = []
for i in range(20):
prices.append({
"date": f"2026020{i:02d}",
"open": 500000 + (i % 2) * 100, # Small oscillation
"high": 500500,
"low": 499500,
"close": 500000 + (i % 2) * 50,
"volume": 1000000,
})
mock_broker.get_daily_prices.return_value = prices
candidates = await scanner.scan()
# Should be filtered out (RSI ~50, not < 30 or > 70)
assert len(candidates) == 0 assert len(candidates) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scan_uses_fallback_on_api_error( async def test_scan_uses_fallback_on_api_error(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None: ) -> None:
"""Test fallback to static list when ranking API fails.""" """Domestic scan should remain operational using fallback symbols."""
mock_broker.fetch_market_rankings.side_effect = ConnectionError("API unavailable") mock_broker.fetch_market_rankings.side_effect = [
ConnectionError("API unavailable"),
# Fallback stocks should still be analyzed ConnectionError("API unavailable"),
prices = [] ]
for i in range(20): mock_broker.get_daily_prices.return_value = [
prices.append({ {"open": 1, "high": 103, "low": 97, "close": 100, "volume": 1000000},
"date": f"2026020{i:02d}", {"open": 1, "high": 103, "low": 97, "close": 100, "volume": 800000},
"open": 50000 - i * 50, ]
"high": 51000 - i * 50,
"low": 49000 - i * 50,
"close": 50000 - i * 75, # Declining
"volume": 1000000,
})
mock_broker.get_daily_prices.return_value = prices
candidates = await scanner.scan(fallback_stocks=["005930", "000660"]) candidates = await scanner.scan(fallback_stocks=["005930", "000660"])
# Should not crash
assert isinstance(candidates, list) assert isinstance(candidates, list)
assert len(candidates) >= 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scan_returns_top_n_only( async def test_scan_returns_top_n_only(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None: ) -> None:
"""Test that scan returns at most top_n candidates.""" """Test that scan returns at most top_n candidates."""
# Return many stocks fluctuation_rows = [
mock_broker.fetch_market_rankings.return_value = [
{ {
"stock_code": f"00{i}000", "stock_code": f"00{i}000",
"name": f"Stock{i}", "name": f"Stock{i}",
@@ -235,62 +188,17 @@ class TestSmartVolatilityScanner:
} }
for i in range(1, 10) for i in range(1, 10)
] ]
mock_broker.fetch_market_rankings.side_effect = [fluctuation_rows, fluctuation_rows]
# All oversold with high volume mock_broker.get_daily_prices.return_value = [
def make_prices(code: str) -> list[dict]: {"open": 1, "high": 105, "low": 95, "close": 100, "volume": 1000000},
prices = [] {"open": 1, "high": 105, "low": 95, "close": 100, "volume": 900000},
for i in range(20): ]
prices.append({
"date": f"2026020{i:02d}",
"open": 10000 - i * 100,
"high": 10500 - i * 100,
"low": 9500 - i * 100,
"close": 10000 - i * 150,
"volume": 1000000,
})
return prices
mock_broker.get_daily_prices.side_effect = make_prices
candidates = await scanner.scan() candidates = await scanner.scan()
# Should respect top_n limit (3) # Should respect top_n limit (3)
assert len(candidates) <= scanner.top_n assert len(candidates) <= scanner.top_n
@pytest.mark.asyncio
async def test_scan_skips_insufficient_price_history(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None:
"""Test that stocks with insufficient history are skipped."""
mock_broker.fetch_market_rankings.return_value = [
{
"stock_code": "005930",
"name": "Samsung",
"price": 70000,
"volume": 5000000,
"change_rate": -5.0,
"volume_increase_rate": 300,
},
]
# Only 5 days of data (need 15+ for RSI)
mock_broker.get_daily_prices.return_value = [
{
"date": f"2026020{i:02d}",
"open": 70000,
"high": 71000,
"low": 69000,
"close": 70000,
"volume": 2000000,
}
for i in range(5)
]
candidates = await scanner.scan()
# Should skip due to insufficient data
assert len(candidates) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_stock_codes( async def test_get_stock_codes(
self, scanner: SmartVolatilityScanner self, scanner: SmartVolatilityScanner
@@ -323,6 +231,160 @@ class TestSmartVolatilityScanner:
assert codes == ["005930", "035420"] assert codes == ["005930", "035420"]
@pytest.mark.asyncio
async def test_scan_overseas_uses_dynamic_symbols(
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, mock_settings: Settings
) -> None:
"""Overseas scan should use provided dynamic universe symbols."""
analyzer = VolatilityAnalyzer()
scanner = SmartVolatilityScanner(
broker=mock_broker,
overseas_broker=mock_overseas_broker,
volatility_analyzer=analyzer,
settings=mock_settings,
)
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
mock_overseas_broker.get_overseas_price.side_effect = [
{"output": {"last": "210.5", "rate": "1.6", "tvol": "1500000"}},
{"output": {"last": "330.1", "rate": "0.2", "tvol": "900000"}},
]
candidates = await scanner.scan(
market=market,
fallback_stocks=["AAPL", "MSFT"],
)
assert [c.stock_code for c in candidates] == ["AAPL"]
assert candidates[0].signal == "momentum"
assert candidates[0].price == 210.5
@pytest.mark.asyncio
async def test_scan_overseas_uses_ranking_api_first(
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, mock_settings: Settings
) -> None:
"""Overseas scan should prioritize ranking API when available."""
analyzer = VolatilityAnalyzer()
scanner = SmartVolatilityScanner(
broker=mock_broker,
overseas_broker=mock_overseas_broker,
volatility_analyzer=analyzer,
settings=mock_settings,
)
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
mock_overseas_broker.fetch_overseas_rankings.return_value = [
{"symb": "NVDA", "last": "780.2", "rate": "2.4", "tvol": "1200000"},
{"symb": "MSFT", "last": "420.0", "rate": "0.3", "tvol": "900000"},
]
candidates = await scanner.scan(market=market, fallback_stocks=["AAPL", "TSLA"])
assert mock_overseas_broker.fetch_overseas_rankings.call_count >= 1
mock_overseas_broker.get_overseas_price.assert_not_called()
assert [c.stock_code for c in candidates] == ["NVDA"]
@pytest.mark.asyncio
async def test_scan_overseas_without_symbols_returns_empty(
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, mock_settings: Settings
) -> None:
"""Overseas scan should return empty list when no symbol universe exists."""
analyzer = VolatilityAnalyzer()
scanner = SmartVolatilityScanner(
broker=mock_broker,
overseas_broker=mock_overseas_broker,
volatility_analyzer=analyzer,
settings=mock_settings,
)
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
candidates = await scanner.scan(market=market, fallback_stocks=[])
assert candidates == []
@pytest.mark.asyncio
async def test_scan_overseas_picks_high_intraday_range_even_with_low_change(
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, mock_settings: Settings
) -> None:
"""Volatility selection should consider intraday range, not only change rate."""
analyzer = VolatilityAnalyzer()
scanner = SmartVolatilityScanner(
broker=mock_broker,
overseas_broker=mock_overseas_broker,
volatility_analyzer=analyzer,
settings=mock_settings,
)
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
# change rate is tiny, but high-low range is large (15%).
mock_overseas_broker.fetch_overseas_rankings.return_value = [
{
"symb": "ABCD",
"last": "100",
"rate": "0.2",
"high": "110",
"low": "95",
"tvol": "800000",
}
]
candidates = await scanner.scan(market=market, fallback_stocks=[])
assert [c.stock_code for c in candidates] == ["ABCD"]
class TestImpliedRSIFormula:
"""Test the implied_rsi formula in SmartVolatilityScanner (issue #181)."""
def test_neutral_change_gives_neutral_rsi(self) -> None:
"""0% change → implied_rsi = 50 (neutral)."""
# formula: 50 + (change_rate * 2.0)
rsi = max(0.0, min(100.0, 50.0 + (0.0 * 2.0)))
assert rsi == 50.0
def test_10pct_change_gives_rsi_70(self) -> None:
"""10% upward change → implied_rsi = 70 (momentum signal)."""
rsi = max(0.0, min(100.0, 50.0 + (10.0 * 2.0)))
assert rsi == 70.0
def test_minus_10pct_gives_rsi_30(self) -> None:
"""-10% change → implied_rsi = 30 (oversold signal)."""
rsi = max(0.0, min(100.0, 50.0 + (-10.0 * 2.0)))
assert rsi == 30.0
def test_saturation_at_25pct(self) -> None:
"""Saturation occurs at >=25% change (not 12.5% as with old coefficient 4.0)."""
rsi_12pct = max(0.0, min(100.0, 50.0 + (12.5 * 2.0)))
rsi_25pct = max(0.0, min(100.0, 50.0 + (25.0 * 2.0)))
rsi_30pct = max(0.0, min(100.0, 50.0 + (30.0 * 2.0)))
# At 12.5% change: RSI = 75 (not 100, unlike old formula)
assert rsi_12pct == 75.0
# At 25%+ saturation
assert rsi_25pct == 100.0
assert rsi_30pct == 100.0 # Capped
def test_negative_saturation(self) -> None:
"""Saturation at -25% gives RSI = 0."""
rsi = max(0.0, min(100.0, 50.0 + (-25.0 * 2.0)))
assert rsi == 0.0
class TestRSICalculation: class TestRSICalculation:
"""Test RSI calculation in VolatilityAnalyzer.""" """Test RSI calculation in VolatilityAnalyzer."""

View File

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

View File

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

View File

@@ -682,6 +682,10 @@ class TestBasicCommands:
"/help - Show available commands\n" "/help - Show available commands\n"
"/status - Trading status (mode, markets, P&L)\n" "/status - Trading status (mode, markets, P&L)\n"
"/positions - Current holdings\n" "/positions - Current holdings\n"
"/report - Daily summary report\n"
"/scenarios - Today's playbook scenarios\n"
"/review - Recent scorecards\n"
"/dashboard - Dashboard URL/status\n"
"/stop - Pause trading\n" "/stop - Pause trading\n"
"/resume - Resume trading" "/resume - Resume trading"
) )
@@ -707,10 +711,106 @@ class TestBasicCommands:
assert "/help" in payload["text"] assert "/help" in payload["text"]
assert "/status" in payload["text"] assert "/status" in payload["text"]
assert "/positions" in payload["text"] assert "/positions" in payload["text"]
assert "/report" in payload["text"]
assert "/scenarios" in payload["text"]
assert "/review" in payload["text"]
assert "/dashboard" in payload["text"]
assert "/stop" in payload["text"] assert "/stop" in payload["text"]
assert "/resume" in payload["text"] assert "/resume" in payload["text"]
class TestExtendedCommands:
"""Test additional bot commands."""
@pytest.mark.asyncio
async def test_report_command(self) -> None:
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_report() -> None:
await client.send_message("<b>📈 Daily Report</b>\n\nTrades: 1")
handler.register_command("report", mock_report)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await handler._handle_update(
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/report"}}
)
payload = mock_post.call_args.kwargs["json"]
assert "Daily Report" in payload["text"]
@pytest.mark.asyncio
async def test_scenarios_command(self) -> None:
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_scenarios() -> None:
await client.send_message("<b>🧠 Today's Scenarios</b>\n\n- AAPL: BUY (85)")
handler.register_command("scenarios", mock_scenarios)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await handler._handle_update(
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/scenarios"}}
)
payload = mock_post.call_args.kwargs["json"]
assert "Today's Scenarios" in payload["text"]
@pytest.mark.asyncio
async def test_review_command(self) -> None:
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_review() -> None:
await client.send_message("<b>📝 Recent Reviews</b>\n\n- 2026-02-14 KR")
handler.register_command("review", mock_review)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await handler._handle_update(
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/review"}}
)
payload = mock_post.call_args.kwargs["json"]
assert "Recent Reviews" in payload["text"]
@pytest.mark.asyncio
async def test_dashboard_command(self) -> None:
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_dashboard() -> None:
await client.send_message("<b>🖥️ Dashboard</b>\n\nURL: http://127.0.0.1:8080")
handler.register_command("dashboard", mock_dashboard)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await handler._handle_update(
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/dashboard"}}
)
payload = mock_post.call_args.kwargs["json"]
assert "Dashboard" in payload["text"]
class TestGetUpdates: class TestGetUpdates:
"""Test getUpdates API interaction.""" """Test getUpdates API interaction."""
@@ -775,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 == [[]]

View File

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