Compare commits

..

1 Commits

Author SHA1 Message Date
agentson
60a22d6cd4 feat: add position-aware conditions to StockCondition (#171)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add unrealized_pnl_pct_above/below and holding_days_above/below fields
  to StockCondition so AI can generate rules like 'P&L > 3% → SELL'
- Evaluate new fields in ScenarioEngine.evaluate_condition() with same
  AND-combining logic as existing technical indicator fields
- Include position fields in _build_match_details() for audit logging
- Parse new fields from AI JSON response in PreMarketPlanner._parse_scenario()
- Update prompt schema example to show new position-aware condition fields
- Add 13 tests covering all new condition combinations and edge cases

Closes #171

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 08:27:44 +09:00
5 changed files with 195 additions and 217 deletions

View File

@@ -46,6 +46,18 @@ class StockCondition(BaseModel):
The ScenarioEngine evaluates all non-None fields as AND conditions. The ScenarioEngine evaluates all non-None fields as AND conditions.
A condition matches only if ALL specified fields are satisfied. 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 rsi_below: float | None = None
@@ -56,6 +68,10 @@ class StockCondition(BaseModel):
price_below: float | None = None price_below: float | None = None
price_change_pct_above: float | None = None price_change_pct_above: float | None = None
price_change_pct_below: 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: def has_any_condition(self) -> bool:
"""Check if at least one condition field is set.""" """Check if at least one condition field is set."""
@@ -70,6 +86,10 @@ class StockCondition(BaseModel):
self.price_below, self.price_below,
self.price_change_pct_above, self.price_change_pct_above,
self.price_change_pct_below, self.price_change_pct_below,
self.unrealized_pnl_pct_above,
self.unrealized_pnl_pct_below,
self.holding_days_above,
self.holding_days_below,
) )
) )

View File

@@ -75,7 +75,6 @@ class PreMarketPlanner:
market: str, market: str,
candidates: list[ScanCandidate], candidates: list[ScanCandidate],
today: date | None = None, today: date | None = None,
current_holdings: list[dict] | None = None,
) -> DayPlaybook: ) -> DayPlaybook:
"""Generate a DayPlaybook for a market using Gemini. """Generate a DayPlaybook for a market using Gemini.
@@ -83,10 +82,6 @@ class PreMarketPlanner:
market: Market code ("KR" or "US") market: Market code ("KR" or "US")
candidates: Stock candidates from SmartVolatilityScanner candidates: Stock candidates from SmartVolatilityScanner
today: Override date (defaults to date.today()). Use market-local date. 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: Returns:
DayPlaybook with scenarios. Empty/defensive if no candidates or failure. DayPlaybook with scenarios. Empty/defensive if no candidates or failure.
@@ -111,7 +106,6 @@ class PreMarketPlanner:
context_data, context_data,
self_market_scorecard, self_market_scorecard,
cross_market, cross_market,
current_holdings=current_holdings,
) )
# 3. Call Gemini # 3. Call Gemini
@@ -124,8 +118,7 @@ class PreMarketPlanner:
# 4. Parse response # 4. Parse response
playbook = self._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( playbook_with_tokens = playbook.model_copy(
update={"token_count": decision.token_count} update={"token_count": decision.token_count}
@@ -237,7 +230,6 @@ class PreMarketPlanner:
context_data: dict[str, Any], context_data: dict[str, Any],
self_market_scorecard: dict[str, Any] | None, self_market_scorecard: dict[str, Any] | None,
cross_market: CrossMarketContext | None, cross_market: CrossMarketContext | None,
current_holdings: list[dict] | None = None,
) -> str: ) -> str:
"""Build a structured prompt for Gemini to generate scenario JSON.""" """Build a structured prompt for Gemini to generate scenario JSON."""
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
@@ -249,26 +241,6 @@ class PreMarketPlanner:
for c in candidates 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 = "" cross_market_text = ""
if cross_market: if cross_market:
cross_market_text = ( cross_market_text = (
@@ -301,20 +273,10 @@ class PreMarketPlanner:
for key, value in list(layer_data.items())[:5]: for key, value in list(layer_data.items())[:5]:
context_text += f" - {key}: {value}\n" 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 ( return (
f"You are a pre-market trading strategist for the {market} market.\n" f"You are a pre-market trading strategist for the {market} market.\n"
f"Generate structured trading scenarios for today.\n\n" f"Generate structured trading scenarios for today.\n\n"
f"## Candidates (from volatility scanner)\n{candidates_text}\n" f"## Candidates (from volatility scanner)\n{candidates_text}\n"
f"{holdings_text}"
f"{self_market_text}" f"{self_market_text}"
f"{cross_market_text}" f"{cross_market_text}"
f"{context_text}\n" f"{context_text}\n"
@@ -332,7 +294,8 @@ class PreMarketPlanner:
f' "stock_code": "...",\n' f' "stock_code": "...",\n'
f' "scenarios": [\n' f' "scenarios": [\n'
f' {{\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' "action": "BUY|SELL|HOLD",\n'
f' "confidence": 85,\n' f' "confidence": 85,\n'
f' "allocation_pct": 10.0,\n' f' "allocation_pct": 10.0,\n'
@@ -346,8 +309,7 @@ class PreMarketPlanner:
f'}}\n\n' f'}}\n\n'
f"Rules:\n" f"Rules:\n"
f"- Max {max_scenarios} scenarios per stock\n" f"- Max {max_scenarios} scenarios per stock\n"
f"- Candidates list is the primary source for BUY candidates\n" f"- Only use stocks from the candidates list\n"
f"{holdings_instruction}"
f"- Confidence 0-100 (80+ for actionable trades)\n" f"- Confidence 0-100 (80+ for actionable trades)\n"
f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\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" f"- Return ONLY the JSON, no markdown fences or explanation\n"
@@ -360,19 +322,12 @@ class PreMarketPlanner:
market: str, market: str,
candidates: list[ScanCandidate], candidates: list[ScanCandidate],
cross_market: CrossMarketContext | None, cross_market: CrossMarketContext | None,
current_holdings: list[dict] | None = None,
) -> DayPlaybook: ) -> DayPlaybook:
"""Parse Gemini's JSON response into a validated DayPlaybook.""" """Parse Gemini's JSON response into a validated DayPlaybook."""
cleaned = self._extract_json(response_text) cleaned = self._extract_json(response_text)
data = json.loads(cleaned) data = json.loads(cleaned)
valid_codes = {c.stock_code for c in candidates} 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 # Parse market outlook
outlook_str = data.get("market_outlook", "neutral") outlook_str = data.get("market_outlook", "neutral")
@@ -436,6 +391,10 @@ class PreMarketPlanner:
price_below=cond_data.get("price_below"), price_below=cond_data.get("price_below"),
price_change_pct_above=cond_data.get("price_change_pct_above"), price_change_pct_above=cond_data.get("price_change_pct_above"),
price_change_pct_below=cond_data.get("price_change_pct_below"), 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(): if not condition.has_any_condition():

View File

@@ -206,6 +206,37 @@ class ScenarioEngine:
if condition.price_change_pct_below is not None: 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) 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) return len(checks) > 0 and all(checks)
def _evaluate_global_condition( def _evaluate_global_condition(
@@ -266,5 +297,9 @@ class ScenarioEngine:
details["current_price"] = self._safe_float(market_data.get("current_price")) 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: 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")) 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 return details

View File

@@ -830,171 +830,3 @@ class TestSmartFallbackPlaybook:
] ]
assert len(buy_scenarios) == 1 assert len(buy_scenarios) == 1
assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default 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

View File

@@ -440,3 +440,135 @@ class TestEvaluate:
assert result.action == ScenarioAction.BUY assert result.action == ScenarioAction.BUY
assert result.match_details["rsi"] == 25.0 assert result.match_details["rsi"] == 25.0
assert isinstance(result.match_details["rsi"], float) 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