Compare commits

...

2 Commits

Author SHA1 Message Date
agentson
1242794fc4 feat: 해외주식 미체결 SELL 시 이중 매수 방지 (#195)
Some checks failed
CI / test (pull_request) Has been cancelled
KIS VTS는 SELL 지정가 주문을 접수 즉시 rt_cd=0으로 반환하지만
실제 체결은 시장가 도달 시까지 지연된다. 이 기간 동안 DB는 포지션을
"종료"로 기록해 다음 사이클에서 이중 매수가 발생할 수 있었다.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 05:52:35 +09:00
b45d136894 Merge pull request 'feat: 미구현 API 4개 대시보드 프론트 연결 (#198)' (#199) from feature/issue-198-dashboard-api-frontend into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #199
2026-02-23 05:37:33 +09:00
2 changed files with 217 additions and 0 deletions

View File

@@ -524,6 +524,14 @@ async def trading_cycle(
# BUY 결정 전 기존 포지션 체크 (중복 매수 방지) # BUY 결정 전 기존 포지션 체크 (중복 매수 방지)
if decision.action == "BUY": if decision.action == "BUY":
existing_position = get_open_position(db_conn, stock_code, market.code) existing_position = get_open_position(db_conn, stock_code, market.code)
if not existing_position and not market.is_domestic:
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
# 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
broker_qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=False
)
if broker_qty > 0:
existing_position = {"price": 0.0, "quantity": broker_qty}
if existing_position: if existing_position:
decision = TradeDecision( decision = TradeDecision(
action="HOLD", action="HOLD",
@@ -1076,6 +1084,33 @@ async def run_daily_session(
decision.confidence, decision.confidence,
) )
# 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:
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
# 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
broker_qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=False
)
if broker_qty > 0:
daily_existing = {"price": 0.0, "quantity": broker_qty}
if daily_existing:
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=(
f"Already holding {stock_code} "
f"(entry={daily_existing['price']:.4f}, "
f"qty={daily_existing['quantity']})"
),
)
logger.info(
"BUY suppressed for %s (%s): already holding open position",
stock_code,
market.name,
)
# Log decision # Log decision
context_snapshot = { context_snapshot = {
"L1": { "L1": {

View File

@@ -3001,3 +3001,185 @@ async def test_buy_proceeds_when_no_open_position() -> None:
# 포지션이 없으므로 해외 주문이 실행되어야 함 # 포지션이 없으므로 해외 주문이 실행되어야 함
overseas_broker.send_overseas_order.assert_called_once() overseas_broker.send_overseas_order.assert_called_once()
class TestOverseasBrokerIntegration:
"""Test overseas broker live-balance gating for double-buy prevention.
Issue #195: KIS VTS SELL limit orders are accepted (rt_cd=0) immediately
but may not fill until the market price reaches the limit. During this window,
the DB records the position as closed, causing the next cycle to BUY again.
These tests verify that live broker balance is used as the authoritative source.
"""
@pytest.mark.asyncio
async def test_overseas_buy_suppressed_by_broker_balance_when_db_shows_closed(
self,
) -> None:
"""BUY must be suppressed when broker still holds shares even if DB says closed.
Scenario: SELL limit order was accepted (DB shows closed), but hasn't
filled yet — broker balance still shows 10 AAPL shares.
Expected: send_overseas_order is NOT called.
"""
db_conn = init_db(":memory:")
# DB: BUY then SELL recorded → get_open_position returns None (closed)
log_trade(
conn=db_conn,
stock_code="AAPL",
action="BUY",
confidence=90,
rationale="entry",
quantity=10,
price=180.0,
market="US_NASDAQ",
exchange_code="NASD",
)
log_trade(
conn=db_conn,
stock_code="AAPL",
action="SELL",
confidence=90,
rationale="sell order accepted",
quantity=10,
price=182.0,
market="US_NASDAQ",
exchange_code="NASD",
)
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "182.50"}}
)
# 브로커: 여전히 AAPL 10주 보유 중 (SELL 미체결)
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}],
"output2": [
{
"frcr_dncl_amt_2": "50000.00",
"frcr_evlu_tota": "60000.00",
"frcr_buy_amt_smtl": "50000.00",
}
],
}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL"))
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
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="decision-id")
await trading_cycle(
broker=MagicMock(),
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US"),
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="AAPL",
scan_candidates={},
)
# 브로커 잔고에 보유 중이므로 BUY 주문이 억제되어야 함 (이중 매수 방지)
overseas_broker.send_overseas_order.assert_not_called()
@pytest.mark.asyncio
async def test_overseas_buy_proceeds_when_broker_shows_no_holding(
self,
) -> None:
"""BUY must proceed when both DB and broker confirm no existing holding.
Scenario: No prior trades in DB and broker balance shows no AAPL.
Expected: send_overseas_order IS called (normal buy flow).
"""
db_conn = init_db(":memory:")
# DB: 레코드 없음 (신규 포지션)
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "182.50"}}
)
# 브로커: AAPL 미보유
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [],
"output2": [
{
"frcr_dncl_amt_2": "50000.00",
"frcr_evlu_tota": "50000.00",
"frcr_buy_amt_smtl": "0.00",
}
],
}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL"))
market = MagicMock()
market.name = "NASDAQ"
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
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="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=MagicMock(),
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US"),
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="AAPL",
scan_candidates={},
)
# DB도 브로커도 보유 없음 → BUY 주문이 실행되어야 함 (회귀 테스트)
overseas_broker.send_overseas_order.assert_called_once()