diff --git a/src/broker/overseas.py b/src/broker/overseas.py index bdf4bcc..33098a6 100644 --- a/src/broker/overseas.py +++ b/src/broker/overseas.py @@ -25,6 +25,10 @@ _RANKING_EXCHANGE_MAP: dict[str, str] = { "TSE": "TSE", } +# Price inquiry API (HHDFS00000300) uses the same short exchange codes as rankings. +# NASD → NAS, NYSE → NYS, AMEX → AMS (confirmed: AMEX returns empty, AMS returns price). +_PRICE_EXCHANGE_MAP: dict[str, str] = _RANKING_EXCHANGE_MAP + class OverseasBroker: """KIS Overseas Stock API wrapper that reuses KISBroker infrastructure.""" @@ -58,9 +62,11 @@ class OverseasBroker: session = self._broker._get_session() headers = await self._broker._auth_headers("HHDFS00000300") + # Map internal exchange codes to the short form expected by the price API. + price_excd = _PRICE_EXCHANGE_MAP.get(exchange_code, exchange_code) params = { "AUTH": "", - "EXCD": exchange_code, + "EXCD": price_excd, "SYMB": stock_code, } url = f"{self._broker._base_url}/uapi/overseas-price/v1/quotations/price" diff --git a/src/config.py b/src/config.py index 1a9a5b6..80e3203 100644 --- a/src/config.py +++ b/src/config.py @@ -55,6 +55,11 @@ class Settings(BaseSettings): # Trading mode MODE: str = Field(default="paper", pattern="^(paper|live)$") + # Simulated USD cash for VTS (paper) overseas trading. + # KIS VTS overseas balance API returns errors for most accounts. + # This value is used as a fallback when the balance API returns 0 in paper mode. + PAPER_OVERSEAS_CASH: float = Field(default=50000.0, ge=0.0) + # Trading frequency mode (daily = batch API calls, realtime = per-stock calls) TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$") DAILY_SESSIONS: int = Field(default=4, ge=1, le=10) diff --git a/src/main.py b/src/main.py index b4f147c..1542a0c 100644 --- a/src/main.py +++ b/src/main.py @@ -239,10 +239,33 @@ async def trading_cycle( 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") + # VTS (paper trading) overseas balance API often returns 0 or errors. + # Fall back to configured paper cash so BUY orders can be sized. + if total_cash <= 0 and settings and settings.PAPER_OVERSEAS_CASH > 0: + logger.debug( + "Overseas cash balance is 0 for %s; using paper fallback %.2f", + stock_code, + settings.PAPER_OVERSEAS_CASH, + ) + total_cash = settings.PAPER_OVERSEAS_CASH + current_price = safe_float(price_data.get("output", {}).get("last", "0")) 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) @@ -692,6 +715,16 @@ async def run_daily_session( price_change_pct = safe_float( price_data.get("output", {}).get("rate", "0") ) + # Fall back to scanner candidate price if API returns 0. + if current_price <= 0: + cand_lookup = candidate_map.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, + ) stock_data: dict[str, Any] = { "stock_code": stock_code, @@ -743,6 +776,10 @@ async def run_daily_session( balance_info.get("frcr_buy_amt_smtl", "0") or "0" ) + # VTS overseas balance API often returns 0; use paper fallback. + if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0: + total_cash = settings.PAPER_OVERSEAS_CASH + # Calculate daily P&L % pnl_pct = ( ((total_eval - purchase_total) / purchase_total * 100) diff --git a/src/strategy/pre_market_planner.py b/src/strategy/pre_market_planner.py index bb5772f..2c029a2 100644 --- a/src/strategy/pre_market_planner.py +++ b/src/strategy/pre_market_planner.py @@ -1,7 +1,8 @@ """Pre-market planner — generates DayPlaybook via Gemini before market open. One Gemini API call per market per day. Candidates come from SmartVolatilityScanner. -On failure, returns a defensive playbook (all HOLD, no trades). +On failure, returns a smart rule-based fallback playbook that uses scanner signals +(momentum/oversold) to generate BUY conditions, avoiding the all-HOLD problem. """ from __future__ import annotations @@ -134,7 +135,7 @@ class PreMarketPlanner: except Exception: logger.exception("Playbook generation failed for %s", market) if self._settings.DEFENSIVE_PLAYBOOK_ON_FAILURE: - return self._defensive_playbook(today, market, candidates) + return self._smart_fallback_playbook(today, market, candidates, self._settings) return self._empty_playbook(today, market) def build_cross_market_context( @@ -470,3 +471,99 @@ class PreMarketPlanner: ), ], ) + + @staticmethod + def _smart_fallback_playbook( + today: date, + market: str, + candidates: list[ScanCandidate], + settings: Settings, + ) -> DayPlaybook: + """Rule-based fallback playbook when Gemini is unavailable. + + Uses scanner signals (RSI, volume_ratio) to generate meaningful BUY + conditions instead of the all-SELL defensive playbook. Candidates are + already pre-qualified by SmartVolatilityScanner, so we trust their + signals and build actionable scenarios from them. + + Scenario logic per candidate: + - momentum signal: BUY when volume_ratio exceeds scanner threshold + - oversold signal: BUY when RSI is below oversold threshold + - always: SELL stop-loss at -3.0% as guard + """ + stock_playbooks = [] + for c in candidates: + scenarios: list[StockScenario] = [] + + if c.signal == "momentum": + scenarios.append( + StockScenario( + condition=StockCondition( + volume_ratio_above=settings.VOL_MULTIPLIER, + ), + action=ScenarioAction.BUY, + confidence=80, + allocation_pct=10.0, + stop_loss_pct=-3.0, + take_profit_pct=5.0, + rationale=( + f"Rule-based BUY: momentum signal, " + f"volume={c.volume_ratio:.1f}x (fallback planner)" + ), + ) + ) + elif c.signal == "oversold": + scenarios.append( + StockScenario( + condition=StockCondition( + rsi_below=settings.RSI_OVERSOLD_THRESHOLD, + ), + action=ScenarioAction.BUY, + confidence=80, + allocation_pct=10.0, + stop_loss_pct=-3.0, + take_profit_pct=5.0, + rationale=( + f"Rule-based BUY: oversold signal, " + f"RSI={c.rsi:.0f} (fallback planner)" + ), + ) + ) + + # Always add stop-loss guard + scenarios.append( + StockScenario( + condition=StockCondition(price_change_pct_below=-3.0), + action=ScenarioAction.SELL, + confidence=90, + stop_loss_pct=-3.0, + rationale="Rule-based stop-loss (fallback planner)", + ) + ) + + stock_playbooks.append( + StockPlaybook( + stock_code=c.stock_code, + scenarios=scenarios, + ) + ) + + logger.info( + "Smart fallback playbook for %s: %d stocks with rule-based BUY/SELL conditions", + market, + len(stock_playbooks), + ) + return DayPlaybook( + date=today, + market=market, + market_outlook=MarketOutlook.NEUTRAL, + default_action=ScenarioAction.HOLD, + stock_playbooks=stock_playbooks, + global_rules=[ + GlobalRule( + condition="portfolio_pnl_pct < -2.0", + action=ScenarioAction.REDUCE_ALL, + rationale="Defensive: reduce on loss threshold", + ), + ], + ) diff --git a/tests/test_overseas_broker.py b/tests/test_overseas_broker.py index 0a3eca9..a6381a0 100644 --- a/tests/test_overseas_broker.py +++ b/tests/test_overseas_broker.py @@ -8,7 +8,7 @@ import aiohttp import pytest from src.broker.kis_api import KISBroker -from src.broker.overseas import OverseasBroker, _RANKING_EXCHANGE_MAP +from src.broker.overseas import OverseasBroker, _PRICE_EXCHANGE_MAP, _RANKING_EXCHANGE_MAP from src.config import Settings @@ -302,7 +302,8 @@ class TestGetOverseasPrice: call_args = mock_session.get.call_args params = call_args[1]["params"] - assert params["EXCD"] == "NASD" + # NASD is mapped to NAS for the price inquiry API (same as ranking API). + assert params["EXCD"] == "NAS" assert params["SYMB"] == "AAPL" @pytest.mark.asyncio @@ -519,3 +520,98 @@ class TestExtractRankingRows: def test_filters_non_dict_rows(self, overseas_broker: OverseasBroker) -> None: data = {"output": [{"a": 1}, "invalid", {"b": 2}]} assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}] + + +# --------------------------------------------------------------------------- +# Price exchange code mapping +# --------------------------------------------------------------------------- + + +class TestPriceExchangeMap: + """Test that get_overseas_price uses the short exchange codes.""" + + def test_price_map_equals_ranking_map(self) -> None: + assert _PRICE_EXCHANGE_MAP is _RANKING_EXCHANGE_MAP + + def test_nasd_maps_to_nas(self) -> None: + assert _PRICE_EXCHANGE_MAP["NASD"] == "NAS" + + def test_amex_maps_to_ams(self) -> None: + assert _PRICE_EXCHANGE_MAP["AMEX"] == "AMS" + + def test_nyse_maps_to_nys(self) -> None: + assert _PRICE_EXCHANGE_MAP["NYSE"] == "NYS" + + @pytest.mark.asyncio + async def test_get_overseas_price_uses_mapped_excd( + self, overseas_broker: OverseasBroker + ) -> None: + """AMEX should be sent as AMS to the price API.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output": {"last": "44.30"}}) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp)) + _setup_broker_mocks(overseas_broker, mock_session) + overseas_broker._broker._auth_headers = AsyncMock(return_value={}) + + await overseas_broker.get_overseas_price("AMEX", "EWUS") + + params = mock_session.get.call_args[1]["params"] + assert params["EXCD"] == "AMS" # mapped, not raw "AMEX" + assert params["SYMB"] == "EWUS" + + @pytest.mark.asyncio + async def test_get_overseas_price_nasd_uses_nas( + self, overseas_broker: OverseasBroker + ) -> None: + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output": {"last": "220.00"}}) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp)) + _setup_broker_mocks(overseas_broker, mock_session) + overseas_broker._broker._auth_headers = AsyncMock(return_value={}) + + await overseas_broker.get_overseas_price("NASD", "AAPL") + + params = mock_session.get.call_args[1]["params"] + assert params["EXCD"] == "NAS" + + +# --------------------------------------------------------------------------- +# PAPER_OVERSEAS_CASH config default +# --------------------------------------------------------------------------- + + +class TestPaperOverseasCash: + def test_default_value(self) -> None: + settings = Settings( + KIS_APP_KEY="x", + KIS_APP_SECRET="x", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="x", + ) + assert settings.PAPER_OVERSEAS_CASH == 50000.0 + + def test_can_be_set_via_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PAPER_OVERSEAS_CASH", "100000.0") + settings = Settings( + KIS_APP_KEY="x", + KIS_APP_SECRET="x", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="x", + ) + assert settings.PAPER_OVERSEAS_CASH == 100000.0 + + def test_zero_disables_fallback(self) -> None: + settings = Settings( + KIS_APP_KEY="x", + KIS_APP_SECRET="x", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="x", + PAPER_OVERSEAS_CASH=0.0, + ) + assert settings.PAPER_OVERSEAS_CASH == 0.0 diff --git a/tests/test_pre_market_planner.py b/tests/test_pre_market_planner.py index 2093208..b37cbe8 100644 --- a/tests/test_pre_market_planner.py +++ b/tests/test_pre_market_planner.py @@ -164,18 +164,23 @@ class TestGeneratePlaybook: assert pb.market_outlook == MarketOutlook.NEUTRAL @pytest.mark.asyncio - async def test_gemini_failure_returns_defensive(self) -> None: + async def test_gemini_failure_returns_smart_fallback(self) -> None: planner = _make_planner() planner._gemini.decide = AsyncMock(side_effect=RuntimeError("API timeout")) + # oversold candidate (signal="oversold", rsi=28.5) candidates = [_candidate()] pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8)) assert pb.default_action == ScenarioAction.HOLD - assert pb.market_outlook == MarketOutlook.NEUTRAL_TO_BEARISH + # Smart fallback uses NEUTRAL outlook (not NEUTRAL_TO_BEARISH) + assert pb.market_outlook == MarketOutlook.NEUTRAL assert pb.stock_count == 1 - # Defensive playbook has stop-loss scenarios - assert pb.stock_playbooks[0].scenarios[0].action == ScenarioAction.SELL + # Oversold candidate → first scenario is BUY, second is SELL stop-loss + scenarios = pb.stock_playbooks[0].scenarios + assert scenarios[0].action == ScenarioAction.BUY + assert scenarios[0].condition.rsi_below == 30 + assert scenarios[1].action == ScenarioAction.SELL @pytest.mark.asyncio async def test_gemini_failure_empty_when_defensive_disabled(self) -> None: @@ -657,3 +662,171 @@ class TestDefensivePlaybook: assert pb.stock_count == 0 assert pb.market == "US" assert pb.market_outlook == MarketOutlook.NEUTRAL + + +# --------------------------------------------------------------------------- +# Smart fallback playbook +# --------------------------------------------------------------------------- + + +class TestSmartFallbackPlaybook: + """Tests for _smart_fallback_playbook — rule-based BUY/SELL on Gemini failure.""" + + def _make_settings(self) -> Settings: + return Settings( + KIS_APP_KEY="test", + KIS_APP_SECRET="test", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="test", + RSI_OVERSOLD_THRESHOLD=30, + VOL_MULTIPLIER=2.0, + ) + + def test_momentum_candidate_gets_buy_on_volume(self) -> None: + candidates = [ + _candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0) + ] + settings = self._make_settings() + + pb = PreMarketPlanner._smart_fallback_playbook( + date(2026, 2, 17), "US_AMEX", candidates, settings + ) + + assert pb.stock_count == 1 + sp = pb.stock_playbooks[0] + assert sp.stock_code == "CHOW" + # First scenario: BUY with volume_ratio_above + buy_sc = sp.scenarios[0] + assert buy_sc.action == ScenarioAction.BUY + assert buy_sc.condition.volume_ratio_above == 2.0 + assert buy_sc.condition.rsi_below is None + assert buy_sc.confidence == 80 + # Second scenario: stop-loss SELL + sell_sc = sp.scenarios[1] + assert sell_sc.action == ScenarioAction.SELL + assert sell_sc.condition.price_change_pct_below == -3.0 + + def test_oversold_candidate_gets_buy_on_rsi(self) -> None: + candidates = [ + _candidate(code="005930", signal="oversold", rsi=22.0, volume_ratio=3.5) + ] + settings = self._make_settings() + + pb = PreMarketPlanner._smart_fallback_playbook( + date(2026, 2, 17), "KR", candidates, settings + ) + + sp = pb.stock_playbooks[0] + buy_sc = sp.scenarios[0] + assert buy_sc.action == ScenarioAction.BUY + assert buy_sc.condition.rsi_below == 30 + assert buy_sc.condition.volume_ratio_above is None + + def test_all_candidates_have_stop_loss_sell(self) -> None: + candidates = [ + _candidate(code="AAA", signal="momentum", volume_ratio=5.0), + _candidate(code="BBB", signal="oversold", rsi=25.0), + ] + settings = self._make_settings() + + pb = PreMarketPlanner._smart_fallback_playbook( + date(2026, 2, 17), "US_NASDAQ", candidates, settings + ) + + assert pb.stock_count == 2 + for sp in pb.stock_playbooks: + sell_scenarios = [s for s in sp.scenarios if s.action == ScenarioAction.SELL] + assert len(sell_scenarios) == 1 + assert sell_scenarios[0].condition.price_change_pct_below == -3.0 + assert sell_scenarios[0].condition.price_change_pct_below == -3.0 + + def test_market_outlook_is_neutral(self) -> None: + candidates = [_candidate(signal="momentum", volume_ratio=5.0)] + settings = self._make_settings() + + pb = PreMarketPlanner._smart_fallback_playbook( + date(2026, 2, 17), "US_AMEX", candidates, settings + ) + + assert pb.market_outlook == MarketOutlook.NEUTRAL + + def test_default_action_is_hold(self) -> None: + candidates = [_candidate(signal="momentum", volume_ratio=5.0)] + settings = self._make_settings() + + pb = PreMarketPlanner._smart_fallback_playbook( + date(2026, 2, 17), "US_AMEX", candidates, settings + ) + + assert pb.default_action == ScenarioAction.HOLD + + def test_has_global_reduce_all_rule(self) -> None: + candidates = [_candidate(signal="momentum", volume_ratio=5.0)] + settings = self._make_settings() + + pb = PreMarketPlanner._smart_fallback_playbook( + date(2026, 2, 17), "US_AMEX", candidates, settings + ) + + assert len(pb.global_rules) == 1 + rule = pb.global_rules[0] + assert rule.action == ScenarioAction.REDUCE_ALL + assert "portfolio_pnl_pct" in rule.condition + + def test_empty_candidates_returns_empty_playbook(self) -> None: + settings = self._make_settings() + + pb = PreMarketPlanner._smart_fallback_playbook( + date(2026, 2, 17), "US_AMEX", [], settings + ) + + assert pb.stock_count == 0 + + def test_vol_multiplier_applied_from_settings(self) -> None: + """VOL_MULTIPLIER=3.0 should set volume_ratio_above=3.0 for momentum.""" + candidates = [_candidate(signal="momentum", volume_ratio=5.0)] + settings = self._make_settings() + settings = settings.model_copy(update={"VOL_MULTIPLIER": 3.0}) + + pb = PreMarketPlanner._smart_fallback_playbook( + date(2026, 2, 17), "US_AMEX", candidates, settings + ) + + buy_sc = pb.stock_playbooks[0].scenarios[0] + assert buy_sc.condition.volume_ratio_above == 3.0 + + def test_rsi_oversold_threshold_applied_from_settings(self) -> None: + """RSI_OVERSOLD_THRESHOLD=25 should set rsi_below=25 for oversold.""" + candidates = [_candidate(signal="oversold", rsi=22.0)] + settings = self._make_settings() + settings = settings.model_copy(update={"RSI_OVERSOLD_THRESHOLD": 25}) + + pb = PreMarketPlanner._smart_fallback_playbook( + date(2026, 2, 17), "KR", candidates, settings + ) + + buy_sc = pb.stock_playbooks[0].scenarios[0] + assert buy_sc.condition.rsi_below == 25 + + @pytest.mark.asyncio + async def test_generate_playbook_uses_smart_fallback_on_gemini_error(self) -> None: + """generate_playbook() should use smart fallback (not defensive) on API failure.""" + planner = _make_planner() + planner._gemini.decide = AsyncMock(side_effect=ConnectionError("429 quota exceeded")) + # momentum candidate + candidates = [ + _candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0) + ] + + pb = await planner.generate_playbook( + "US_AMEX", candidates, today=date(2026, 2, 18) + ) + + # Should NOT be all-SELL defensive; should have BUY for momentum + assert pb.stock_count == 1 + buy_scenarios = [ + s for s in pb.stock_playbooks[0].scenarios + if s.action == ScenarioAction.BUY + ] + assert len(buy_scenarios) == 1 + assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default