From b1f48d859ec506e5872264d3e5b2cd959ec89e86 Mon Sep 17 00:00:00 2001 From: agentson Date: Fri, 20 Feb 2026 08:25:38 +0900 Subject: [PATCH] feat: include current holdings in pre-market AI prompt (#170) - Add current_holdings parameter to generate_playbook() and _build_prompt() - Inject '## Current Holdings' section into Gemini prompt with qty, entry price, unrealized PnL%, and holding days for each held position - Instruct AI to generate SELL/HOLD scenarios for held stocks even if not in scanner candidates list - Allow held stock codes in _parse_response() valid_codes set so AI- generated SELL scenarios for holdings pass validation - Add 6 tests covering prompt inclusion, omission, and response parsing Closes #170 Co-Authored-By: Claude Sonnet 4.6 --- src/strategy/pre_market_planner.py | 50 ++++++++- tests/test_pre_market_planner.py | 168 +++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 2 deletions(-) diff --git a/src/strategy/pre_market_planner.py b/src/strategy/pre_market_planner.py index 2c029a2..3d15e8f 100644 --- a/src/strategy/pre_market_planner.py +++ b/src/strategy/pre_market_planner.py @@ -75,6 +75,7 @@ class PreMarketPlanner: market: str, candidates: list[ScanCandidate], today: date | None = None, + current_holdings: list[dict] | None = None, ) -> DayPlaybook: """Generate a DayPlaybook for a market using Gemini. @@ -82,6 +83,10 @@ class PreMarketPlanner: market: Market code ("KR" or "US") candidates: Stock candidates from SmartVolatilityScanner today: Override date (defaults to date.today()). Use market-local date. + current_holdings: Currently held positions with entry_price and unrealized_pnl_pct. + Each dict: {"stock_code": str, "name": str, "qty": int, + "entry_price": float, "unrealized_pnl_pct": float, + "holding_days": int} Returns: DayPlaybook with scenarios. Empty/defensive if no candidates or failure. @@ -106,6 +111,7 @@ class PreMarketPlanner: context_data, self_market_scorecard, cross_market, + current_holdings=current_holdings, ) # 3. Call Gemini @@ -118,7 +124,8 @@ class PreMarketPlanner: # 4. Parse response playbook = self._parse_response( - decision.rationale, today, market, candidates, cross_market + decision.rationale, today, market, candidates, cross_market, + current_holdings=current_holdings, ) playbook_with_tokens = playbook.model_copy( update={"token_count": decision.token_count} @@ -230,6 +237,7 @@ class PreMarketPlanner: context_data: dict[str, Any], self_market_scorecard: dict[str, Any] | None, cross_market: CrossMarketContext | None, + current_holdings: list[dict] | None = None, ) -> str: """Build a structured prompt for Gemini to generate scenario JSON.""" max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK @@ -241,6 +249,26 @@ class PreMarketPlanner: for c in candidates ) + holdings_text = "" + if current_holdings: + lines = [] + for h in current_holdings: + code = h.get("stock_code", "") + name = h.get("name", "") + qty = h.get("qty", 0) + entry_price = h.get("entry_price", 0.0) + pnl_pct = h.get("unrealized_pnl_pct", 0.0) + holding_days = h.get("holding_days", 0) + lines.append( + f" - {code} ({name}): {qty}주 @ {entry_price:,.0f}, " + f"미실현손익 {pnl_pct:+.2f}%, 보유 {holding_days}일" + ) + holdings_text = ( + "\n## Current Holdings (보유 중 — SELL/HOLD 전략 고려 필요)\n" + + "\n".join(lines) + + "\n" + ) + cross_market_text = "" if cross_market: cross_market_text = ( @@ -273,10 +301,20 @@ class PreMarketPlanner: for key, value in list(layer_data.items())[:5]: context_text += f" - {key}: {value}\n" + holdings_instruction = "" + if current_holdings: + holding_codes = [h.get("stock_code", "") for h in current_holdings] + holdings_instruction = ( + f"- Also include SELL/HOLD scenarios for held stocks: " + f"{', '.join(holding_codes)} " + f"(even if not in candidates list)\n" + ) + return ( f"You are a pre-market trading strategist for the {market} market.\n" f"Generate structured trading scenarios for today.\n\n" f"## Candidates (from volatility scanner)\n{candidates_text}\n" + f"{holdings_text}" f"{self_market_text}" f"{cross_market_text}" f"{context_text}\n" @@ -308,7 +346,8 @@ class PreMarketPlanner: f'}}\n\n' f"Rules:\n" f"- Max {max_scenarios} scenarios per stock\n" - f"- Only use stocks from the candidates list\n" + f"- Candidates list is the primary source for BUY candidates\n" + f"{holdings_instruction}" f"- Confidence 0-100 (80+ for actionable trades)\n" f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\n" f"- Return ONLY the JSON, no markdown fences or explanation\n" @@ -321,12 +360,19 @@ class PreMarketPlanner: market: str, candidates: list[ScanCandidate], cross_market: CrossMarketContext | None, + current_holdings: list[dict] | None = None, ) -> DayPlaybook: """Parse Gemini's JSON response into a validated DayPlaybook.""" cleaned = self._extract_json(response_text) data = json.loads(cleaned) valid_codes = {c.stock_code for c in candidates} + # Holdings are also valid — AI may generate SELL/HOLD scenarios for them + if current_holdings: + for h in current_holdings: + code = h.get("stock_code", "") + if code: + valid_codes.add(code) # Parse market outlook outlook_str = data.get("market_outlook", "neutral") diff --git a/tests/test_pre_market_planner.py b/tests/test_pre_market_planner.py index b37cbe8..50e2a3b 100644 --- a/tests/test_pre_market_planner.py +++ b/tests/test_pre_market_planner.py @@ -830,3 +830,171 @@ class TestSmartFallbackPlaybook: ] assert len(buy_scenarios) == 1 assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default + + +# --------------------------------------------------------------------------- +# Holdings in prompt (#170) +# --------------------------------------------------------------------------- + + +class TestHoldingsInPrompt: + """Tests for current_holdings parameter in generate_playbook / _build_prompt.""" + + def _make_holdings(self) -> list[dict]: + return [ + { + "stock_code": "005930", + "name": "Samsung", + "qty": 10, + "entry_price": 71000.0, + "unrealized_pnl_pct": 2.3, + "holding_days": 3, + } + ] + + def test_build_prompt_includes_holdings_section(self) -> None: + """Prompt should contain a Current Holdings section when holdings are given.""" + planner = _make_planner() + candidates = [_candidate()] + holdings = self._make_holdings() + + prompt = planner._build_prompt( + "KR", + candidates, + context_data={}, + self_market_scorecard=None, + cross_market=None, + current_holdings=holdings, + ) + + assert "## Current Holdings" in prompt + assert "005930" in prompt + assert "+2.30%" in prompt + assert "보유 3일" in prompt + + def test_build_prompt_no_holdings_omits_section(self) -> None: + """Prompt should NOT contain a Current Holdings section when holdings=None.""" + planner = _make_planner() + candidates = [_candidate()] + + prompt = planner._build_prompt( + "KR", + candidates, + context_data={}, + self_market_scorecard=None, + cross_market=None, + current_holdings=None, + ) + + assert "## Current Holdings" not in prompt + + def test_build_prompt_empty_holdings_omits_section(self) -> None: + """Empty list should also omit the holdings section.""" + planner = _make_planner() + candidates = [_candidate()] + + prompt = planner._build_prompt( + "KR", + candidates, + context_data={}, + self_market_scorecard=None, + cross_market=None, + current_holdings=[], + ) + + assert "## Current Holdings" not in prompt + + def test_build_prompt_holdings_instruction_included(self) -> None: + """Prompt should include instruction to generate scenarios for held stocks.""" + planner = _make_planner() + candidates = [_candidate()] + holdings = self._make_holdings() + + prompt = planner._build_prompt( + "KR", + candidates, + context_data={}, + self_market_scorecard=None, + cross_market=None, + current_holdings=holdings, + ) + + assert "005930" in prompt + assert "SELL/HOLD" in prompt + + @pytest.mark.asyncio + async def test_generate_playbook_passes_holdings_to_prompt(self) -> None: + """generate_playbook should pass current_holdings through to the prompt.""" + planner = _make_planner() + candidates = [_candidate()] + holdings = self._make_holdings() + + # Capture the actual prompt sent to Gemini + captured_prompts: list[str] = [] + original_decide = planner._gemini.decide + + async def capture_and_call(data: dict) -> TradeDecision: + captured_prompts.append(data.get("prompt_override", "")) + return await original_decide(data) + + planner._gemini.decide = capture_and_call # type: ignore[method-assign] + + await planner.generate_playbook( + "KR", candidates, today=date(2026, 2, 8), current_holdings=holdings + ) + + assert len(captured_prompts) == 1 + assert "## Current Holdings" in captured_prompts[0] + assert "005930" in captured_prompts[0] + + @pytest.mark.asyncio + async def test_holdings_stock_allowed_in_parse_response(self) -> None: + """Holdings stocks not in candidates list should be accepted in the response.""" + holding_code = "000660" # Not in candidates + stocks = [ + { + "stock_code": "005930", # candidate + "scenarios": [ + { + "condition": {"rsi_below": 30}, + "action": "BUY", + "confidence": 85, + "rationale": "oversold", + } + ], + }, + { + "stock_code": holding_code, # holding only + "scenarios": [ + { + "condition": {"price_change_pct_below": -2.0}, + "action": "SELL", + "confidence": 90, + "rationale": "stop-loss", + } + ], + }, + ] + planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks)) + candidates = [_candidate()] # only 005930 + holdings = [ + { + "stock_code": holding_code, + "name": "SK Hynix", + "qty": 5, + "entry_price": 180000.0, + "unrealized_pnl_pct": -1.5, + "holding_days": 7, + } + ] + + pb = await planner.generate_playbook( + "KR", + candidates, + today=date(2026, 2, 8), + current_holdings=holdings, + ) + + codes = [sp.stock_code for sp in pb.stock_playbooks] + assert "005930" in codes + assert holding_code in codes