From df12be13051d28a024caa9f2a326046a996038d6 Mon Sep 17 00:00:00 2001 From: agentson Date: Thu, 26 Feb 2026 01:29:46 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=95=B4=EC=99=B8=20cash=3D0.00=20?= =?UTF-8?q?=EB=B0=8F=20get=5Fopen=5Fposition=20HOLD=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=88=98=EC=A0=95=20(#264,=20#265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 변경사항 ### #264 — 해외 매수가능금액 조회 API 교체 (frcr_dncl_amt_2 → inquire-psamount) - TTTS3012R (해외주식 잔고) output2에 frcr_dncl_amt_2 필드가 존재하지 않아 총 가용 현금이 항상 0.00으로 산출되는 문제 수정 - OverseasBroker에 get_overseas_buying_power() 메서드 추가 (TR_ID: 실전 TTTS3007R / 모의 VTTS3007R, ord_psbl_frcr_amt 반환) - main.py trading_cycle() 및 daily cycle 모두 수정 - 출처: 한국투자증권 오픈API 전체문서 (20260221) — 해외주식 매수가능금액조회 시트 ### #265 — get_open_position() HOLD 레코드 필터링 추가 - HOLD 결정도 trades 테이블에 저장되어 BUY 이후 HOLD 기록 시 최신 레코드가 HOLD → get_open_position이 None 반환하는 문제 수정 - 쿼리에 AND action IN ('BUY', 'SELL') 필터 추가 - HOLD 레코드를 제외하고 마지막 BUY/SELL 기록만 확인 Co-Authored-By: Claude Sonnet 4.6 --- src/broker/overseas.py | 53 ++++++++++++++++++++++++ src/db.py | 1 + src/main.py | 91 ++++++++++++++++++++++++++++-------------- tests/test_main.py | 74 +++++++++++++++++++++++++--------- 4 files changed, 170 insertions(+), 49 deletions(-) diff --git a/src/broker/overseas.py b/src/broker/overseas.py index 69504a6..d98ea67 100644 --- a/src/broker/overseas.py +++ b/src/broker/overseas.py @@ -222,6 +222,59 @@ class OverseasBroker: f"Network error fetching overseas balance: {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( self, exchange_code: str, diff --git a/src/db.py b/src/db.py index d25239a..9c24584 100644 --- a/src/db.py +++ b/src/db.py @@ -258,6 +258,7 @@ def get_open_position( FROM trades WHERE stock_code = ? AND market = ? + AND action IN ('BUY', 'SELL') ORDER BY timestamp DESC LIMIT 1 """, diff --git a/src/main.py b/src/main.py index 67115a5..14c8e40 100644 --- a/src/main.py +++ b/src/main.py @@ -508,9 +508,43 @@ async def trading_cycle( balance_info = {} 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") + # Resolve current price first (needed for buying power API) + current_price = safe_float(price_data.get("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_data.get("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("ord_psbl_frcr_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. # Only activate in paper mode — live mode must use real balance from KIS. if ( @@ -526,34 +560,6 @@ async def trading_cycle( ) 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 % pnl_pct = ( ((total_eval - purchase_total) / purchase_total * 100) @@ -1659,10 +1665,35 @@ async def run_daily_session( balance_info = {} 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" ) + + # 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("ord_psbl_frcr_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. # Only activate in paper mode — live mode must use real balance from KIS. if ( diff --git a/tests/test_main.py b/tests/test_main.py index 0480c46..a8a0aa7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -903,12 +903,14 @@ class TestOverseasBalanceParsing: "output2": [ { "frcr_evlu_tota": "10000.00", - "frcr_dncl_amt_2": "5000.00", "frcr_buy_amt_smtl": "4500.00", } ] } ) + broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "5000.00"}} + ) return broker @pytest.fixture @@ -922,11 +924,13 @@ class TestOverseasBalanceParsing: return_value={ "output2": { "frcr_evlu_tota": "10000.00", - "frcr_dncl_amt_2": "5000.00", "frcr_buy_amt_smtl": "4500.00", } } ) + broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "5000.00"}} + ) return broker @pytest.fixture @@ -937,6 +941,9 @@ class TestOverseasBalanceParsing: return_value={"output": {"last": "150.50"}} ) broker.get_overseas_balance = AsyncMock(return_value={"output2": []}) + broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "0.00"}} + ) return broker @pytest.fixture @@ -951,12 +958,15 @@ class TestOverseasBalanceParsing: "output2": [ { "frcr_evlu_tota": "10000.00", - "frcr_dncl_amt_2": "5000.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": {"ord_psbl_frcr_amt": "5000.00"}} + ) return broker @pytest.fixture @@ -1186,12 +1196,14 @@ class TestOverseasBalanceParsing: "output2": [ { "frcr_evlu_tota": "100000.00", - "frcr_dncl_amt_2": "50000.00", "frcr_buy_amt_smtl": "50000.00", } ] } ) + broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "50000.00"}} + ) broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"}) return broker @@ -1291,12 +1303,14 @@ class TestOverseasBalanceParsing: "output2": [ { "frcr_evlu_tota": "100000.00", - "frcr_dncl_amt_2": "50000.00", "frcr_buy_amt_smtl": "50000.00", } ], } ) + overseas_broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "50000.00"}} + ) overseas_broker.send_overseas_order = AsyncMock( return_value={"rt_cd": "0", "msg1": "OK"} ) @@ -1355,9 +1369,12 @@ class TestOverseasBalanceParsing: overseas_broker.get_overseas_balance = AsyncMock( return_value={ "output1": [], - "output2": [{"frcr_evlu_tota": "0", "frcr_dncl_amt_2": "10000", "frcr_buy_amt_smtl": "0"}], + "output2": [{"frcr_evlu_tota": "0", "frcr_buy_amt_smtl": "0"}], } ) + overseas_broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "10000"}} + ) overseas_broker.get_overseas_price = AsyncMock( return_value={"output": {"last": "50.1234", "rate": "0"}} ) @@ -1413,9 +1430,12 @@ class TestOverseasBalanceParsing: overseas_broker.get_overseas_balance = AsyncMock( return_value={ "output1": [], - "output2": [{"frcr_evlu_tota": "0", "frcr_dncl_amt_2": "10000", "frcr_buy_amt_smtl": "0"}], + "output2": [{"frcr_evlu_tota": "0", "frcr_buy_amt_smtl": "0"}], } ) + overseas_broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "10000"}} + ) overseas_broker.get_overseas_price = AsyncMock( return_value={"output": {"last": "0.5678", "rate": "0"}} ) @@ -2781,9 +2801,11 @@ class TestBuyCooldown: ) broker.get_overseas_balance = AsyncMock(return_value={ "output1": [], - "output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000", - "frcr_buy_amt_smtl": "0"}], + "output2": [{"frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}], }) + broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "50000"}} + ) broker.send_overseas_order = AsyncMock( return_value={"rt_cd": "1", "msg1": "모의투자 주문가능금액이 부족합니다."} ) @@ -2896,9 +2918,11 @@ class TestBuyCooldown: ) overseas_broker.get_overseas_balance = AsyncMock(return_value={ "output1": [], - "output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000", - "frcr_buy_amt_smtl": "0"}], + "output2": [{"frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}], }) + overseas_broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "50000"}} + ) overseas_broker.send_overseas_order = AsyncMock( return_value={"rt_cd": "1", "msg1": "기타 오류 메시지"} ) @@ -3293,9 +3317,12 @@ async def test_buy_suppressed_when_open_position_exists() -> None: overseas_broker.get_overseas_balance = AsyncMock( return_value={ "output1": [], - "output2": [{"frcr_dncl_amt_2": "10000", "frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}], + "output2": [{"frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}], } ) + overseas_broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "10000"}} + ) overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"}) engine = MagicMock(spec=ScenarioEngine) @@ -3357,9 +3384,12 @@ async def test_buy_proceeds_when_no_open_position() -> None: overseas_broker.get_overseas_balance = AsyncMock( return_value={ "output1": [], - "output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}], + "output2": [{"frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}], } ) + overseas_broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "50000"}} + ) overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"}) engine = MagicMock(spec=ScenarioEngine) @@ -3460,13 +3490,15 @@ class TestOverseasBrokerIntegration: "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.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "50000.00"}} + ) overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"}) engine = MagicMock(spec=ScenarioEngine) @@ -3534,13 +3566,15 @@ class TestOverseasBrokerIntegration: "output1": [], "output2": [ { - "frcr_dncl_amt_2": "50000.00", "frcr_evlu_tota": "50000.00", "frcr_buy_amt_smtl": "0.00", } ], } ) + overseas_broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ord_psbl_frcr_amt": "50000.00"}} + ) overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"}) engine = MagicMock(spec=ScenarioEngine) @@ -3963,7 +3997,6 @@ class TestSyncPositionsFromBroker: "output2": [ { "frcr_evlu_tota": "50000", - "frcr_dncl_amt_2": "10000", "frcr_buy_amt_smtl": "40000", } ], @@ -4131,7 +4164,7 @@ class TestSyncPositionsFromBroker: 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"}], + "output2": [{"frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "40000"}], } broker = MagicMock() overseas_broker = MagicMock() @@ -4789,6 +4822,9 @@ class TestOverseasGhostPositionClose: 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": {"ord_psbl_frcr_amt": "0.00"}} + ) ob.send_overseas_order = AsyncMock(return_value=sell_result) return ob @@ -4811,7 +4847,7 @@ class TestOverseasGhostPositionClose: "output1": [ {"ovrs_pdno": stock_code, "ord_psbl_qty": "5", "ovrs_cblc_qty": "5"} ], - "output2": [{"tot_evlu_amt": "10000", "frcr_dncl_amt_2": "10000"}], + "output2": [{"tot_evlu_amt": "10000"}], } sell_result = {"rt_cd": "1", "msg1": "모의투자 잔고내역이 없습니다"} @@ -4887,7 +4923,7 @@ class TestOverseasGhostPositionClose: current_price = 250.0 balance_data = { "output1": [{"ovrs_pdno": stock_code, "ord_psbl_qty": "5", "ovrs_cblc_qty": "5"}], - "output2": [{"tot_evlu_amt": "100000", "frcr_dncl_amt_2": "100000"}], + "output2": [{"tot_evlu_amt": "100000"}], } sell_result = {"rt_cd": "1", "msg1": "일시적 오류가 발생했습니다"} -- 2.49.1