feat: use playbook allocation_pct in position sizing (#172) #176

Merged
jihoson merged 1 commits from feature/issue-172-playbook-allocation-sizing into main 2026-02-20 08:37:59 +09:00
2 changed files with 105 additions and 1 deletions

View File

@@ -190,8 +190,15 @@ def _determine_order_quantity(
candidate: ScanCandidate | None, candidate: ScanCandidate | None,
settings: Settings | None, settings: Settings | None,
broker_held_qty: int = 0, broker_held_qty: int = 0,
playbook_allocation_pct: float | None = None,
scenario_confidence: int = 80,
) -> int: ) -> int:
"""Determine order quantity using volatility-aware position sizing.""" """Determine order quantity using volatility-aware position sizing.
Priority:
1. playbook_allocation_pct (AI-specified) scaled by scenario_confidence
2. Fallback: volatility-score-based allocation from scanner candidate
"""
if action == "SELL": if action == "SELL":
return broker_held_qty return broker_held_qty
if current_price <= 0 or total_cash <= 0: if current_price <= 0 or total_cash <= 0:
@@ -200,6 +207,22 @@ def _determine_order_quantity(
if settings is None or not settings.POSITION_SIZING_ENABLED: if settings is None or not settings.POSITION_SIZING_ENABLED:
return 1 return 1
# Use AI-specified allocation_pct if available
if playbook_allocation_pct is not None:
# Confidence scaling: confidence 80 → 1.0x, confidence 95 → 1.19x
confidence_scale = scenario_confidence / 80.0
effective_pct = min(
settings.POSITION_MAX_ALLOCATION_PCT,
max(
settings.POSITION_MIN_ALLOCATION_PCT,
playbook_allocation_pct * confidence_scale,
),
)
budget = total_cash * (effective_pct / 100.0)
quantity = int(budget // current_price)
return max(0, quantity)
# Fallback: volatility-score-based allocation
target_score = max(1.0, settings.POSITION_VOLATILITY_TARGET_SCORE) target_score = max(1.0, settings.POSITION_VOLATILITY_TARGET_SCORE)
observed_score = candidate.score if candidate else target_score observed_score = candidate.score if candidate else target_score
observed_score = max(1.0, min(100.0, observed_score)) observed_score = max(1.0, min(100.0, observed_score))
@@ -568,6 +591,7 @@ async def trading_cycle(
if decision.action == "SELL" if decision.action == "SELL"
else 0 else 0
) )
matched_scenario = match.matched_scenario
quantity = _determine_order_quantity( quantity = _determine_order_quantity(
action=decision.action, action=decision.action,
current_price=current_price, current_price=current_price,
@@ -575,6 +599,8 @@ async def trading_cycle(
candidate=candidate, candidate=candidate,
settings=settings, settings=settings,
broker_held_qty=broker_held_qty, broker_held_qty=broker_held_qty,
playbook_allocation_pct=matched_scenario.allocation_pct if matched_scenario else None,
scenario_confidence=match.confidence,
) )
if quantity <= 0: if quantity <= 0:
logger.info( logger.info(

View File

@@ -205,6 +205,84 @@ class TestDetermineOrderQuantity:
) )
assert result == 2 assert result == 2
def test_determine_order_quantity_uses_playbook_allocation_pct(self) -> None:
"""playbook_allocation_pct should take priority over volatility-based sizing."""
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# playbook says 20%, confidence 80 → scale=1.0 → 20%
# 1,000,000 * 20% = 200,000 // 50,000 price = 4 shares
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
playbook_allocation_pct=20.0,
scenario_confidence=80,
)
assert result == 4
def test_determine_order_quantity_confidence_scales_allocation(self) -> None:
"""Higher confidence should produce a larger allocation (up to max)."""
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# confidence 96 → scale=1.2 → 10% * 1.2 = 12%
# 1,000,000 * 12% = 120,000 // 50,000 price = 2 shares
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
playbook_allocation_pct=10.0,
scenario_confidence=96,
)
# scale = 96/80 = 1.2 → effective_pct = 12.0
# budget = 1_000_000 * 0.12 = 120_000 → qty = 120_000 // 50_000 = 2
assert result == 2
def test_determine_order_quantity_confidence_clamped_to_max(self) -> None:
"""Confidence scaling should not exceed POSITION_MAX_ALLOCATION_PCT."""
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_MAX_ALLOCATION_PCT = 15.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# playbook 20% * scale 1.5 = 30% → clamped to 15%
# 1,000,000 * 15% = 150,000 // 50,000 price = 3 shares
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
playbook_allocation_pct=20.0,
scenario_confidence=120, # extreme → scale = 1.5
)
assert result == 3
def test_determine_order_quantity_fallback_when_no_playbook(self) -> None:
"""Without playbook_allocation_pct, falls back to volatility-based sizing."""
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_VOLATILITY_TARGET_SCORE = 50.0
settings.POSITION_BASE_ALLOCATION_PCT = 10.0
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# Same as test_buy_with_position_sizing_calculates_correctly (no playbook)
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
playbook_allocation_pct=None, # explicit None → fallback
)
assert result == 2
class TestSafeFloat: class TestSafeFloat:
"""Test safe_float() helper function.""" """Test safe_float() helper function."""