Compare commits

...

11 Commits

Author SHA1 Message Date
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
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
8 changed files with 1196 additions and 32 deletions

View File

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

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

@@ -17,7 +17,7 @@ class Settings(BaseSettings):
# Google Gemini
GEMINI_API_KEY: str
GEMINI_MODEL: str = "gemini-pro"
GEMINI_MODEL: str = "gemini-2.0-flash"
# External Data APIs (optional — for data-driven decisions)
NEWS_API_KEY: str | None = None

View File

@@ -40,7 +40,7 @@ from src.evolution.daily_review import DailyReviewer
from src.evolution.optimizer import EvolutionOptimizer
from src.logging.decision_logger import DecisionLogger
from src.logging_config import setup_logging
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
from src.markets.schedule import MARKETS, MarketInfo, get_next_market_open, get_open_markets
from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
from src.strategy.models import DayPlaybook, MarketOutlook
from src.strategy.playbook_store import PlaybookStore
@@ -129,6 +129,88 @@ async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kw
raise
async def sync_positions_from_broker(
broker: Any,
overseas_broker: Any,
db_conn: Any,
settings: "Settings",
) -> int:
"""Sync open positions from the live broker into the local DB at startup.
Fetches current holdings from the broker for all configured markets and
inserts a synthetic BUY record for any position that the DB does not
already know about. This prevents double-buy when positions were opened
in a previous session or entered manually outside the system.
Returns:
Number of new positions synced.
"""
synced = 0
seen_exchange_codes: set[str] = set()
for market_code in settings.enabled_market_list:
market = MARKETS.get(market_code)
if market is None:
continue
try:
if market.is_domestic:
balance_data = await broker.get_balance()
log_market = market_code # "KR"
else:
if market.exchange_code in seen_exchange_codes:
continue
seen_exchange_codes.add(market.exchange_code)
balance_data = await overseas_broker.get_overseas_balance(
market.exchange_code
)
log_market = market_code # e.g. "US_NASDAQ"
except ConnectionError as exc:
logger.warning(
"Startup sync: balance fetch failed for %s — skipping: %s",
market_code,
exc,
)
continue
held_codes = _extract_held_codes_from_balance(
balance_data, is_domestic=market.is_domestic
)
for stock_code in held_codes:
if get_open_position(db_conn, stock_code, log_market):
continue # already tracked
qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic
)
log_trade(
conn=db_conn,
stock_code=stock_code,
action="BUY",
confidence=0,
rationale="[startup-sync] Position detected from broker at startup",
quantity=qty,
price=0.0,
market=log_market,
exchange_code=market.exchange_code,
mode=settings.MODE,
)
logger.info(
"Startup sync: %s/%s recorded as open position (qty=%d)",
log_market,
stock_code,
qty,
)
synced += 1
if synced:
logger.info(
"Startup sync complete: %d position(s) synced from broker", synced
)
else:
logger.info("Startup sync: no new positions to sync from broker")
return synced
def _extract_symbol_from_holding(item: dict[str, Any]) -> str:
"""Extract symbol from overseas holding payload variants."""
for key in (
@@ -571,11 +653,11 @@ async def trading_cycle(
# BUY 결정 전 기존 포지션 체크 (중복 매수 방지)
if decision.action == "BUY":
existing_position = get_open_position(db_conn, stock_code, market.code)
if not existing_position and not market.is_domestic:
if not existing_position:
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
# 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
# 국내/해외 모두 라이브 브로커 잔고를 authoritative source로 사용.
broker_qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=False
balance_data, stock_code, is_domestic=market.is_domestic
)
if broker_qty > 0:
existing_position = {"price": 0.0, "quantity": broker_qty}
@@ -778,21 +860,23 @@ async def trading_cycle(
price=0, # market order
)
else:
# For overseas orders:
# - KIS VTS only accepts limit orders (지정가만 가능)
# - BUY: use 0.5% premium over last price to improve fill probability
# (ask price is typically slightly above last, and VTS won't fill below ask)
# - SELL: use last price as the limit
# For overseas orders, always use limit orders (지정가):
# - KIS market orders (ORD_DVSN=01) calculate quantity based on upper limit
# price (상한가 기준), resulting in only 60-80% of intended cash being used.
# - BUY: +0.2% above last price — tight enough to minimise overpayment while
# achieving >90% fill rate on large-cap US stocks.
# - SELL: -0.2% below last price — ensures fill even when price dips slightly
# (placing at exact last price risks no-fill if the bid is just below).
if decision.action == "BUY":
order_price = round(current_price * 1.005, 4)
order_price = round(current_price * 1.002, 4)
else:
order_price = current_price
order_price = round(current_price * 0.998, 4)
result = await overseas_broker.send_overseas_order(
exchange_code=market.exchange_code,
stock_code=stock_code,
order_type=decision.action,
quantity=quantity,
price=order_price, # limit order — KIS VTS rejects market orders
price=order_price, # limit order
)
# Check if KIS rejected the order (rt_cd != "0")
if result.get("rt_cd", "") != "0":
@@ -908,18 +992,30 @@ async def run_daily_session(
telegram: TelegramClient,
settings: Settings,
smart_scanner: SmartVolatilityScanner | None = None,
) -> None:
daily_start_eval: float = 0.0,
) -> float:
"""Execute one daily trading session.
V2 proactive strategy: 1 Gemini call for playbook generation,
then local scenario evaluation per stock (0 API calls).
Args:
daily_start_eval: Portfolio evaluation at the start of the trading day.
Used to compute intra-day P&L for the Circuit Breaker.
Pass 0.0 on the first session of each day; the function will set
it from the first balance query and return it for subsequent
sessions.
Returns:
The daily_start_eval value that should be forwarded to the next
session of the same trading day.
"""
# Get currently open markets
open_markets = get_open_markets(settings.enabled_market_list)
if not open_markets:
logger.info("No markets open for this session")
return
return daily_start_eval
logger.info("Starting daily trading session for %d markets", len(open_markets))
@@ -1121,12 +1217,27 @@ async def run_daily_session(
):
total_cash = settings.PAPER_OVERSEAS_CASH
# Calculate daily P&L %
pnl_pct = (
((total_eval - purchase_total) / purchase_total * 100)
if purchase_total > 0
else 0.0
)
# Capture the day's opening portfolio value on the first market processed
# in this session. Used to compute intra-day P&L for the CB instead of
# the cumulative purchase_total which spans the entire account history.
if daily_start_eval <= 0 and total_eval > 0:
daily_start_eval = total_eval
logger.info(
"Daily CB baseline set: total_eval=%.2f (first balance of the day)",
daily_start_eval,
)
# Daily P&L: compare current eval vs start-of-day eval.
# Falls back to purchase_total if daily_start_eval is unavailable (e.g. paper
# mode where balance API returns 0 for all values).
if daily_start_eval > 0:
pnl_pct = (total_eval - daily_start_eval) / daily_start_eval * 100
else:
pnl_pct = (
((total_eval - purchase_total) / purchase_total * 100)
if purchase_total > 0
else 0.0
)
portfolio_data = {
"portfolio_pnl_pct": pnl_pct,
"total_cash": total_cash,
@@ -1160,11 +1271,11 @@ async def run_daily_session(
# BUY 중복 방지: 브로커 잔고 기반 (미체결 SELL 리밋 주문 보호)
if decision.action == "BUY":
daily_existing = get_open_position(db_conn, stock_code, market.code)
if not daily_existing and not market.is_domestic:
if not daily_existing:
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
# 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
# 국내/해외 모두 라이브 브로커 잔고를 authoritative source로 사용.
broker_qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=False
balance_data, stock_code, is_domestic=market.is_domestic
)
if broker_qty > 0:
daily_existing = {"price": 0.0, "quantity": broker_qty}
@@ -1395,6 +1506,7 @@ async def run_daily_session(
)
logger.info("Daily trading session completed")
return daily_start_eval
async def _handle_market_close(
@@ -2012,6 +2124,12 @@ async def run(settings: Settings) -> None:
except Exception as exc:
logger.warning("System startup notification failed: %s", exc)
# Sync broker positions → DB to prevent double-buy on restart
try:
await sync_positions_from_broker(broker, overseas_broker, db_conn, settings)
except Exception as exc:
logger.warning("Startup position sync failed (non-fatal): %s", exc)
# Start command handler
try:
await command_handler.start_polling()
@@ -2030,13 +2148,26 @@ async def run(settings: Settings) -> None:
session_interval = settings.SESSION_INTERVAL_HOURS * 3600 # Convert to seconds
# daily_start_eval: portfolio eval captured at the first session of each
# trading day. Reset on calendar-date change so the CB measures only
# today's drawdown, not cumulative account history.
_cb_daily_start_eval: float = 0.0
_cb_last_date: str = ""
while not shutdown.is_set():
# Wait for trading to be unpaused
await pause_trading.wait()
_run_context_scheduler(context_scheduler, now=datetime.now(UTC))
# Reset intra-day CB baseline on a new calendar date
today_str = datetime.now(UTC).date().isoformat()
if today_str != _cb_last_date:
_cb_last_date = today_str
_cb_daily_start_eval = 0.0
logger.info("New trading day %s — daily CB baseline reset", today_str)
try:
await run_daily_session(
_cb_daily_start_eval = await run_daily_session(
broker,
overseas_broker,
scenario_engine,
@@ -2050,6 +2181,7 @@ async def run(settings: Settings) -> None:
telegram,
settings,
smart_scanner=smart_scanner,
daily_start_eval=_cb_daily_start_eval,
)
except CircuitBreakerTripped:
logger.critical("Circuit breaker tripped — shutting down")

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,9 @@ from src.main import (
_run_context_scheduler,
_run_evolution_loop,
_start_dashboard_server,
run_daily_session,
safe_float,
sync_positions_from_broker,
trading_cycle,
)
from src.strategy.models import (
@@ -1103,10 +1105,11 @@ class TestOverseasBalanceParsing:
mock_telegram: MagicMock,
mock_overseas_market: MagicMock,
) -> None:
"""Overseas BUY order must use current_price (limit), not 0 (market).
"""Overseas BUY order must use current_price +0.2% limit, not market order.
KIS VTS rejects market orders for overseas paper trading.
Regression test for issue #149.
KIS market orders (ORD_DVSN=01) calculate quantity based on upper limit price
(상한가 기준), resulting in only 60-80% of intended cash being used.
Regression test for issue #149 / #211.
"""
mock_telegram.notify_trade_execution = AsyncMock()
@@ -1127,14 +1130,93 @@ class TestOverseasBalanceParsing:
scan_candidates={},
)
# Verify limit order was sent with actual price + 0.5% premium (issue #151), not 0.0
# Verify BUY limit order uses +0.2% premium (issue #211)
mock_overseas_broker_with_buy_scenario.send_overseas_order.assert_called_once()
call_kwargs = mock_overseas_broker_with_buy_scenario.send_overseas_order.call_args
sent_price = call_kwargs[1].get("price") or call_kwargs[0][4]
expected_price = round(182.5 * 1.005, 4) # 0.5% premium for BUY limit orders
expected_price = round(182.5 * 1.002, 4) # 0.2% premium for BUY limit orders
assert sent_price == expected_price, (
f"Expected limit price {expected_price} (182.5 * 1.005) but got {sent_price}. "
"KIS VTS only accepts limit orders; BUY uses 0.5% premium to improve fill rate."
f"Expected limit price {expected_price} (182.5 * 1.002) but got {sent_price}. "
"BUY uses +0.2% to improve fill rate while minimising overpayment (#211)."
)
@pytest.mark.asyncio
async def test_overseas_sell_order_uses_limit_price_below_current(
self,
mock_domestic_broker: MagicMock,
mock_playbook: DayPlaybook,
mock_risk: MagicMock,
mock_db: MagicMock,
mock_decision_logger: MagicMock,
mock_context_store: MagicMock,
mock_criticality_assessor: MagicMock,
mock_telegram: MagicMock,
mock_overseas_market: MagicMock,
) -> None:
"""Overseas SELL order must use current_price -0.2% limit (#211).
Placing SELL at exact last price risks no-fill when the bid is just below.
Using -0.2% ensures the order fills even if the price dips slightly.
"""
sell_price = 182.5
# Broker mock: returns price data and a balance with 5 AAPL shares held.
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": str(sell_price), "rate": "1.5", "tvol": "5000000"}}
)
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [
{
"ovrs_pdno": "AAPL",
"ovrs_cblc_qty": "5",
"pchs_avg_pric": "170.0",
"evlu_pfls_rt": "7.35",
}
],
"output2": [
{
"frcr_evlu_tota": "100000.00",
"frcr_dncl_amt_2": "50000.00",
"frcr_buy_amt_smtl": "50000.00",
}
],
}
)
overseas_broker.send_overseas_order = AsyncMock(
return_value={"rt_cd": "0", "msg1": "OK"}
)
sell_engine = MagicMock(spec=ScenarioEngine)
sell_engine.evaluate = MagicMock(return_value=_make_sell_match("AAPL"))
mock_telegram.notify_trade_execution = AsyncMock()
with patch("src.main.log_trade"), patch("src.main.get_open_position") as mock_pos:
mock_pos.return_value = {"quantity": 5, "stock_code": "AAPL", "price": 170.0}
await trading_cycle(
broker=mock_domestic_broker,
overseas_broker=overseas_broker,
scenario_engine=sell_engine,
playbook=mock_playbook,
risk=mock_risk,
db_conn=mock_db,
decision_logger=mock_decision_logger,
context_store=mock_context_store,
criticality_assessor=mock_criticality_assessor,
telegram=mock_telegram,
market=mock_overseas_market,
stock_code="AAPL",
scan_candidates={},
)
overseas_broker.send_overseas_order.assert_called_once()
call_kwargs = overseas_broker.send_overseas_order.call_args
sent_price = call_kwargs[1].get("price") or call_kwargs[0][4]
expected_price = round(sell_price * 0.998, 4) # -0.2% for SELL limit orders
assert sent_price == expected_price, (
f"Expected SELL limit price {expected_price} (182.5 * 0.998) but got {sent_price}. "
"SELL uses -0.2% to ensure fill even when price dips slightly (#211)."
)
@@ -3271,3 +3353,522 @@ class TestRetryConnection:
await _retry_connection(bad_input, label="bad")
assert call_count == 1 # No retry for non-ConnectionError
# run_daily_session — daily CB baseline (daily_start_eval) tests (issue #207)
# ---------------------------------------------------------------------------
class TestDailyCBBaseline:
"""Tests for run_daily_session's daily_start_eval (CB baseline) behaviour.
Issue #207: CB P&L should be computed relative to the portfolio value at
the start of each trading day, not the cumulative purchase_total.
"""
def _make_settings(self) -> Settings:
return Settings(
KIS_APP_KEY="test-key",
KIS_APP_SECRET="test-secret",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="test-gemini",
MODE="paper",
PAPER_OVERSEAS_CASH=0,
)
def _make_domestic_balance(
self, tot_evlu_amt: float = 0.0, dnca_tot_amt: float = 50000.0
) -> dict:
return {
"output1": [],
"output2": [
{
"tot_evlu_amt": str(tot_evlu_amt),
"dnca_tot_amt": str(dnca_tot_amt),
"pchs_amt_smtl_amt": "40000.0",
}
],
}
@pytest.mark.asyncio
async def test_returns_daily_start_eval_when_no_markets_open(self) -> None:
"""run_daily_session returns the unchanged daily_start_eval when no markets are open."""
with patch("src.main.get_open_markets", return_value=[]):
result = await run_daily_session(
broker=MagicMock(),
overseas_broker=MagicMock(),
scenario_engine=MagicMock(),
playbook_store=MagicMock(),
pre_market_planner=MagicMock(),
risk=MagicMock(),
db_conn=init_db(":memory:"),
decision_logger=MagicMock(),
context_store=MagicMock(),
criticality_assessor=MagicMock(),
telegram=MagicMock(),
settings=self._make_settings(),
smart_scanner=None,
daily_start_eval=12345.0,
)
assert result == 12345.0
@pytest.mark.asyncio
async def test_returns_zero_when_no_markets_and_no_baseline(self) -> None:
"""run_daily_session returns 0.0 when no markets are open and daily_start_eval=0."""
with patch("src.main.get_open_markets", return_value=[]):
result = await run_daily_session(
broker=MagicMock(),
overseas_broker=MagicMock(),
scenario_engine=MagicMock(),
playbook_store=MagicMock(),
pre_market_planner=MagicMock(),
risk=MagicMock(),
db_conn=init_db(":memory:"),
decision_logger=MagicMock(),
context_store=MagicMock(),
criticality_assessor=MagicMock(),
telegram=MagicMock(),
settings=self._make_settings(),
smart_scanner=None,
daily_start_eval=0.0,
)
assert result == 0.0
@pytest.mark.asyncio
async def test_captures_total_eval_as_baseline_on_first_session(self) -> None:
"""When daily_start_eval=0 and balance returns a positive total_eval, the returned
value equals total_eval (the captured baseline for the day)."""
from src.analysis.smart_scanner import ScanCandidate
settings = self._make_settings()
broker = MagicMock()
# Domestic balance: tot_evlu_amt=55000
broker.get_balance = AsyncMock(
return_value=self._make_domestic_balance(tot_evlu_amt=55000.0)
)
# Price data for the stock
broker.get_current_price = AsyncMock(
return_value=(100.0, 1.5, 100.0)
)
market = MagicMock()
market.name = "KR"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
market.timezone = __import__("zoneinfo").ZoneInfo("Asia/Seoul")
smart_scanner = MagicMock()
smart_scanner.scan = AsyncMock(
return_value=[
ScanCandidate(
stock_code="005930",
name="Samsung",
price=100.0,
volume=1_000_000.0,
volume_ratio=2.5,
rsi=45.0,
signal="momentum",
score=80.0,
)
]
)
playbook_store = MagicMock()
playbook_store.load = MagicMock(return_value=_make_playbook("KR"))
scenario_engine = MagicMock(spec=ScenarioEngine)
scenario_engine.evaluate = MagicMock(return_value=_make_hold_match("005930"))
risk = MagicMock()
risk.check_circuit_breaker = MagicMock()
risk.check_fat_finger = MagicMock()
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="d1")
async def _passthrough(fn, *a, label: str = "", **kw): # type: ignore[override]
return await fn(*a, **kw)
with patch("src.main.get_open_markets", return_value=[market]), \
patch("src.main._retry_connection", new=_passthrough):
result = await run_daily_session(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=scenario_engine,
playbook_store=playbook_store,
pre_market_planner=MagicMock(),
risk=risk,
db_conn=init_db(":memory:"),
decision_logger=decision_logger,
context_store=MagicMock(),
criticality_assessor=MagicMock(),
telegram=telegram,
settings=settings,
smart_scanner=smart_scanner,
daily_start_eval=0.0,
)
assert result == 55000.0 # captured from tot_evlu_amt
@pytest.mark.asyncio
async def test_does_not_overwrite_existing_baseline(self) -> None:
"""When daily_start_eval > 0, it must not be overwritten even if balance returns
a different value (baseline is fixed at the start of each trading day)."""
from src.analysis.smart_scanner import ScanCandidate
settings = self._make_settings()
broker = MagicMock()
# Balance reports a different eval value (market moved during the day)
broker.get_balance = AsyncMock(
return_value=self._make_domestic_balance(tot_evlu_amt=58000.0)
)
broker.get_current_price = AsyncMock(return_value=(100.0, 1.5, 100.0))
market = MagicMock()
market.name = "KR"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
market.timezone = __import__("zoneinfo").ZoneInfo("Asia/Seoul")
smart_scanner = MagicMock()
smart_scanner.scan = AsyncMock(
return_value=[
ScanCandidate(
stock_code="005930",
name="Samsung",
price=100.0,
volume=1_000_000.0,
volume_ratio=2.5,
rsi=45.0,
signal="momentum",
score=80.0,
)
]
)
playbook_store = MagicMock()
playbook_store.load = MagicMock(return_value=_make_playbook("KR"))
scenario_engine = MagicMock(spec=ScenarioEngine)
scenario_engine.evaluate = MagicMock(return_value=_make_hold_match("005930"))
risk = MagicMock()
risk.check_circuit_breaker = MagicMock()
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="d1")
async def _passthrough(fn, *a, label: str = "", **kw): # type: ignore[override]
return await fn(*a, **kw)
with patch("src.main.get_open_markets", return_value=[market]), \
patch("src.main._retry_connection", new=_passthrough):
result = await run_daily_session(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=scenario_engine,
playbook_store=playbook_store,
pre_market_planner=MagicMock(),
risk=risk,
db_conn=init_db(":memory:"),
decision_logger=decision_logger,
context_store=MagicMock(),
criticality_assessor=MagicMock(),
telegram=telegram,
settings=settings,
smart_scanner=smart_scanner,
daily_start_eval=55000.0, # existing baseline
)
# Must return the original baseline, NOT the new total_eval (58000)
assert result == 55000.0
# ---------------------------------------------------------------------------
# sync_positions_from_broker — startup DB sync tests (issue #206)
# ---------------------------------------------------------------------------
class TestSyncPositionsFromBroker:
"""Tests for sync_positions_from_broker() startup position sync (issue #206).
The function queries broker balances at startup and inserts synthetic BUY
records for any holdings that the local DB is unaware of, preventing
double-buy when positions were opened in a previous session or manually.
"""
def _make_settings(self, enabled_markets: str = "KR") -> Settings:
return Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
ENABLED_MARKETS=enabled_markets,
MODE="paper",
)
def _domestic_balance(
self,
stock_code: str = "005930",
qty: int = 5,
) -> dict:
return {
"output1": [{"pdno": stock_code, "ord_psbl_qty": str(qty)}],
"output2": [
{
"tot_evlu_amt": "1000000",
"dnca_tot_amt": "500000",
"pchs_amt_smtl_amt": "500000",
}
],
}
def _overseas_balance(
self,
stock_code: str = "AAPL",
qty: int = 10,
) -> dict:
return {
"output1": [{"ovrs_pdno": stock_code, "ovrs_cblc_qty": str(qty)}],
"output2": [
{
"frcr_evlu_tota": "50000",
"frcr_dncl_amt_2": "10000",
"frcr_buy_amt_smtl": "40000",
}
],
}
@pytest.mark.asyncio
async def test_syncs_domestic_position_not_in_db(self) -> None:
"""A domestic holding found in broker but absent from DB is inserted."""
settings = self._make_settings("KR")
db_conn = init_db(":memory:")
broker = MagicMock()
broker.get_balance = AsyncMock(
return_value=self._domestic_balance("005930", qty=7)
)
overseas_broker = MagicMock()
synced = await sync_positions_from_broker(
broker, overseas_broker, db_conn, settings
)
assert synced == 1
from src.db import get_open_position
pos = get_open_position(db_conn, "005930", "KR")
assert pos is not None
assert pos["quantity"] == 7
@pytest.mark.asyncio
async def test_skips_position_already_in_db(self) -> None:
"""No duplicate record is created when the position already exists in DB."""
settings = self._make_settings("KR")
db_conn = init_db(":memory:")
# Pre-insert a BUY record
log_trade(
conn=db_conn,
stock_code="005930",
action="BUY",
confidence=85,
rationale="existing position",
quantity=5,
price=70000.0,
market="KR",
exchange_code="KRX",
)
broker = MagicMock()
broker.get_balance = AsyncMock(
return_value=self._domestic_balance("005930", qty=5)
)
overseas_broker = MagicMock()
synced = await sync_positions_from_broker(
broker, overseas_broker, db_conn, settings
)
assert synced == 0
@pytest.mark.asyncio
async def test_syncs_overseas_position_not_in_db(self) -> None:
"""An overseas holding found in broker but absent from DB is inserted."""
settings = self._make_settings("US_NASDAQ")
db_conn = init_db(":memory:")
broker = MagicMock()
overseas_broker = MagicMock()
overseas_broker.get_overseas_balance = AsyncMock(
return_value=self._overseas_balance("AAPL", qty=10)
)
synced = await sync_positions_from_broker(
broker, overseas_broker, db_conn, settings
)
assert synced == 1
from src.db import get_open_position
pos = get_open_position(db_conn, "AAPL", "US_NASDAQ")
assert pos is not None
assert pos["quantity"] == 10
@pytest.mark.asyncio
async def test_returns_zero_when_broker_has_no_holdings(self) -> None:
"""Returns 0 when broker reports empty holdings."""
settings = self._make_settings("KR")
db_conn = init_db(":memory:")
broker = MagicMock()
broker.get_balance = AsyncMock(
return_value={"output1": [], "output2": [{}]}
)
overseas_broker = MagicMock()
synced = await sync_positions_from_broker(
broker, overseas_broker, db_conn, settings
)
assert synced == 0
@pytest.mark.asyncio
async def test_handles_connection_error_gracefully(self) -> None:
"""ConnectionError during balance fetch is logged but does not raise."""
settings = self._make_settings("KR")
db_conn = init_db(":memory:")
broker = MagicMock()
broker.get_balance = AsyncMock(
side_effect=ConnectionError("KIS unreachable")
)
overseas_broker = MagicMock()
synced = await sync_positions_from_broker(
broker, overseas_broker, db_conn, settings
)
assert synced == 0 # Failure treated as no-op
@pytest.mark.asyncio
async def test_deduplicates_exchange_codes_for_overseas(self) -> None:
"""Each exchange code is queried at most once even if multiple market
codes share the same exchange (defensive deduplication)."""
# Both US_NASDAQ and a hypothetical duplicate would share "NASD"
# Use two DIFFERENT overseas markets (NASD vs NYSE) to verify each is
# queried separately.
settings = self._make_settings("US_NASDAQ,US_NYSE")
db_conn = init_db(":memory:")
broker = MagicMock()
overseas_broker = MagicMock()
overseas_broker.get_overseas_balance = AsyncMock(
return_value={"output1": [], "output2": [{}]}
)
await sync_positions_from_broker(
broker, overseas_broker, db_conn, settings
)
# Two distinct exchange codes (NASD, NYSE) → 2 calls
assert overseas_broker.get_overseas_balance.call_count == 2
# ---------------------------------------------------------------------------
# Domestic BUY double-prevention (issue #206) — trading_cycle integration
# ---------------------------------------------------------------------------
class TestDomesticBuyDoublePreventionTradingCycle:
"""Verify domestic BUY suppression using broker balance in trading_cycle.
Issue #206: the broker-balance check was overseas-only; domestic stocks
were not protected against double-buy caused by untracked positions.
"""
@pytest.mark.asyncio
async def test_domestic_buy_suppressed_when_broker_holds_stock(
self,
) -> None:
"""BUY for a domestic stock must be suppressed when broker holds it,
even if the DB shows no open position."""
db_conn = init_db(":memory:")
# DB: no open position for 005930
broker = MagicMock()
broker.get_current_price = AsyncMock(return_value=(70000.0, 1.0, 0.0))
# Broker balance: holds 5 shares of 005930
broker.get_balance = AsyncMock(
return_value={
"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}],
"output2": [
{
"tot_evlu_amt": "1000000",
"dnca_tot_amt": "500000",
"pchs_amt_smtl_amt": "500000",
}
],
}
)
broker.send_order = AsyncMock(return_value={"msg1": "주문접수"})
market = MagicMock()
market.name = "KR"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match("005930"))
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="d1")
settings = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
MODE="paper",
)
await trading_cycle(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=_make_playbook(market="KR"),
risk=MagicMock(),
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
settings=settings,
market=market,
stock_code="005930",
scan_candidates={"KR": {}},
)
# BUY must NOT have been executed because broker still holds the stock
broker.send_order.assert_not_called()