From ce5ea5abde108153fa8382dd9de6370e9f3754df Mon Sep 17 00:00:00 2001 From: agentson Date: Wed, 25 Feb 2026 02:18:11 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=99=B8?= =?UTF-8?q?=20=EB=A7=A4=EC=9E=85=20=EC=A2=85=EB=AA=A9=EC=97=90=20pchs=5Fav?= =?UTF-8?q?g=5Fpric=20=EB=B0=98=EC=98=81=20(#249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main.py | 36 +++++++++++- tests/test_main.py | 140 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index ee5e051..daaebd2 100644 --- a/src/main.py +++ b/src/main.py @@ -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, diff --git a/tests/test_main.py b/tests/test_main.py index 4f0a848..d6fb3f5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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