diff --git a/src/strategy/models.py b/src/strategy/models.py index f860714..f7090f7 100644 --- a/src/strategy/models.py +++ b/src/strategy/models.py @@ -46,6 +46,18 @@ class StockCondition(BaseModel): The ScenarioEngine evaluates all non-None fields as AND conditions. A condition matches only if ALL specified fields are satisfied. + + Technical indicator fields: + rsi_below / rsi_above — RSI threshold + volume_ratio_above / volume_ratio_below — volume vs previous day + price_above / price_below — absolute price level + price_change_pct_above / price_change_pct_below — intraday % change + + Position-aware fields (require market_data enrichment from open position): + unrealized_pnl_pct_above — matches if unrealized P&L > threshold (e.g. 3.0 → +3%) + unrealized_pnl_pct_below — matches if unrealized P&L < threshold (e.g. -2.0 → -2%) + holding_days_above — matches if position held for more than N days + holding_days_below — matches if position held for fewer than N days """ rsi_below: float | None = None @@ -56,6 +68,10 @@ class StockCondition(BaseModel): price_below: float | None = None price_change_pct_above: float | None = None price_change_pct_below: float | None = None + unrealized_pnl_pct_above: float | None = None + unrealized_pnl_pct_below: float | None = None + holding_days_above: int | None = None + holding_days_below: int | None = None def has_any_condition(self) -> bool: """Check if at least one condition field is set.""" @@ -70,6 +86,10 @@ class StockCondition(BaseModel): self.price_below, self.price_change_pct_above, self.price_change_pct_below, + self.unrealized_pnl_pct_above, + self.unrealized_pnl_pct_below, + self.holding_days_above, + self.holding_days_below, ) ) diff --git a/src/strategy/pre_market_planner.py b/src/strategy/pre_market_planner.py index 2c029a2..73069b1 100644 --- a/src/strategy/pre_market_planner.py +++ b/src/strategy/pre_market_planner.py @@ -294,7 +294,8 @@ class PreMarketPlanner: f' "stock_code": "...",\n' f' "scenarios": [\n' f' {{\n' - f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0}},\n' + f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0,' + f' "unrealized_pnl_pct_above": 3.0, "holding_days_above": 5}},\n' f' "action": "BUY|SELL|HOLD",\n' f' "confidence": 85,\n' f' "allocation_pct": 10.0,\n' @@ -390,6 +391,10 @@ class PreMarketPlanner: price_below=cond_data.get("price_below"), price_change_pct_above=cond_data.get("price_change_pct_above"), price_change_pct_below=cond_data.get("price_change_pct_below"), + unrealized_pnl_pct_above=cond_data.get("unrealized_pnl_pct_above"), + unrealized_pnl_pct_below=cond_data.get("unrealized_pnl_pct_below"), + holding_days_above=cond_data.get("holding_days_above"), + holding_days_below=cond_data.get("holding_days_below"), ) if not condition.has_any_condition(): diff --git a/src/strategy/scenario_engine.py b/src/strategy/scenario_engine.py index 85164b1..f1cd530 100644 --- a/src/strategy/scenario_engine.py +++ b/src/strategy/scenario_engine.py @@ -206,6 +206,37 @@ class ScenarioEngine: if condition.price_change_pct_below is not None: checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below) + # Position-aware conditions + unrealized_pnl_pct = self._safe_float(market_data.get("unrealized_pnl_pct")) + if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None: + if "unrealized_pnl_pct" not in market_data: + self._warn_missing_key("unrealized_pnl_pct") + if condition.unrealized_pnl_pct_above is not None: + checks.append( + unrealized_pnl_pct is not None + and unrealized_pnl_pct > condition.unrealized_pnl_pct_above + ) + if condition.unrealized_pnl_pct_below is not None: + checks.append( + unrealized_pnl_pct is not None + and unrealized_pnl_pct < condition.unrealized_pnl_pct_below + ) + + holding_days = self._safe_float(market_data.get("holding_days")) + if condition.holding_days_above is not None or condition.holding_days_below is not None: + if "holding_days" not in market_data: + self._warn_missing_key("holding_days") + if condition.holding_days_above is not None: + checks.append( + holding_days is not None + and holding_days > condition.holding_days_above + ) + if condition.holding_days_below is not None: + checks.append( + holding_days is not None + and holding_days < condition.holding_days_below + ) + return len(checks) > 0 and all(checks) def _evaluate_global_condition( @@ -266,5 +297,9 @@ class ScenarioEngine: details["current_price"] = self._safe_float(market_data.get("current_price")) if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None: details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct")) + if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None: + details["unrealized_pnl_pct"] = self._safe_float(market_data.get("unrealized_pnl_pct")) + if condition.holding_days_above is not None or condition.holding_days_below is not None: + details["holding_days"] = self._safe_float(market_data.get("holding_days")) return details diff --git a/tests/test_scenario_engine.py b/tests/test_scenario_engine.py index 4d8acfe..4fcea51 100644 --- a/tests/test_scenario_engine.py +++ b/tests/test_scenario_engine.py @@ -440,3 +440,135 @@ class TestEvaluate: assert result.action == ScenarioAction.BUY assert result.match_details["rsi"] == 25.0 assert isinstance(result.match_details["rsi"], float) + + +# --------------------------------------------------------------------------- +# Position-aware condition tests (#171) +# --------------------------------------------------------------------------- + + +class TestPositionAwareConditions: + """Tests for unrealized_pnl_pct and holding_days condition fields.""" + + def test_evaluate_condition_unrealized_pnl_above_matches( + self, engine: ScenarioEngine + ) -> None: + """unrealized_pnl_pct_above should match when P&L exceeds threshold.""" + condition = StockCondition(unrealized_pnl_pct_above=3.0) + assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 5.0}) is True + + def test_evaluate_condition_unrealized_pnl_above_no_match( + self, engine: ScenarioEngine + ) -> None: + """unrealized_pnl_pct_above should NOT match when P&L is below threshold.""" + condition = StockCondition(unrealized_pnl_pct_above=3.0) + assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 2.0}) is False + + def test_evaluate_condition_unrealized_pnl_below_matches( + self, engine: ScenarioEngine + ) -> None: + """unrealized_pnl_pct_below should match when P&L is under threshold.""" + condition = StockCondition(unrealized_pnl_pct_below=-2.0) + assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -3.5}) is True + + def test_evaluate_condition_unrealized_pnl_below_no_match( + self, engine: ScenarioEngine + ) -> None: + """unrealized_pnl_pct_below should NOT match when P&L is above threshold.""" + condition = StockCondition(unrealized_pnl_pct_below=-2.0) + assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -1.0}) is False + + def test_evaluate_condition_holding_days_above_matches( + self, engine: ScenarioEngine + ) -> None: + """holding_days_above should match when position held longer than threshold.""" + condition = StockCondition(holding_days_above=5) + assert engine.evaluate_condition(condition, {"holding_days": 7}) is True + + def test_evaluate_condition_holding_days_above_no_match( + self, engine: ScenarioEngine + ) -> None: + """holding_days_above should NOT match when position held shorter.""" + condition = StockCondition(holding_days_above=5) + assert engine.evaluate_condition(condition, {"holding_days": 3}) is False + + def test_evaluate_condition_holding_days_below_matches( + self, engine: ScenarioEngine + ) -> None: + """holding_days_below should match when position held fewer days.""" + condition = StockCondition(holding_days_below=3) + assert engine.evaluate_condition(condition, {"holding_days": 1}) is True + + def test_evaluate_condition_holding_days_below_no_match( + self, engine: ScenarioEngine + ) -> None: + """holding_days_below should NOT match when held more days.""" + condition = StockCondition(holding_days_below=3) + assert engine.evaluate_condition(condition, {"holding_days": 5}) is False + + def test_combined_pnl_and_holding_days(self, engine: ScenarioEngine) -> None: + """Combined position-aware conditions should AND-evaluate correctly.""" + condition = StockCondition( + unrealized_pnl_pct_above=3.0, + holding_days_above=5, + ) + # Both met → match + assert engine.evaluate_condition( + condition, + {"unrealized_pnl_pct": 4.5, "holding_days": 7}, + ) is True + # Only pnl met → no match + assert engine.evaluate_condition( + condition, + {"unrealized_pnl_pct": 4.5, "holding_days": 3}, + ) is False + + def test_missing_unrealized_pnl_does_not_match( + self, engine: ScenarioEngine + ) -> None: + """Missing unrealized_pnl_pct key should not match the condition.""" + condition = StockCondition(unrealized_pnl_pct_above=3.0) + assert engine.evaluate_condition(condition, {}) is False + + def test_missing_holding_days_does_not_match( + self, engine: ScenarioEngine + ) -> None: + """Missing holding_days key should not match the condition.""" + condition = StockCondition(holding_days_above=5) + assert engine.evaluate_condition(condition, {}) is False + + def test_match_details_includes_position_fields( + self, engine: ScenarioEngine + ) -> None: + """match_details should include position fields when condition specifies them.""" + pb = _playbook( + scenarios=[ + StockScenario( + condition=StockCondition(unrealized_pnl_pct_above=3.0), + action=ScenarioAction.SELL, + confidence=90, + rationale="Take profit", + ) + ] + ) + result = engine.evaluate( + pb, + "005930", + {"unrealized_pnl_pct": 5.0}, + {}, + ) + assert result.action == ScenarioAction.SELL + assert "unrealized_pnl_pct" in result.match_details + assert result.match_details["unrealized_pnl_pct"] == 5.0 + + def test_position_conditions_parse_from_planner(self) -> None: + """StockCondition should accept and store new fields from JSON parsing.""" + condition = StockCondition( + unrealized_pnl_pct_above=3.0, + unrealized_pnl_pct_below=None, + holding_days_above=5, + holding_days_below=None, + ) + assert condition.unrealized_pnl_pct_above == 3.0 + assert condition.holding_days_above == 5 + assert condition.has_any_condition() is True