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,