diff --git a/src/main.py b/src/main.py index 14c8e40..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. @@ -581,6 +583,28 @@ 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 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)) + 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) @@ -1499,8 +1523,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 +2837,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..0662127 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,13 +1702,70 @@ 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 + 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,