Compare commits

..

1 Commits

Author SHA1 Message Date
agentson
d6edbc0fa2 feat: use market_outlook to adjust BUY confidence threshold (#173)
Some checks failed
CI / test (pull_request) Has been cancelled
- Import MarketOutlook at module level in main.py
- After scenario evaluation, check market_outlook and apply BUY confidence
  threshold: BEARISH→90, BULLISH→75, others→settings.CONFIDENCE_THRESHOLD
- BUY actions below the adjusted threshold are downgraded to HOLD with
  a descriptive rationale including the outlook and threshold values
- Add 5 integration tests covering bearish suppression, bearish allow,
  bullish allow, bullish suppression, and neutral default threshold

Closes #173

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 08:31:24 +09:00
2 changed files with 311 additions and 106 deletions

View File

@@ -42,7 +42,7 @@ from src.logging.decision_logger import DecisionLogger
from src.logging_config import setup_logging
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
from src.strategy.models import DayPlaybook
from src.strategy.models import DayPlaybook, MarketOutlook
from src.strategy.playbook_store import PlaybookStore
from src.strategy.pre_market_planner import PreMarketPlanner
from src.strategy.scenario_engine import ScenarioEngine
@@ -190,15 +190,8 @@ def _determine_order_quantity(
candidate: ScanCandidate | None,
settings: Settings | None,
broker_held_qty: int = 0,
playbook_allocation_pct: float | None = None,
scenario_confidence: int = 80,
) -> int:
"""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
"""
"""Determine order quantity using volatility-aware position sizing."""
if action == "SELL":
return broker_held_qty
if current_price <= 0 or total_cash <= 0:
@@ -207,22 +200,6 @@ def _determine_order_quantity(
if settings is None or not settings.POSITION_SIZING_ENABLED:
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)
observed_score = candidate.score if candidate else target_score
observed_score = max(1.0, min(100.0, observed_score))
@@ -480,6 +457,34 @@ async def trading_cycle(
)
stock_playbook = playbook.get_stock_playbook(stock_code)
# 2.1. Apply market_outlook-based BUY confidence threshold
if decision.action == "BUY":
base_threshold = (settings.CONFIDENCE_THRESHOLD if settings else 80)
outlook = playbook.market_outlook
if outlook == MarketOutlook.BEARISH:
min_confidence = 90
elif outlook == MarketOutlook.BULLISH:
min_confidence = 75
else:
min_confidence = base_threshold
if match.confidence < min_confidence:
logger.info(
"BUY suppressed for %s (%s): confidence %d < %d (market_outlook=%s)",
stock_code,
market.name,
match.confidence,
min_confidence,
outlook.value,
)
decision = TradeDecision(
action="HOLD",
confidence=match.confidence,
rationale=(
f"BUY confidence {match.confidence} < {min_confidence} "
f"(market_outlook={outlook.value})"
),
)
if decision.action == "HOLD":
open_position = get_open_position(db_conn, stock_code, market.code)
if open_position:
@@ -591,7 +596,6 @@ async def trading_cycle(
if decision.action == "SELL"
else 0
)
matched_scenario = match.matched_scenario
quantity = _determine_order_quantity(
action=decision.action,
current_price=current_price,
@@ -599,8 +603,6 @@ async def trading_cycle(
candidate=candidate,
settings=settings,
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:
logger.info(

View File

@@ -205,84 +205,6 @@ class TestDetermineOrderQuantity:
)
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:
"""Test safe_float() helper function."""
@@ -2192,3 +2114,284 @@ def test_start_dashboard_server_enabled_starts_thread() -> None:
assert thread == mock_thread
mock_thread_cls.assert_called_once()
mock_thread.start.assert_called_once()
# ---------------------------------------------------------------------------
# market_outlook BUY confidence threshold tests (#173)
# ---------------------------------------------------------------------------
class TestMarketOutlookConfidenceThreshold:
"""Tests for market_outlook-based BUY confidence suppression in trading_cycle."""
@pytest.fixture
def mock_broker(self) -> MagicMock:
broker = MagicMock()
broker.get_current_price = AsyncMock(return_value=(50000.0, 1.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
"output2": [
{
"tot_evlu_amt": "10000000",
"dnca_tot_amt": "5000000",
"pchs_amt_smtl_amt": "9500000",
}
]
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
return broker
@pytest.fixture
def mock_market(self) -> MagicMock:
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
return market
@pytest.fixture
def mock_telegram(self) -> MagicMock:
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
return telegram
def _make_buy_match_with_confidence(
self, confidence: int, stock_code: str = "005930"
) -> ScenarioMatch:
from src.strategy.models import StockScenario
scenario = StockScenario(
condition=StockCondition(rsi_below=30),
action=ScenarioAction.BUY,
confidence=confidence,
allocation_pct=10.0,
)
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=scenario,
action=ScenarioAction.BUY,
confidence=confidence,
rationale="Test buy",
)
def _make_playbook_with_outlook(
self, outlook_str: str, market: str = "KR"
) -> DayPlaybook:
from src.strategy.models import MarketOutlook
outlook_map = {
"bearish": MarketOutlook.BEARISH,
"bullish": MarketOutlook.BULLISH,
"neutral": MarketOutlook.NEUTRAL,
"neutral_to_bullish": MarketOutlook.NEUTRAL_TO_BULLISH,
"neutral_to_bearish": MarketOutlook.NEUTRAL_TO_BEARISH,
}
return DayPlaybook(
date=date(2026, 2, 20),
market=market,
market_outlook=outlook_map[outlook_str],
)
@pytest.mark.asyncio
async def test_bearish_outlook_raises_buy_confidence_threshold(
self,
mock_broker: MagicMock,
mock_market: MagicMock,
mock_telegram: MagicMock,
) -> None:
"""BUY with confidence 85 should be suppressed to HOLD in bearish market."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(85))
playbook = self._make_playbook_with_outlook("bearish")
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=decision_logger,
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
# HOLD should be logged (not BUY) — check decision_logger was called with HOLD
call_args = decision_logger.log_decision.call_args
assert call_args is not None
assert call_args.kwargs["action"] == "HOLD"
@pytest.mark.asyncio
async def test_bearish_outlook_allows_high_confidence_buy(
self,
mock_broker: MagicMock,
mock_market: MagicMock,
mock_telegram: MagicMock,
) -> None:
"""BUY with confidence 92 should proceed in bearish market (threshold=90)."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(92))
playbook = self._make_playbook_with_outlook("bearish")
risk = MagicMock()
risk.validate_order = MagicMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=risk,
db_conn=MagicMock(),
decision_logger=decision_logger,
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
call_args = decision_logger.log_decision.call_args
assert call_args is not None
assert call_args.kwargs["action"] == "BUY"
@pytest.mark.asyncio
async def test_bullish_outlook_lowers_buy_confidence_threshold(
self,
mock_broker: MagicMock,
mock_market: MagicMock,
mock_telegram: MagicMock,
) -> None:
"""BUY with confidence 77 should proceed in bullish market (threshold=75)."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(77))
playbook = self._make_playbook_with_outlook("bullish")
risk = MagicMock()
risk.validate_order = MagicMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=risk,
db_conn=MagicMock(),
decision_logger=decision_logger,
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
call_args = decision_logger.log_decision.call_args
assert call_args is not None
assert call_args.kwargs["action"] == "BUY"
@pytest.mark.asyncio
async def test_bullish_outlook_suppresses_very_low_confidence_buy(
self,
mock_broker: MagicMock,
mock_market: MagicMock,
mock_telegram: MagicMock,
) -> None:
"""BUY with confidence 70 should be suppressed even in bullish market (threshold=75)."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(70))
playbook = self._make_playbook_with_outlook("bullish")
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=decision_logger,
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
call_args = decision_logger.log_decision.call_args
assert call_args is not None
assert call_args.kwargs["action"] == "HOLD"
@pytest.mark.asyncio
async def test_neutral_outlook_uses_default_threshold(
self,
mock_broker: MagicMock,
mock_market: MagicMock,
mock_telegram: MagicMock,
) -> None:
"""BUY with confidence 82 should proceed in neutral market (default=80)."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(82))
playbook = self._make_playbook_with_outlook("neutral")
risk = MagicMock()
risk.validate_order = MagicMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="decision-id")
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=risk,
db_conn=MagicMock(),
decision_logger=decision_logger,
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
call_args = decision_logger.log_decision.call_args
assert call_args is not None
assert call_args.kwargs["action"] == "BUY"