feat: 시스템 외 매입 종목에 pchs_avg_pric 반영 (#249)
Some checks failed
CI / test (pull_request) Has been cancelled

sync_positions_from_broker()에서 price=0.0 하드코딩으로 인해
stop-loss/take-profit이 외부 매수 종목에 작동하지 않던 문제를 수정한다.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
agentson
2026-02-25 02:18:11 +09:00
parent 5ae302b083
commit ce5ea5abde
2 changed files with 175 additions and 1 deletions

View File

@@ -182,6 +182,9 @@ async def sync_positions_from_broker(
qty = _extract_held_qty_from_balance(
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(
conn=db_conn,
stock_code=stock_code,
@@ -189,7 +192,7 @@ async def sync_positions_from_broker(
confidence=0,
rationale="[startup-sync] Position detected from broker at startup",
quantity=qty,
price=0.0,
price=avg_price,
market=log_market,
exchange_code=market.exchange_code,
mode=settings.MODE,
@@ -321,6 +324,37 @@ def _extract_held_qty_from_balance(
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(
*,
action: str,

View File

@@ -15,6 +15,7 @@ from src.logging.decision_logger import DecisionLogger
from src.main import (
_apply_dashboard_flag,
_determine_order_quantity,
_extract_avg_price_from_balance,
_extract_held_codes_from_balance,
_extract_held_qty_from_balance,
_handle_market_close,
@@ -76,6 +77,81 @@ 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:
"""Tests for _extract_held_qty_from_balance()."""
@@ -3818,6 +3894,70 @@ class TestSyncPositionsFromBroker:
# Two distinct exchange codes (NASD, NYSE) → 2 calls
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_dncl_amt_2": "10000", "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