From ccb00ee77d97eaed8077ab621cf17e72ca157038 Mon Sep 17 00:00:00 2001 From: agentson Date: Thu, 26 Feb 2026 01:39:45 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=20WARNING=202?= =?UTF-8?q?=EC=A2=85=20=EC=88=98=EC=A0=95=20-=20scanner=20=EC=98=A4?= =?UTF-8?q?=ED=95=B4=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=B0=8F=20=ED=99=80?= =?UTF-8?q?=EB=94=A9=20=EC=A2=85=EB=AA=A9=20rsi=20=EB=88=84=EB=9D=BD=20(#2?= =?UTF-8?q?67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. WARNING → DEBUG: fallback_stocks 없어도 overseas ranking API로 scanner 정상 동작하므로 오해를 주는 WARNING 레벨을 DEBUG로 낮춤 (2곳) 2. 홀딩 종목 market_data 보강: scanner를 통하지 않은 종목(NVDA 등)에 price_change_pct 기반 implied_rsi와 volume_ratio=1.0 기본값 설정, scenario_engine 조건 평가 완전화 3. test_main.py: 새로운 동작에 맞게 관련 테스트 2개 업데이트 Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 14 ++++++++++---- tests/test_main.py | 14 ++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/main.py b/src/main.py index 14c8e40..3b652bb 100644 --- a/src/main.py +++ b/src/main.py @@ -581,6 +581,11 @@ async def trading_cycle( if candidate: market_data["rsi"] = candidate.rsi market_data["volume_ratio"] = candidate.volume_ratio + else: + # Holding stocks not in scanner: derive implied RSI from price change, + # volume_ratio defaults to 1.0 (no surge data available). + market_data["rsi"] = max(0.0, min(100.0, 50.0 + price_change_pct * 2.0)) + 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) @@ -1499,8 +1504,9 @@ async def run_daily_session( active_stocks={}, ) if not fallback_stocks: - logger.warning( - "No dynamic overseas symbol universe for %s; scanner cannot run", + logger.debug( + "No dynamic overseas symbol universe for %s;" + " scanner will use overseas ranking API", market.code, ) try: @@ -2812,9 +2818,9 @@ async def run(settings: Settings) -> None: active_stocks=active_stocks, ) if not fallback_stocks: - logger.warning( + logger.debug( "No dynamic overseas symbol universe for %s;" - " scanner cannot run", + " scanner will use overseas ranking API", market.code, ) diff --git a/tests/test_main.py b/tests/test_main.py index a8a0aa7..3f15a00 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1668,10 +1668,10 @@ class TestScenarioEngineIntegration: scan_candidates={"US": {"005930": us_candidate}}, # Wrong market ) - # Should NOT have rsi/volume_ratio because candidate is under US, not KR + # Should NOT use US candidate's rsi (=15.0); fallback implied_rsi used instead market_data = engine.evaluate.call_args[0][2] - assert "rsi" not in market_data - assert "volume_ratio" not in market_data + assert market_data["rsi"] != 15.0 # US candidate's rsi must be ignored + assert market_data["volume_ratio"] == 1.0 # Fallback default @pytest.mark.asyncio async def test_scenario_engine_called_without_scanner_data( @@ -1702,11 +1702,13 @@ class TestScenarioEngineIntegration: scan_candidates={}, # No scanner data ) - # Should still work, just without rsi/volume_ratio + # Holding stocks without scanner data use implied_rsi (from price_change_pct) + # and volume_ratio=1.0 as fallback, so rsi/volume_ratio are always present. engine.evaluate.assert_called_once() market_data = engine.evaluate.call_args[0][2] - assert "rsi" not in market_data - assert "volume_ratio" not in market_data + assert "rsi" in market_data # Implied RSI from price_change_pct=2.5 → 55.0 + assert market_data["rsi"] == pytest.approx(55.0) + assert market_data["volume_ratio"] == 1.0 assert market_data["current_price"] == 50000.0 @pytest.mark.asyncio From 9d7ca12275697bf4158cd16d3dcc671520d1c393 Mon Sep 17 00:00:00 2001 From: agentson Date: Thu, 26 Feb 2026 01:45:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=ED=99=80=EB=94=A9=20=EC=A2=85?= =?UTF-8?q?=EB=AA=A9=20volume=5Fratio=EB=A5=BC=20price=20API=20high/low=20?= =?UTF-8?q?=EC=8B=A4=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A1=9C=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20(#267)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit candidate 없는 해외 홀딩 종목(NVDA 등)에 대해 이미 호출된 get_overseas_price 응답의 high/low를 활용하여 scanner와 동일한 방식으로 volume_ratio 계산: intraday_range_pct = (high - low) / price * 100 volume_ratio = max(1.0, volatility_pct / 2.0) high/low 미제공 시(국내 종목, API 미응답) 기존 기본값 1.0 유지. implied_rsi는 이미 실API price_change_pct(rate 필드) 기반. tests/test_main.py: 해외 홀딩 종목 volume_ratio 계산 검증 테스트 추가 Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 29 +++++++++++++++++++----- tests/test_main.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/main.py b/src/main.py index 3b652bb..68122d1 100644 --- a/src/main.py +++ b/src/main.py @@ -477,6 +477,7 @@ async def trading_cycle( cycle_start_time = asyncio.get_event_loop().time() # 1. Fetch market data + price_output: dict[str, Any] = {} # Populated for overseas markets; used for fallback metrics if market.is_domestic: current_price, price_change_pct, foreigner_net = await broker.get_current_price( stock_code @@ -511,7 +512,8 @@ async def trading_cycle( 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")) + 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) @@ -523,7 +525,7 @@ async def trading_cycle( ) 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_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. @@ -582,10 +584,27 @@ async def trading_cycle( market_data["rsi"] = candidate.rsi market_data["volume_ratio"] = candidate.volume_ratio else: - # Holding stocks not in scanner: derive implied RSI from price change, - # volume_ratio defaults to 1.0 (no surge data available). + # 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)) - market_data["volume_ratio"] = 1.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) diff --git a/tests/test_main.py b/tests/test_main.py index 3f15a00..0662127 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1711,6 +1711,61 @@ class TestScenarioEngineIntegration: assert market_data["volume_ratio"] == 1.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": {"ord_psbl_frcr_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 async def test_scenario_matched_notification_sent( self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,