feat: add position-aware conditions to StockCondition (#171) #175
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user