Compare commits

..

3 Commits

Author SHA1 Message Date
425218c3be Merge pull request 'feat: 국내주식 지정가 전환 및 미체결 처리 (#232)' (#233) from feature/issue-232-domestic-limit-order-pending into feature/issue-229-overseas-pending-order-handling
Reviewed-on: #233
2026-02-23 22:00:46 +09:00
agentson
b4b09a6d4c docs: requirements-log에 #232 국내주식 지정가 전환 기록
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:45:49 +09:00
agentson
66c35da7f1 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 21:44:58 +09:00
19 changed files with 383 additions and 1141 deletions

View File

@@ -322,36 +322,3 @@ Order result: 모의투자 매수주문이 완료 되었습니다. ✓
- `TestHandleDomesticPendingOrders` (4), `TestDomesticLimitOrderPrice` (2) - `TestHandleDomesticPendingOrders` (4), `TestDomesticLimitOrderPrice` (2)
**이슈/PR:** #232, PR #233 **이슈/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

@@ -23,7 +23,7 @@ if [ -z "${APP_CMD:-}" ]; then
dashboard_port="${DASHBOARD_PORT:-8080}" dashboard_port="${DASHBOARD_PORT:-8080}"
APP_CMD="DASHBOARD_PORT=$dashboard_port $PYTHON_BIN -m src.main --mode=live --dashboard" APP_CMD="DASHBOARD_PORT=$dashboard_port $PYTHON_BIN -m src.main --mode=paper --dashboard"
fi fi
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"

View File

@@ -346,10 +346,8 @@ 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=raw action="HOLD", confidence=0, rationale="Missing required fields"
) )
action = str(data["action"]).upper() action = str(data["action"]).upper()
@@ -441,18 +439,6 @@ 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: {"action":"BUY"|"SELL"|"HOLD","confidence":<0-100>,"rationale":"<text>"}\n' 'Return JSON: {"act":"BUY"|"SELL"|"HOLD","conf":<0-100>,"reason":"<text>"}\n'
"Rules: action=BUY/SELL/HOLD, confidence=0-100, rationale=concise. No markdown." "Rules: act=BUY/SELL/HOLD, conf=0-100, reason=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

@@ -430,7 +430,7 @@ class KISBroker:
"fid_cond_mrkt_div_code": "J", "fid_cond_mrkt_div_code": "J",
"fid_cond_scr_div_code": "20170", "fid_cond_scr_div_code": "20170",
"fid_input_iscd": "0000", "fid_input_iscd": "0000",
"fid_rank_sort_cls_code": "0", "fid_rank_sort_cls_code": "0000",
"fid_input_cnt_1": str(limit), "fid_input_cnt_1": str(limit),
"fid_prc_cls_code": "0", "fid_prc_cls_code": "0",
"fid_input_price_1": "0", "fid_input_price_1": "0",
@@ -466,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("stck_shrn_iscd") or item.get("mksc_shrn_iscd", ""), "stock_code": 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")),

View File

@@ -121,7 +121,6 @@ class OverseasBroker:
tr_id = self._broker._settings.OVERSEAS_RANKING_VOLUME_TR_ID tr_id = self._broker._settings.OVERSEAS_RANKING_VOLUME_TR_ID
path = self._broker._settings.OVERSEAS_RANKING_VOLUME_PATH path = self._broker._settings.OVERSEAS_RANKING_VOLUME_PATH
params: dict[str, str] = { params: dict[str, str] = {
"KEYB": "", # NEXT KEY BUFF — Required, 공백
"AUTH": "", "AUTH": "",
"EXCD": ranking_excd, "EXCD": ranking_excd,
"MIXN": "0", "MIXN": "0",
@@ -131,11 +130,10 @@ class OverseasBroker:
tr_id = self._broker._settings.OVERSEAS_RANKING_FLUCT_TR_ID tr_id = self._broker._settings.OVERSEAS_RANKING_FLUCT_TR_ID
path = self._broker._settings.OVERSEAS_RANKING_FLUCT_PATH path = self._broker._settings.OVERSEAS_RANKING_FLUCT_PATH
params = { params = {
"KEYB": "", # NEXT KEY BUFF — Required, 공백
"AUTH": "", "AUTH": "",
"EXCD": ranking_excd, "EXCD": ranking_excd,
"NDAY": "0", "NDAY": "0",
"GUBN": "1", # 0=하락율, 1=상승율 — 변동성 스캐너는 급등 종목 우선 "GUBN": "1",
"VOL_RANG": "0", "VOL_RANG": "0",
} }
@@ -222,59 +220,6 @@ class OverseasBroker:
f"Network error fetching overseas balance: {exc}" f"Network error fetching overseas balance: {exc}"
) from exc ) from exc
async def get_overseas_buying_power(
self,
exchange_code: str,
stock_code: str,
price: float,
) -> dict[str, Any]:
"""
Fetch overseas buying power for a specific stock and price.
Args:
exchange_code: Exchange code (e.g., "NASD", "NYSE")
stock_code: Stock ticker symbol
price: Current stock price (used for quantity calculation)
Returns:
API response; key field: output.ord_psbl_frcr_amt (주문가능외화금액)
Raises:
ConnectionError: On network or API errors
"""
await self._broker._rate_limiter.acquire()
session = self._broker._get_session()
# TR_ID: 실전 TTTS3007R, 모의 VTTS3007R
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 매수가능금액조회' 시트
ps_tr_id = (
"TTTS3007R" if self._broker._settings.MODE == "live" else "VTTS3007R"
)
headers = await self._broker._auth_headers(ps_tr_id)
params = {
"CANO": self._broker._account_no,
"ACNT_PRDT_CD": self._broker._product_cd,
"OVRS_EXCG_CD": exchange_code,
"OVRS_ORD_UNPR": f"{price:.2f}",
"ITEM_CD": stock_code,
}
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-psamount"
)
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_buying_power failed ({resp.status}): {text}"
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(
f"Network error fetching overseas buying power: {exc}"
) from exc
async def send_overseas_order( async def send_overseas_order(
self, self,
exchange_code: str, exchange_code: str,

View File

@@ -13,11 +13,10 @@ from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI: def create_dashboard_app(db_path: str) -> 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:
@@ -112,7 +111,6 @@ def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI:
return { return {
"date": today, "date": today,
"mode": mode,
"markets": market_status, "markets": market_status,
"totals": { "totals": {
"trade_count": total_trades, "trade_count": total_trades,

View File

@@ -43,19 +43,6 @@
font-size: 12px; transition: border-color 0.2s; font-size: 12px; transition: border-color 0.2s;
} }
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); } .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 */
.cb-gauge-wrap { .cb-gauge-wrap {
@@ -238,7 +225,6 @@
<header> <header>
<h1>&#x1F40D; The Ouroboros</h1> <h1>&#x1F40D; The Ouroboros</h1>
<div class="header-right"> <div class="header-right">
<span class="mode-badge" id="mode-badge">--</span>
<div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker"> <div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker">
<span class="cb-dot unknown" id="cb-dot"></span> <span class="cb-dot unknown" id="cb-dot"></span>
<span id="cb-label">CB --</span> <span id="cb-label">CB --</span>
@@ -526,22 +512,9 @@
} }
document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}`; document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}`;
renderCbGauge(d.circuit_breaker); renderCbGauge(d.circuit_breaker);
renderModeBadge(d.mode);
} catch {} } 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() { async function fetchPerformance() {
try { try {
const r = await fetch('/api/performance?market=all'); const r = await fetch('/api/performance?market=all');

View File

@@ -254,11 +254,10 @@ def get_open_position(
"""Return open position if latest trade is BUY, else None.""" """Return open position if latest trade is BUY, else None."""
cursor = conn.execute( cursor = conn.execute(
""" """
SELECT action, decision_id, price, quantity, timestamp SELECT action, decision_id, price, quantity
FROM trades FROM trades
WHERE stock_code = ? WHERE stock_code = ?
AND market = ? AND market = ?
AND action IN ('BUY', 'SELL')
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT 1 LIMIT 1
""", """,
@@ -267,7 +266,7 @@ def get_open_position(
row = cursor.fetchone() row = cursor.fetchone()
if not row or row[0] != "BUY": if not row or row[0] != "BUY":
return None return None
return {"decision_id": row[1], "price": row[2], "quantity": row[3], "timestamp": row[4]} return {"decision_id": row[1], "price": row[2], "quantity": row[3]}
def get_recent_symbols( def get_recent_symbols(

View File

@@ -182,9 +182,6 @@ async def sync_positions_from_broker(
qty = _extract_held_qty_from_balance( qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic balance_data, stock_code, is_domestic=market.is_domestic
) )
avg_price = _extract_avg_price_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic
)
log_trade( log_trade(
conn=db_conn, conn=db_conn,
stock_code=stock_code, stock_code=stock_code,
@@ -192,7 +189,7 @@ async def sync_positions_from_broker(
confidence=0, confidence=0,
rationale="[startup-sync] Position detected from broker at startup", rationale="[startup-sync] Position detected from broker at startup",
quantity=qty, quantity=qty,
price=avg_price, price=0.0,
market=log_market, market=log_market,
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
mode=settings.MODE, mode=settings.MODE,
@@ -260,15 +257,7 @@ def _extract_held_codes_from_balance(
if is_domestic: if is_domestic:
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0) qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
else: else:
# ord_psbl_qty (주문가능수량) is the actual sellable quantity. qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
# ovrs_cblc_qty (해외잔고수량) includes unsettled/expired holdings
# that cannot actually be sold (e.g. expired warrants).
qty = int(
holding.get("ord_psbl_qty")
or holding.get("ovrs_cblc_qty")
or holding.get("hldg_qty")
or 0
)
if qty > 0: if qty > 0:
codes.append(code) codes.append(code)
return codes return codes
@@ -291,12 +280,10 @@ def _extract_held_qty_from_balance(
ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled) ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled)
hldg_qty — 보유수량 (fallback) hldg_qty — 보유수량 (fallback)
Overseas fields (VTTS3012R / TTTS3012R output1): Overseas fields (output1):
ovrs_pdno — 종목코드 ovrs_pdno — 종목코드
ord_psbl_qty 주문가능수량 (preferred: actual sellable qty) ovrs_cblc_qty — 해외잔고수량 (preferred)
ovrs_cblc_qty — 해외잔고수량 (fallback: total holding, may include hldg_qty — 보유수량 (fallback)
unsettled or expired positions with ord_psbl_qty=0)
hldg_qty — 보유수량 (last-resort fallback)
""" """
output1 = balance_data.get("output1", []) output1 = balance_data.get("output1", [])
if isinstance(output1, dict): if isinstance(output1, dict):
@@ -314,47 +301,11 @@ def _extract_held_qty_from_balance(
if is_domestic: if is_domestic:
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0) qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
else: else:
qty = int( qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
holding.get("ord_psbl_qty")
or holding.get("ovrs_cblc_qty")
or holding.get("hldg_qty")
or 0
)
return qty return qty
return 0 return 0
def _extract_avg_price_from_balance(
balance_data: dict[str, Any],
stock_code: str,
*,
is_domestic: bool,
) -> float:
"""Extract the broker-reported average purchase price for a stock.
Uses ``pchs_avg_pric`` (매입평균가격) from the balance response (output1).
Returns 0.0 when absent so callers can use ``if price > 0`` as sentinel.
Domestic fields (VTTC8434R output1): pdno, pchs_avg_pric
Overseas fields (VTTS3012R output1): ovrs_pdno, pchs_avg_pric
"""
output1 = balance_data.get("output1", [])
if isinstance(output1, dict):
output1 = [output1]
if not isinstance(output1, list):
return 0.0
for holding in output1:
if not isinstance(holding, dict):
continue
code_key = "pdno" if is_domestic else "ovrs_pdno"
held_code = str(holding.get(code_key, "")).strip().upper()
if held_code != stock_code.strip().upper():
continue
return safe_float(holding.get("pchs_avg_pric"), 0.0)
return 0.0
def _determine_order_quantity( def _determine_order_quantity(
*, *,
action: str, action: str,
@@ -477,7 +428,6 @@ async def trading_cycle(
cycle_start_time = asyncio.get_event_loop().time() cycle_start_time = asyncio.get_event_loop().time()
# 1. Fetch market data # 1. Fetch market data
price_output: dict[str, Any] = {} # Populated for overseas markets; used for fallback metrics
if market.is_domestic: if market.is_domestic:
current_price, price_change_pct, foreigner_net = await broker.get_current_price( current_price, price_change_pct, foreigner_net = await broker.get_current_price(
stock_code stock_code
@@ -509,44 +459,9 @@ async def trading_cycle(
balance_info = {} balance_info = {}
total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0") total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0")
total_cash = safe_float(balance_info.get("frcr_dncl_amt_2", "0") or "0")
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0") purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
# Resolve current price first (needed for buying power API)
price_output = price_data.get("output", {})
current_price = safe_float(price_output.get("last", "0"))
if current_price <= 0:
market_candidates_lookup = scan_candidates.get(market.code, {})
cand_lookup = market_candidates_lookup.get(stock_code)
if cand_lookup and cand_lookup.price > 0:
logger.debug(
"Price API returned 0 for %s; using scanner candidate price %.4f",
stock_code,
cand_lookup.price,
)
current_price = cand_lookup.price
foreigner_net = 0.0 # Not available for overseas
price_change_pct = safe_float(price_output.get("rate", "0"))
# Fetch available foreign currency cash via inquire-psamount (TTTS3007R/VTTS3007R).
# TTTS3012R output2 does not include a cash/deposit field — frcr_dncl_amt_2 does not exist.
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 매수가능금액조회' 시트
total_cash = 0.0
if current_price > 0:
try:
ps_data = await overseas_broker.get_overseas_buying_power(
market.exchange_code, stock_code, current_price
)
total_cash = safe_float(
ps_data.get("output", {}).get("ovrs_ord_psbl_amt", "0") or "0"
)
except ConnectionError as exc:
logger.warning(
"Could not fetch overseas buying power for %s/%s: %s",
market.exchange_code,
stock_code,
exc,
)
# Paper mode fallback: VTS overseas balance API often fails for many accounts. # Paper mode fallback: VTS overseas balance API often fails for many accounts.
# Only activate in paper mode — live mode must use real balance from KIS. # Only activate in paper mode — live mode must use real balance from KIS.
if ( if (
@@ -562,6 +477,34 @@ async def trading_cycle(
) )
total_cash = settings.PAPER_OVERSEAS_CASH total_cash = settings.PAPER_OVERSEAS_CASH
current_price = safe_float(price_data.get("output", {}).get("last", "0"))
# Fallback: if price API returns 0, use scanner candidate price
if current_price <= 0:
market_candidates_lookup = scan_candidates.get(market.code, {})
cand_lookup = market_candidates_lookup.get(stock_code)
if cand_lookup and cand_lookup.price > 0:
logger.debug(
"Price API returned 0 for %s; using scanner candidate price %.4f",
stock_code,
cand_lookup.price,
)
current_price = cand_lookup.price
foreigner_net = 0.0 # Not available for overseas
price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0"))
# Price API may return 0/empty for certain VTS exchange codes.
# Fall back to the scanner candidate's price so order sizing still works.
if current_price <= 0:
market_candidates_lookup = scan_candidates.get(market.code, {})
cand_lookup = market_candidates_lookup.get(stock_code)
if cand_lookup and cand_lookup.price > 0:
current_price = cand_lookup.price
logger.debug(
"Price API returned 0 for %s; using scanner price %.4f",
stock_code,
current_price,
)
# Calculate daily P&L % # Calculate daily P&L %
pnl_pct = ( pnl_pct = (
((total_eval - purchase_total) / purchase_total * 100) ((total_eval - purchase_total) / purchase_total * 100)
@@ -583,44 +526,6 @@ async def trading_cycle(
if candidate: if candidate:
market_data["rsi"] = candidate.rsi market_data["rsi"] = candidate.rsi
market_data["volume_ratio"] = candidate.volume_ratio market_data["volume_ratio"] = candidate.volume_ratio
else:
# Holding stocks not in scanner: derive metrics from price API data already fetched.
# For overseas stocks, price_output contains high/low/rate from get_overseas_price.
# For domestic stocks, only price_change_pct is available from get_current_price.
market_data["rsi"] = max(0.0, min(100.0, 50.0 + price_change_pct * 2.0))
if price_output and current_price > 0:
pr_high = safe_float(
price_output.get("high") or price_output.get("ovrs_hgpr")
or price_output.get("stck_hgpr")
)
pr_low = safe_float(
price_output.get("low") or price_output.get("ovrs_lwpr")
or price_output.get("stck_lwpr")
)
if pr_high > 0 and pr_low > 0 and pr_high >= pr_low:
intraday_range_pct = (pr_high - pr_low) / current_price * 100.0
volatility_pct = max(abs(price_change_pct), intraday_range_pct)
market_data["volume_ratio"] = max(1.0, volatility_pct / 2.0)
else:
market_data["volume_ratio"] = 1.0
else:
market_data["volume_ratio"] = 1.0
# Enrich market_data with holding info for SELL/HOLD scenario conditions
open_pos = get_open_position(db_conn, stock_code, market.code)
if open_pos and current_price > 0:
entry_price = safe_float(open_pos.get("price"), 0.0)
if entry_price > 0:
market_data["unrealized_pnl_pct"] = (
(current_price - entry_price) / entry_price * 100
)
entry_ts = open_pos.get("timestamp")
if entry_ts:
try:
entry_date = datetime.fromisoformat(entry_ts).date()
market_data["holding_days"] = (datetime.now(UTC).date() - entry_date).days
except (ValueError, TypeError):
pass
# 1.3. Record L7 real-time context (market-scoped keys) # 1.3. Record L7 real-time context (market-scoped keys)
timeframe = datetime.now(UTC).isoformat() timeframe = datetime.now(UTC).isoformat()
@@ -776,7 +681,7 @@ async def trading_cycle(
open_position = get_open_position(db_conn, stock_code, market.code) open_position = get_open_position(db_conn, stock_code, market.code)
if open_position: if open_position:
entry_price = safe_float(open_position.get("price"), 0.0) entry_price = safe_float(open_position.get("price"), 0.0)
if entry_price > 0 and current_price > 0: if entry_price > 0:
loss_pct = (current_price - entry_price) / entry_price * 100 loss_pct = (current_price - entry_price) / entry_price * 100
stop_loss_threshold = -2.0 stop_loss_threshold = -2.0
take_profit_threshold = 3.0 take_profit_threshold = 3.0
@@ -971,13 +876,10 @@ async def trading_cycle(
# - SELL: -0.2% below last price — ensures fill even when price dips slightly # - 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). # (placing at exact last price risks no-fill if the bid is just below).
overseas_price: float overseas_price: float
# KIS requires at most 2 decimal places for prices >= $1 (≥1달러 소수점 2자리 제한).
# Penny stocks (< $1) keep 4 decimal places to preserve price precision.
_price_decimals = 2 if current_price >= 1.0 else 4
if decision.action == "BUY": if decision.action == "BUY":
overseas_price = round(current_price * 1.002, _price_decimals) overseas_price = round(current_price * 1.002, 4)
else: else:
overseas_price = round(current_price * 0.998, _price_decimals) overseas_price = round(current_price * 0.998, 4)
result = await overseas_broker.send_overseas_order( result = await overseas_broker.send_overseas_order(
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
stock_code=stock_code, stock_code=stock_code,
@@ -1006,33 +908,6 @@ async def trading_cycle(
stock_code, stock_code,
_BUY_COOLDOWN_SECONDS, _BUY_COOLDOWN_SECONDS,
) )
# Close ghost position when broker has no matching balance.
# This prevents infinite SELL retry cycles for positions that
# exist in the DB (from startup sync) but are no longer
# sellable at the broker (expired warrants, delisted stocks, etc.)
if decision.action == "SELL" and "잔고내역이 없습니다" in msg1:
logger.warning(
"Ghost position detected for %s (%s): broker reports no balance."
" Closing DB position to prevent infinite retry.",
stock_code,
market.exchange_code,
)
log_trade(
conn=db_conn,
stock_code=stock_code,
action="SELL",
confidence=0,
rationale=(
"[ghost-close] Broker reported no balance;"
" position closed without fill"
),
quantity=0,
price=0.0,
pnl=0.0,
market=market.code,
exchange_code=market.exchange_code,
mode=settings.MODE if settings else "paper",
)
logger.info("Order result: %s", result.get("msg1", "OK")) logger.info("Order result: %s", result.get("msg1", "OK"))
# 5.5. Notify trade execution (only on success) # 5.5. Notify trade execution (only on success)
@@ -1523,9 +1398,8 @@ async def run_daily_session(
active_stocks={}, active_stocks={},
) )
if not fallback_stocks: if not fallback_stocks:
logger.debug( logger.warning(
"No dynamic overseas symbol universe for %s;" "No dynamic overseas symbol universe for %s; scanner cannot run",
" scanner will use overseas ranking API",
market.code, market.code,
) )
try: try:
@@ -1690,35 +1564,10 @@ async def run_daily_session(
balance_info = {} balance_info = {}
total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0") total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0")
total_cash = safe_float(balance_info.get("frcr_dncl_amt_2", "0") or "0")
purchase_total = safe_float( purchase_total = safe_float(
balance_info.get("frcr_buy_amt_smtl", "0") or "0" balance_info.get("frcr_buy_amt_smtl", "0") or "0"
) )
# Fetch available foreign currency cash via inquire-psamount (TTTS3007R/VTTS3007R).
# TTTS3012R output2 does not include a cash/deposit field — frcr_dncl_amt_2 does not exist.
# Use the first stock with a valid price as the reference for the buying power query.
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 매수가능금액조회' 시트
total_cash = 0.0
ref_stock = next(
(s for s in stocks_data if s.get("current_price", 0) > 0), None
)
if ref_stock:
try:
ps_data = await overseas_broker.get_overseas_buying_power(
market.exchange_code,
ref_stock["stock_code"],
ref_stock["current_price"],
)
total_cash = safe_float(
ps_data.get("output", {}).get("ovrs_ord_psbl_amt", "0") or "0"
)
except ConnectionError as exc:
logger.warning(
"Could not fetch overseas buying power for %s: %s",
market.exchange_code,
exc,
)
# Paper mode fallback: VTS overseas balance API often fails for many accounts. # Paper mode fallback: VTS overseas balance API often fails for many accounts.
# Only activate in paper mode — live mode must use real balance from KIS. # Only activate in paper mode — live mode must use real balance from KIS.
if ( if (
@@ -2154,7 +2003,7 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
import uvicorn import uvicorn
from src.dashboard import create_dashboard_app from src.dashboard import create_dashboard_app
app = create_dashboard_app(settings.DB_PATH, mode=settings.MODE) app = create_dashboard_app(settings.DB_PATH)
uvicorn.run( uvicorn.run(
app, app,
host=settings.DASHBOARD_HOST, host=settings.DASHBOARD_HOST,
@@ -2837,9 +2686,9 @@ async def run(settings: Settings) -> None:
active_stocks=active_stocks, active_stocks=active_stocks,
) )
if not fallback_stocks: if not fallback_stocks:
logger.debug( logger.warning(
"No dynamic overseas symbol universe for %s;" "No dynamic overseas symbol universe for %s;"
" scanner will use overseas ranking API", " scanner cannot run",
market.code, market.code,
) )

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

@@ -93,21 +93,9 @@ 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)
raw = '{"action": "BUY"}' decision = client.parse_response('{"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)
@@ -302,10 +290,9 @@ class TestPromptOverride:
client = GeminiClient(settings) client = GeminiClient(settings)
custom_prompt = "You are a playbook generator. Return JSON with scenarios." custom_prompt = "You are a playbook generator. Return JSON with scenarios."
playbook_json = '{"market_outlook": "neutral", "stocks": []}'
mock_response = MagicMock() mock_response = MagicMock()
mock_response.text = playbook_json mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "test"}'
with patch.object( with patch.object(
client._client.aio.models, client._client.aio.models,
@@ -318,7 +305,7 @@ class TestPromptOverride:
"current_price": 0, "current_price": 0,
"prompt_override": custom_prompt, "prompt_override": custom_prompt,
} }
decision = await client.decide(market_data) await client.decide(market_data)
# Verify the custom prompt was sent, not a built prompt # Verify the custom prompt was sent, not a built prompt
mock_generate.assert_called_once() mock_generate.assert_called_once()
@@ -326,50 +313,17 @@ class TestPromptOverride:
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None "contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
) )
assert actual_prompt == custom_prompt assert actual_prompt == custom_prompt
# Raw response preserved in rationale without parse_response (#247)
assert decision.rationale == playbook_json
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prompt_override_skips_parse_response(self, settings): async def test_prompt_override_skips_optimization(self, settings):
"""prompt_override bypasses parse_response — no Missing fields warning, raw preserved.""" """prompt_override should bypass prompt optimization."""
client = GeminiClient(settings) client = GeminiClient(settings)
client._enable_optimization = True client._enable_optimization = True
custom_prompt = "Custom playbook prompt" custom_prompt = "Custom playbook prompt"
playbook_json = '{"market_outlook": "bullish", "stocks": [{"stock_code": "AAPL"}]}'
mock_response = MagicMock() mock_response = MagicMock()
mock_response.text = playbook_json 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,
):
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( with patch.object(
client._client.aio.models, client._client.aio.models,
@@ -387,7 +341,6 @@ class TestPromptOverride:
actual_prompt = mock_generate.call_args[1].get( 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 "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 assert actual_prompt == custom_prompt
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -354,8 +354,6 @@ class TestFetchMarketRankings:
assert "ranking/fluctuation" in url assert "ranking/fluctuation" in url
assert headers.get("tr_id") == "FHPST01700000" assert headers.get("tr_id") == "FHPST01700000"
assert params.get("fid_cond_scr_div_code") == "20170" 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 @pytest.mark.asyncio
async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None: async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None:
@@ -378,27 +376,6 @@ class TestFetchMarketRankings:
assert result[0]["price"] == 75000.0 assert result[0]["price"] == 75000.0
assert result[0]["change_rate"] == 2.5 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) # KRX tick unit / round-down helpers (issue #157)

View File

@@ -413,39 +413,3 @@ def test_status_circuit_breaker_unknown_when_no_data(tmp_path: Path) -> None:
cb = body["circuit_breaker"] cb = body["circuit_breaker"]
assert cb["status"] == "unknown" assert cb["status"] == "unknown"
assert cb["current_pnl_pct"] is None 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"

View File

@@ -15,7 +15,6 @@ from src.logging.decision_logger import DecisionLogger
from src.main import ( from src.main import (
_apply_dashboard_flag, _apply_dashboard_flag,
_determine_order_quantity, _determine_order_quantity,
_extract_avg_price_from_balance,
_extract_held_codes_from_balance, _extract_held_codes_from_balance,
_extract_held_qty_from_balance, _extract_held_qty_from_balance,
_handle_market_close, _handle_market_close,
@@ -77,81 +76,6 @@ def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch:
) )
class TestExtractAvgPriceFromBalance:
"""Tests for _extract_avg_price_from_balance() (issue #249)."""
def test_domestic_returns_pchs_avg_pric(self) -> None:
"""Domestic balance with pchs_avg_pric returns the correct float."""
balance = {"output1": [{"pdno": "005930", "pchs_avg_pric": "68000.00"}]}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 68000.0
def test_overseas_returns_pchs_avg_pric(self) -> None:
"""Overseas balance with pchs_avg_pric returns the correct float."""
balance = {"output1": [{"ovrs_pdno": "AAPL", "pchs_avg_pric": "170.50"}]}
result = _extract_avg_price_from_balance(balance, "AAPL", is_domestic=False)
assert result == 170.5
def test_returns_zero_when_field_absent(self) -> None:
"""Returns 0.0 when pchs_avg_pric key is missing entirely."""
balance = {"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}]}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_returns_zero_when_field_empty_string(self) -> None:
"""Returns 0.0 when pchs_avg_pric is an empty string."""
balance = {"output1": [{"pdno": "005930", "pchs_avg_pric": ""}]}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_returns_zero_when_stock_not_found(self) -> None:
"""Returns 0.0 when the requested stock_code is not in output1."""
balance = {"output1": [{"pdno": "000660", "pchs_avg_pric": "100000.0"}]}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_returns_zero_when_output1_empty(self) -> None:
"""Returns 0.0 when output1 is an empty list."""
balance = {"output1": []}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_returns_zero_when_output1_key_absent(self) -> None:
"""Returns 0.0 when output1 key is missing from balance_data."""
balance: dict = {}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_handles_output1_as_dict(self) -> None:
"""Handles the edge case where output1 is a dict instead of a list."""
balance = {"output1": {"pdno": "005930", "pchs_avg_pric": "55000.0"}}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 55000.0
def test_case_insensitive_code_matching(self) -> None:
"""Stock code comparison is case-insensitive."""
balance = {"output1": [{"ovrs_pdno": "aapl", "pchs_avg_pric": "170.0"}]}
result = _extract_avg_price_from_balance(balance, "AAPL", is_domestic=False)
assert result == 170.0
def test_returns_zero_for_non_numeric_string(self) -> None:
"""Returns 0.0 when pchs_avg_pric contains a non-numeric value."""
balance = {"output1": [{"pdno": "005930", "pchs_avg_pric": "N/A"}]}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_returns_correct_stock_among_multiple(self) -> None:
"""Returns only the avg price of the requested stock when output1 has multiple holdings."""
balance = {
"output1": [
{"pdno": "000660", "pchs_avg_pric": "150000.0"},
{"pdno": "005930", "pchs_avg_pric": "68000.0"},
]
}
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 68000.0
class TestExtractHeldQtyFromBalance: class TestExtractHeldQtyFromBalance:
"""Tests for _extract_held_qty_from_balance().""" """Tests for _extract_held_qty_from_balance()."""
@@ -177,24 +101,10 @@ class TestExtractHeldQtyFromBalance:
balance = {"output1": [], "output2": [{}]} balance = {"output1": [], "output2": [{}]}
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 0 assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 0
def test_overseas_returns_ord_psbl_qty_first(self) -> None: def test_overseas_returns_ovrs_cblc_qty(self) -> None:
"""ord_psbl_qty (주문가능수량) takes priority over ovrs_cblc_qty."""
balance = {
"output1": [{"ovrs_pdno": "AAPL", "ord_psbl_qty": "8", "ovrs_cblc_qty": "10"}]
}
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 8
def test_overseas_fallback_to_ovrs_cblc_qty_when_ord_psbl_qty_absent(self) -> None:
balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}]} balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}]}
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 10 assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 10
def test_overseas_returns_zero_when_ord_psbl_qty_zero(self) -> None:
"""Expired/delisted securities: ovrs_cblc_qty large but ord_psbl_qty=0."""
balance = {
"output1": [{"ovrs_pdno": "MLECW", "ord_psbl_qty": "0", "ovrs_cblc_qty": "289456"}]
}
assert _extract_held_qty_from_balance(balance, "MLECW", is_domestic=False) == 0
def test_overseas_fallback_to_hldg_qty(self) -> None: def test_overseas_fallback_to_hldg_qty(self) -> None:
balance = {"output1": [{"ovrs_pdno": "AAPL", "hldg_qty": "4"}]} balance = {"output1": [{"ovrs_pdno": "AAPL", "hldg_qty": "4"}]}
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 4 assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 4
@@ -237,26 +147,6 @@ class TestExtractHeldCodesFromBalance:
result = _extract_held_codes_from_balance(balance, is_domestic=False) result = _extract_held_codes_from_balance(balance, is_domestic=False)
assert result == ["AAPL"] assert result == ["AAPL"]
def test_overseas_uses_ord_psbl_qty_to_filter(self) -> None:
"""ord_psbl_qty=0 should exclude stock even if ovrs_cblc_qty is large."""
balance = {
"output1": [
{"ovrs_pdno": "MLECW", "ord_psbl_qty": "0", "ovrs_cblc_qty": "289456"},
{"ovrs_pdno": "AAPL", "ord_psbl_qty": "5", "ovrs_cblc_qty": "5"},
]
}
result = _extract_held_codes_from_balance(balance, is_domestic=False)
assert "MLECW" not in result
assert "AAPL" in result
def test_overseas_includes_stock_when_ord_psbl_qty_absent_and_ovrs_cblc_qty_positive(
self,
) -> None:
"""Fallback to ovrs_cblc_qty when ord_psbl_qty field is missing."""
balance = {"output1": [{"ovrs_pdno": "TSLA", "ovrs_cblc_qty": "3"}]}
result = _extract_held_codes_from_balance(balance, is_domestic=False)
assert "TSLA" in result
class TestDetermineOrderQuantity: class TestDetermineOrderQuantity:
"""Test _determine_order_quantity() — SELL uses broker_held_qty.""" """Test _determine_order_quantity() — SELL uses broker_held_qty."""
@@ -903,14 +793,12 @@ class TestOverseasBalanceParsing:
"output2": [ "output2": [
{ {
"frcr_evlu_tota": "10000.00", "frcr_evlu_tota": "10000.00",
"frcr_dncl_amt_2": "5000.00",
"frcr_buy_amt_smtl": "4500.00", "frcr_buy_amt_smtl": "4500.00",
} }
] ]
} }
) )
broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "5000.00"}}
)
return broker return broker
@pytest.fixture @pytest.fixture
@@ -924,13 +812,11 @@ class TestOverseasBalanceParsing:
return_value={ return_value={
"output2": { "output2": {
"frcr_evlu_tota": "10000.00", "frcr_evlu_tota": "10000.00",
"frcr_dncl_amt_2": "5000.00",
"frcr_buy_amt_smtl": "4500.00", "frcr_buy_amt_smtl": "4500.00",
} }
} }
) )
broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "5000.00"}}
)
return broker return broker
@pytest.fixture @pytest.fixture
@@ -941,9 +827,6 @@ class TestOverseasBalanceParsing:
return_value={"output": {"last": "150.50"}} return_value={"output": {"last": "150.50"}}
) )
broker.get_overseas_balance = AsyncMock(return_value={"output2": []}) broker.get_overseas_balance = AsyncMock(return_value={"output2": []})
broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "0.00"}}
)
return broker return broker
@pytest.fixture @pytest.fixture
@@ -958,15 +841,12 @@ class TestOverseasBalanceParsing:
"output2": [ "output2": [
{ {
"frcr_evlu_tota": "10000.00", "frcr_evlu_tota": "10000.00",
"frcr_dncl_amt_2": "5000.00",
"frcr_buy_amt_smtl": "4500.00", "frcr_buy_amt_smtl": "4500.00",
} }
] ]
} }
) )
# get_overseas_buying_power not called when price=0, but mock for safety
broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "5000.00"}}
)
return broker return broker
@pytest.fixture @pytest.fixture
@@ -1196,14 +1076,12 @@ class TestOverseasBalanceParsing:
"output2": [ "output2": [
{ {
"frcr_evlu_tota": "100000.00", "frcr_evlu_tota": "100000.00",
"frcr_dncl_amt_2": "50000.00",
"frcr_buy_amt_smtl": "50000.00", "frcr_buy_amt_smtl": "50000.00",
} }
] ]
} }
) )
broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "50000.00"}}
)
broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"}) broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
return broker return broker
@@ -1258,8 +1136,7 @@ class TestOverseasBalanceParsing:
mock_overseas_broker_with_buy_scenario.send_overseas_order.assert_called_once() 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 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] sent_price = call_kwargs[1].get("price") or call_kwargs[0][4]
# KIS requires max 2 decimal places for prices >= $1 (#252) expected_price = round(182.5 * 1.002, 4) # 0.2% premium for BUY limit orders
expected_price = round(182.5 * 1.002, 2) # 0.2% premium for BUY limit orders
assert sent_price == expected_price, ( assert sent_price == expected_price, (
f"Expected limit price {expected_price} (182.5 * 1.002) but got {sent_price}. " 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)." "BUY uses +0.2% to improve fill rate while minimising overpayment (#211)."
@@ -1303,14 +1180,12 @@ class TestOverseasBalanceParsing:
"output2": [ "output2": [
{ {
"frcr_evlu_tota": "100000.00", "frcr_evlu_tota": "100000.00",
"frcr_dncl_amt_2": "50000.00",
"frcr_buy_amt_smtl": "50000.00", "frcr_buy_amt_smtl": "50000.00",
} }
], ],
} }
) )
overseas_broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "50000.00"}}
)
overseas_broker.send_overseas_order = AsyncMock( overseas_broker.send_overseas_order = AsyncMock(
return_value={"rt_cd": "0", "msg1": "OK"} return_value={"rt_cd": "0", "msg1": "OK"}
) )
@@ -1340,139 +1215,12 @@ class TestOverseasBalanceParsing:
overseas_broker.send_overseas_order.assert_called_once() overseas_broker.send_overseas_order.assert_called_once()
call_kwargs = overseas_broker.send_overseas_order.call_args call_kwargs = overseas_broker.send_overseas_order.call_args
sent_price = call_kwargs[1].get("price") or call_kwargs[0][4] sent_price = call_kwargs[1].get("price") or call_kwargs[0][4]
# KIS requires max 2 decimal places for prices >= $1 (#252) expected_price = round(sell_price * 0.998, 4) # -0.2% for SELL limit orders
expected_price = round(sell_price * 0.998, 2) # -0.2% for SELL limit orders
assert sent_price == expected_price, ( assert sent_price == expected_price, (
f"Expected SELL limit price {expected_price} (182.5 * 0.998) but got {sent_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)." "SELL uses -0.2% to ensure fill even when price dips slightly (#211)."
) )
@pytest.mark.asyncio
async def test_overseas_buy_price_rounded_to_2_decimals_for_dollar_plus_stock(
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:
"""BUY price for $1+ stocks is rounded to 2 decimal places (issue #252).
KIS rejects prices with more than 2 decimal places for stocks priced >= $1.
current_price=50.1234 * 1.002 = 50.22... should be sent as 50.22, not 50.2236.
"""
overseas_broker = MagicMock()
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [],
"output2": [{"frcr_evlu_tota": "0", "frcr_buy_amt_smtl": "0"}],
}
)
overseas_broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "10000"}}
)
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "50.1234", "rate": "0"}}
)
overseas_broker.send_overseas_order = AsyncMock(
return_value={"rt_cd": None, "msg1": "주문접수"}
)
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match())
await trading_cycle(
broker=mock_domestic_broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=mock_playbook,
risk=mock_risk,
db_conn=db_conn,
decision_logger=decision_logger,
context_store=mock_context_store,
criticality_assessor=mock_criticality_assessor,
telegram=mock_telegram,
market=mock_overseas_market,
stock_code="TQQQ",
scan_candidates={},
)
overseas_broker.send_overseas_order.assert_called_once()
sent_price = overseas_broker.send_overseas_order.call_args[1].get("price") or \
overseas_broker.send_overseas_order.call_args[0][4]
# 50.1234 * 1.002 = 50.2235... rounded to 2 decimals = 50.22
assert sent_price == round(50.1234 * 1.002, 2), (
f"Expected 2-decimal price {round(50.1234 * 1.002, 2)} but got {sent_price} (#252)"
)
@pytest.mark.asyncio
async def test_overseas_penny_stock_price_keeps_4_decimals(
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:
"""BUY price for penny stocks (< $1) uses 4 decimal places (issue #252)."""
overseas_broker = MagicMock()
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [],
"output2": [{"frcr_evlu_tota": "0", "frcr_buy_amt_smtl": "0"}],
}
)
overseas_broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "10000"}}
)
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "0.5678", "rate": "0"}}
)
overseas_broker.send_overseas_order = AsyncMock(
return_value={"rt_cd": None, "msg1": "주문접수"}
)
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match())
await trading_cycle(
broker=mock_domestic_broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=mock_playbook,
risk=mock_risk,
db_conn=db_conn,
decision_logger=decision_logger,
context_store=mock_context_store,
criticality_assessor=mock_criticality_assessor,
telegram=mock_telegram,
market=mock_overseas_market,
stock_code="PENNYX",
scan_candidates={},
)
overseas_broker.send_overseas_order.assert_called_once()
sent_price = overseas_broker.send_overseas_order.call_args[1].get("price") or \
overseas_broker.send_overseas_order.call_args[0][4]
# 0.5678 * 1.002 = 0.56893... rounded to 4 decimals = 0.5689
assert sent_price == round(0.5678 * 1.002, 4), (
f"Expected 4-decimal price {round(0.5678 * 1.002, 4)} but got {sent_price} (#252)"
)
class TestScenarioEngineIntegration: class TestScenarioEngineIntegration:
"""Test scenario engine integration in trading_cycle.""" """Test scenario engine integration in trading_cycle."""
@@ -1668,10 +1416,10 @@ class TestScenarioEngineIntegration:
scan_candidates={"US": {"005930": us_candidate}}, # Wrong market scan_candidates={"US": {"005930": us_candidate}}, # Wrong market
) )
# Should NOT use US candidate's rsi (=15.0); fallback implied_rsi used instead # Should NOT have rsi/volume_ratio because candidate is under US, not KR
market_data = engine.evaluate.call_args[0][2] market_data = engine.evaluate.call_args[0][2]
assert market_data["rsi"] != 15.0 # US candidate's rsi must be ignored assert "rsi" not in market_data
assert market_data["volume_ratio"] == 1.0 # Fallback default assert "volume_ratio" not in market_data
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scenario_engine_called_without_scanner_data( async def test_scenario_engine_called_without_scanner_data(
@@ -1702,70 +1450,13 @@ class TestScenarioEngineIntegration:
scan_candidates={}, # No scanner data scan_candidates={}, # No scanner data
) )
# Holding stocks without scanner data use implied_rsi (from price_change_pct) # Should still work, just without rsi/volume_ratio
# and volume_ratio=1.0 as fallback, so rsi/volume_ratio are always present.
engine.evaluate.assert_called_once() engine.evaluate.assert_called_once()
market_data = engine.evaluate.call_args[0][2] market_data = engine.evaluate.call_args[0][2]
assert "rsi" in market_data # Implied RSI from price_change_pct=2.5 → 55.0 assert "rsi" not in market_data
assert market_data["rsi"] == pytest.approx(55.0) assert "volume_ratio" not in market_data
assert market_data["volume_ratio"] == 1.0
assert market_data["current_price"] == 50000.0 assert market_data["current_price"] == 50000.0
@pytest.mark.asyncio
async def test_holding_overseas_stock_derives_volume_ratio_from_price_api(
self, mock_broker: MagicMock, mock_telegram: MagicMock,
) -> None:
"""Test overseas holding stocks derive volume_ratio from get_overseas_price high/low."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_hold_match())
os_market = MagicMock()
os_market.name = "NASDAQ"
os_market.code = "US_NASDAQ"
os_market.exchange_code = "NAS"
os_market.is_domestic = False
os_market.timezone = UTC
os_broker = MagicMock()
# price_change_pct=5.0, high=106, low=94 → intraday_range=12% → volume_ratio=max(1,6)=6
os_broker.get_overseas_price = AsyncMock(return_value={
"output": {"last": "100.0", "rate": "5.0", "high": "106.0", "low": "94.0"}
})
os_broker.get_overseas_balance = AsyncMock(return_value={
"output2": [{"frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "9000"}]
})
os_broker.get_overseas_buying_power = AsyncMock(return_value={
"output": {"ovrs_ord_psbl_amt": "500"}
})
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=os_broker,
scenario_engine=engine,
playbook=_make_playbook(),
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=os_market,
stock_code="NVDA",
scan_candidates={}, # Not in scanner — holding stock
)
market_data = engine.evaluate.call_args[0][2]
# rsi: 50.0 + 5.0 * 2.0 = 60.0
assert market_data["rsi"] == pytest.approx(60.0)
# intraday_range = (106-94)/100 * 100 = 12.0%
# volatility_pct = max(abs(5.0), 12.0) = 12.0
# volume_ratio = max(1.0, 12.0 / 2.0) = 6.0
assert market_data["volume_ratio"] == pytest.approx(6.0)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scenario_matched_notification_sent( async def test_scenario_matched_notification_sent(
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock, self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
@@ -2323,92 +2014,6 @@ async def test_hold_not_overridden_when_between_stop_loss_and_take_profit() -> N
broker.send_order.assert_not_called() broker.send_order.assert_not_called()
@pytest.mark.asyncio
async def test_stop_loss_not_triggered_when_current_price_is_zero() -> None:
"""HOLD must stay HOLD when current_price=0 even if entry_price is set (issue #251).
A price API failure that returns 0.0 must not cause a false -100% stop-loss.
"""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
buy_decision_id = decision_logger.log_decision(
stock_code="005930",
market="KR",
exchange_code="KRX",
action="BUY",
confidence=90,
rationale="entry",
context_snapshot={},
input_data={},
)
log_trade(
conn=db_conn,
stock_code="005930",
action="BUY",
confidence=90,
rationale="entry",
quantity=1,
price=100.0, # valid entry price
market="KR",
exchange_code="KRX",
decision_id=buy_decision_id,
)
broker = MagicMock()
# Price API returns 0.0 — simulates API failure or pre-market unavailability
broker.get_current_price = AsyncMock(return_value=(0.0, 0.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
"output2": [
{
"tot_evlu_amt": "100000",
"dnca_tot_amt": "10000",
"pchs_amt_smtl_amt": "90000",
}
]
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
await trading_cycle(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_hold_match())),
playbook=_make_playbook("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,
market=market,
stock_code="005930",
scan_candidates={},
)
# No SELL order must be placed — current_price=0 must suppress stop-loss
broker.send_order.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sell_order_uses_broker_balance_qty_not_db() -> None: async def test_sell_order_uses_broker_balance_qty_not_db() -> None:
"""SELL quantity must come from broker balance output1, not DB. """SELL quantity must come from broker balance output1, not DB.
@@ -2858,11 +2463,9 @@ class TestBuyCooldown:
) )
broker.get_overseas_balance = AsyncMock(return_value={ broker.get_overseas_balance = AsyncMock(return_value={
"output1": [], "output1": [],
"output2": [{"frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}], "output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000",
"frcr_buy_amt_smtl": "0"}],
}) })
broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "50000"}}
)
broker.send_overseas_order = AsyncMock( broker.send_overseas_order = AsyncMock(
return_value={"rt_cd": "1", "msg1": "모의투자 주문가능금액이 부족합니다."} return_value={"rt_cd": "1", "msg1": "모의투자 주문가능금액이 부족합니다."}
) )
@@ -2975,11 +2578,9 @@ class TestBuyCooldown:
) )
overseas_broker.get_overseas_balance = AsyncMock(return_value={ overseas_broker.get_overseas_balance = AsyncMock(return_value={
"output1": [], "output1": [],
"output2": [{"frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}], "output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000",
"frcr_buy_amt_smtl": "0"}],
}) })
overseas_broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "50000"}}
)
overseas_broker.send_overseas_order = AsyncMock( overseas_broker.send_overseas_order = AsyncMock(
return_value={"rt_cd": "1", "msg1": "기타 오류 메시지"} return_value={"rt_cd": "1", "msg1": "기타 오류 메시지"}
) )
@@ -3374,12 +2975,9 @@ async def test_buy_suppressed_when_open_position_exists() -> None:
overseas_broker.get_overseas_balance = AsyncMock( overseas_broker.get_overseas_balance = AsyncMock(
return_value={ return_value={
"output1": [], "output1": [],
"output2": [{"frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}], "output2": [{"frcr_dncl_amt_2": "10000", "frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}],
} }
) )
overseas_broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "10000"}}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"}) overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"})
engine = MagicMock(spec=ScenarioEngine) engine = MagicMock(spec=ScenarioEngine)
@@ -3441,12 +3039,9 @@ async def test_buy_proceeds_when_no_open_position() -> None:
overseas_broker.get_overseas_balance = AsyncMock( overseas_broker.get_overseas_balance = AsyncMock(
return_value={ return_value={
"output1": [], "output1": [],
"output2": [{"frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}], "output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}],
} }
) )
overseas_broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "50000"}}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"}) overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"})
engine = MagicMock(spec=ScenarioEngine) engine = MagicMock(spec=ScenarioEngine)
@@ -3547,15 +3142,13 @@ class TestOverseasBrokerIntegration:
"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}], "output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}],
"output2": [ "output2": [
{ {
"frcr_dncl_amt_2": "50000.00",
"frcr_evlu_tota": "60000.00", "frcr_evlu_tota": "60000.00",
"frcr_buy_amt_smtl": "50000.00", "frcr_buy_amt_smtl": "50000.00",
} }
], ],
} }
) )
overseas_broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "50000.00"}}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"}) overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
engine = MagicMock(spec=ScenarioEngine) engine = MagicMock(spec=ScenarioEngine)
@@ -3623,15 +3216,13 @@ class TestOverseasBrokerIntegration:
"output1": [], "output1": [],
"output2": [ "output2": [
{ {
"frcr_dncl_amt_2": "50000.00",
"frcr_evlu_tota": "50000.00", "frcr_evlu_tota": "50000.00",
"frcr_buy_amt_smtl": "0.00", "frcr_buy_amt_smtl": "0.00",
} }
], ],
} }
) )
overseas_broker.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "50000.00"}}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"}) overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
engine = MagicMock(spec=ScenarioEngine) engine = MagicMock(spec=ScenarioEngine)
@@ -4054,6 +3645,7 @@ class TestSyncPositionsFromBroker:
"output2": [ "output2": [
{ {
"frcr_evlu_tota": "50000", "frcr_evlu_tota": "50000",
"frcr_dncl_amt_2": "10000",
"frcr_buy_amt_smtl": "40000", "frcr_buy_amt_smtl": "40000",
} }
], ],
@@ -4192,70 +3784,6 @@ class TestSyncPositionsFromBroker:
# Two distinct exchange codes (NASD, NYSE) → 2 calls # Two distinct exchange codes (NASD, NYSE) → 2 calls
assert overseas_broker.get_overseas_balance.call_count == 2 assert overseas_broker.get_overseas_balance.call_count == 2
@pytest.mark.asyncio
async def test_syncs_domestic_position_with_correct_avg_price(self) -> None:
"""Domestic position is stored with pchs_avg_pric as price (issue #249)."""
settings = self._make_settings("KR")
db_conn = init_db(":memory:")
balance = {
"output1": [{"pdno": "005930", "ord_psbl_qty": "5", "pchs_avg_pric": "68000.0"}],
"output2": [{"tot_evlu_amt": "1000000", "dnca_tot_amt": "500000", "pchs_amt_smtl_amt": "500000"}],
}
broker = MagicMock()
broker.get_balance = AsyncMock(return_value=balance)
overseas_broker = MagicMock()
await sync_positions_from_broker(broker, overseas_broker, db_conn, settings)
from src.db import get_open_position
pos = get_open_position(db_conn, "005930", "KR")
assert pos is not None
assert pos["price"] == 68000.0
@pytest.mark.asyncio
async def test_syncs_overseas_position_with_correct_avg_price(self) -> None:
"""Overseas position is stored with pchs_avg_pric as price (issue #249)."""
settings = self._make_settings("US_NASDAQ")
db_conn = init_db(":memory:")
balance = {
"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10", "pchs_avg_pric": "170.0"}],
"output2": [{"frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "40000"}],
}
broker = MagicMock()
overseas_broker = MagicMock()
overseas_broker.get_overseas_balance = AsyncMock(return_value=balance)
await sync_positions_from_broker(broker, overseas_broker, db_conn, settings)
from src.db import get_open_position
pos = get_open_position(db_conn, "AAPL", "US_NASDAQ")
assert pos is not None
assert pos["price"] == 170.0
@pytest.mark.asyncio
async def test_syncs_position_with_zero_price_when_pchs_avg_pric_absent(self) -> None:
"""Fallback to price=0.0 when pchs_avg_pric is absent (issue #249)."""
settings = self._make_settings("KR")
db_conn = init_db(":memory:")
# No pchs_avg_pric in output1
balance = {
"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}],
"output2": [{"tot_evlu_amt": "1000000", "dnca_tot_amt": "500000", "pchs_amt_smtl_amt": "500000"}],
}
broker = MagicMock()
broker.get_balance = AsyncMock(return_value=balance)
overseas_broker = MagicMock()
await sync_positions_from_broker(broker, overseas_broker, db_conn, settings)
from src.db import get_open_position
pos = get_open_position(db_conn, "005930", "KR")
assert pos is not None
assert pos["price"] == 0.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Domestic BUY double-prevention (issue #206) — trading_cycle integration # Domestic BUY double-prevention (issue #206) — trading_cycle integration
@@ -4850,192 +4378,3 @@ class TestDomesticLimitOrderPrice:
expected_price = kr_round_down(current_price * 0.998) expected_price = kr_round_down(current_price * 0.998)
assert call_kwargs["price"] == expected_price assert call_kwargs["price"] == expected_price
assert call_kwargs["order_type"] == "SELL" assert call_kwargs["order_type"] == "SELL"
# ---------------------------------------------------------------------------
# Ghost position — overseas SELL "잔고내역이 없습니다" handling
# ---------------------------------------------------------------------------
class TestOverseasGhostPositionClose:
"""trading_cycle must close ghost DB position when broker returns 잔고없음."""
def _make_overseas_market(self) -> MagicMock:
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
return market
def _make_overseas_broker(
self,
current_price: float,
balance_data: dict,
sell_result: dict,
) -> MagicMock:
ob = MagicMock()
ob.get_overseas_price = AsyncMock(
return_value={"output": {"last": str(current_price), "rate": "0.0"}}
)
ob.get_overseas_balance = AsyncMock(return_value=balance_data)
ob.get_overseas_buying_power = AsyncMock(
return_value={"output": {"ovrs_ord_psbl_amt": "0.00"}}
)
ob.send_overseas_order = AsyncMock(return_value=sell_result)
return ob
@pytest.mark.asyncio
async def test_ghost_position_closes_db_on_no_balance_error(self) -> None:
"""When SELL fails with '잔고내역이 없습니다', log_trade is called to close the ghost.
This can happen when exchange code recorded at startup differs from the
exchange code used in the SELL cycle (e.g. KNRX recorded as NASD but
actually traded on AMEX), causing the broker to see no matching balance.
The position has ord_psbl_qty > 0 (so a SELL is attempted), but KIS
rejects it with '잔고내역이 없습니다'.
"""
from src.strategy.models import ScenarioAction
stock_code = "KNRX"
current_price = 1.5
# ord_psbl_qty=5 means the code passes the qty check and a SELL is sent
balance_data = {
"output1": [
{"ovrs_pdno": stock_code, "ord_psbl_qty": "5", "ovrs_cblc_qty": "5"}
],
"output2": [{"tot_evlu_amt": "10000"}],
}
sell_result = {"rt_cd": "1", "msg1": "모의투자 잔고내역이 없습니다"}
domestic_broker = MagicMock()
domestic_broker.get_balance = AsyncMock(return_value={"output1": [], "output2": [{}]})
overseas_broker = self._make_overseas_broker(current_price, balance_data, sell_result)
market = self._make_overseas_market()
sell_match = ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=ScenarioAction.SELL,
confidence=85,
rationale="test ghost KNRX",
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=sell_match)
risk = MagicMock()
risk.validate_order = MagicMock()
risk.check_circuit_breaker = MagicMock()
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
db_conn = MagicMock()
settings = MagicMock(spec=Settings)
settings.MODE = "paper"
settings.POSITION_SIZING_ENABLED = False
settings.PAPER_OVERSEAS_CASH = 0
with patch("src.main.log_trade") as mock_log_trade, patch(
"src.main.get_open_position", return_value=None
), patch("src.main.get_latest_buy_trade", return_value=None):
await trading_cycle(
broker=domestic_broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US_NASDAQ"),
risk=risk,
db_conn=db_conn,
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code=stock_code,
scan_candidates={},
settings=settings,
)
# log_trade must be called with action="SELL" to close the ghost position
ghost_close_calls = [
c
for c in mock_log_trade.call_args_list
if c.kwargs.get("action") == "SELL"
and "[ghost-close]" in (c.kwargs.get("rationale") or "")
]
assert ghost_close_calls, "Expected ghost-close log_trade call was not made"
@pytest.mark.asyncio
async def test_normal_sell_failure_does_not_close_db(self) -> None:
"""Non-잔고없음 SELL failures must NOT close the DB position."""
from src.strategy.models import ScenarioAction
stock_code = "TSLA"
current_price = 250.0
balance_data = {
"output1": [{"ovrs_pdno": stock_code, "ord_psbl_qty": "5", "ovrs_cblc_qty": "5"}],
"output2": [{"tot_evlu_amt": "100000"}],
}
sell_result = {"rt_cd": "1", "msg1": "일시적 오류가 발생했습니다"}
domestic_broker = MagicMock()
domestic_broker.get_balance = AsyncMock(return_value={"output1": [], "output2": [{}]})
overseas_broker = self._make_overseas_broker(current_price, balance_data, sell_result)
market = self._make_overseas_market()
sell_match = ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=ScenarioAction.SELL,
confidence=85,
rationale="test",
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=sell_match)
risk = MagicMock()
risk.validate_order = MagicMock()
risk.check_circuit_breaker = MagicMock()
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
db_conn = MagicMock()
with patch("src.main.log_trade") as mock_log_trade, patch(
"src.main.get_open_position", return_value=None
):
await trading_cycle(
broker=domestic_broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US_NASDAQ"),
risk=risk,
db_conn=db_conn,
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code=stock_code,
scan_candidates={},
)
ghost_close_calls = [
c
for c in mock_log_trade.call_args_list
if c.kwargs.get("action") == "SELL"
and "[ghost-close]" in (c.kwargs.get("rationale") or "")
]
assert not ghost_close_calls, "Ghost-close must NOT be triggered for non-잔고없음 errors"

View File

@@ -28,7 +28,6 @@ def mock_settings() -> Settings:
KIS_APP_SECRET="test_secret", KIS_APP_SECRET="test_secret",
KIS_ACCOUNT_NO="12345678-01", KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="test_gemini_key", GEMINI_API_KEY="test_gemini_key",
MODE="paper", # Explicitly set to avoid .env MODE=live override
) )
@@ -123,10 +122,9 @@ class TestFetchOverseasRankings:
params = call_args[1]["params"] params = call_args[1]["params"]
assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url
assert params["KEYB"] == "" # Required by KIS API spec
assert params["EXCD"] == "NAS" assert params["EXCD"] == "NAS"
assert params["NDAY"] == "0" assert params["NDAY"] == "0"
assert params["GUBN"] == "1" # 1=상승율 — 변동성 스캐너는 급등 종목 우선 assert params["GUBN"] == "1"
assert params["VOL_RANG"] == "0" assert params["VOL_RANG"] == "0"
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000") overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000")
@@ -159,7 +157,6 @@ class TestFetchOverseasRankings:
params = call_args[1]["params"] params = call_args[1]["params"]
assert "/uapi/overseas-stock/v1/ranking/volume-surge" in url assert "/uapi/overseas-stock/v1/ranking/volume-surge" in url
assert params["KEYB"] == "" # Required by KIS API spec
assert params["EXCD"] == "NYS" assert params["EXCD"] == "NYS"
assert params["MIXN"] == "0" assert params["MIXN"] == "0"
assert params["VOL_RANG"] == "0" assert params["VOL_RANG"] == "0"

View File

@@ -124,10 +124,6 @@ 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."""