Compare commits
1 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6edbc0fa2 |
30
src/main.py
30
src/main.py
@@ -42,7 +42,7 @@ from src.logging.decision_logger import DecisionLogger
|
|||||||
from src.logging_config import setup_logging
|
from src.logging_config import setup_logging
|
||||||
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
|
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
|
||||||
from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
|
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.playbook_store import PlaybookStore
|
||||||
from src.strategy.pre_market_planner import PreMarketPlanner
|
from src.strategy.pre_market_planner import PreMarketPlanner
|
||||||
from src.strategy.scenario_engine import ScenarioEngine
|
from src.strategy.scenario_engine import ScenarioEngine
|
||||||
@@ -457,6 +457,34 @@ async def trading_cycle(
|
|||||||
)
|
)
|
||||||
stock_playbook = playbook.get_stock_playbook(stock_code)
|
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":
|
if decision.action == "HOLD":
|
||||||
open_position = get_open_position(db_conn, stock_code, market.code)
|
open_position = get_open_position(db_conn, stock_code, market.code)
|
||||||
if open_position:
|
if open_position:
|
||||||
|
|||||||
@@ -46,18 +46,6 @@ 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
|
||||||
@@ -68,10 +56,6 @@ 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."""
|
||||||
@@ -86,10 +70,6 @@ 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,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -294,8 +294,7 @@ 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,'
|
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0}},\n'
|
||||||
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'
|
||||||
@@ -391,10 +390,6 @@ 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():
|
||||||
|
|||||||
@@ -206,37 +206,6 @@ 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(
|
||||||
@@ -297,9 +266,5 @@ 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
|
||||||
|
|||||||
@@ -2114,3 +2114,284 @@ def test_start_dashboard_server_enabled_starts_thread() -> None:
|
|||||||
assert thread == mock_thread
|
assert thread == mock_thread
|
||||||
mock_thread_cls.assert_called_once()
|
mock_thread_cls.assert_called_once()
|
||||||
mock_thread.start.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"
|
||||||
|
|||||||
@@ -440,135 +440,3 @@ 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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user