Some checks failed
CI / test (pull_request) Has been cancelled
Address PR #110 review findings: 1. High — Realtime mode now loads playbook from DB before calling Gemini, preventing duplicate API calls on process restart (4/day budget). 2. Medium — Pass market-local date (via market.timezone) to generate_playbook() and _empty_playbook() instead of date.today(). 3. Medium — scan_candidates restructured from {stock_code: candidate} to {market_code: {stock_code: candidate}} to prevent KR/US symbol collision. New test: test_scan_candidates_market_scoped verifies cross-market isolation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1052 lines
38 KiB
Python
1052 lines
38 KiB
Python
"""Tests for main trading loop integration."""
|
|
|
|
from datetime import date
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
|
from src.main import safe_float, trading_cycle
|
|
from src.strategy.models import (
|
|
DayPlaybook,
|
|
ScenarioAction,
|
|
StockCondition,
|
|
StockScenario,
|
|
)
|
|
from src.strategy.scenario_engine import ScenarioEngine, ScenarioMatch
|
|
|
|
|
|
def _make_playbook(market: str = "KR") -> DayPlaybook:
|
|
"""Create a minimal empty playbook for testing."""
|
|
return DayPlaybook(date=date(2026, 2, 8), market=market)
|
|
|
|
|
|
def _make_buy_match(stock_code: str = "005930") -> ScenarioMatch:
|
|
"""Create a ScenarioMatch that returns BUY."""
|
|
return ScenarioMatch(
|
|
stock_code=stock_code,
|
|
matched_scenario=None,
|
|
action=ScenarioAction.BUY,
|
|
confidence=85,
|
|
rationale="Test buy",
|
|
)
|
|
|
|
|
|
def _make_hold_match(stock_code: str = "005930") -> ScenarioMatch:
|
|
"""Create a ScenarioMatch that returns HOLD."""
|
|
return ScenarioMatch(
|
|
stock_code=stock_code,
|
|
matched_scenario=None,
|
|
action=ScenarioAction.HOLD,
|
|
confidence=0,
|
|
rationale="No scenario conditions met",
|
|
)
|
|
|
|
|
|
class TestSafeFloat:
|
|
"""Test safe_float() helper function."""
|
|
|
|
def test_converts_valid_string(self):
|
|
"""Test conversion of valid numeric string."""
|
|
assert safe_float("123.45") == 123.45
|
|
assert safe_float("0") == 0.0
|
|
assert safe_float("-99.9") == -99.9
|
|
|
|
def test_handles_empty_string(self):
|
|
"""Test empty string returns default."""
|
|
assert safe_float("") == 0.0
|
|
assert safe_float("", 99.0) == 99.0
|
|
|
|
def test_handles_none(self):
|
|
"""Test None returns default."""
|
|
assert safe_float(None) == 0.0
|
|
assert safe_float(None, 42.0) == 42.0
|
|
|
|
def test_handles_invalid_string(self):
|
|
"""Test invalid string returns default."""
|
|
assert safe_float("invalid") == 0.0
|
|
assert safe_float("not_a_number", 100.0) == 100.0
|
|
assert safe_float("12.34.56") == 0.0
|
|
|
|
def test_handles_float_input(self):
|
|
"""Test float input passes through."""
|
|
assert safe_float(123.45) == 123.45
|
|
assert safe_float(0.0) == 0.0
|
|
|
|
def test_custom_default(self):
|
|
"""Test custom default value."""
|
|
assert safe_float("", -1.0) == -1.0
|
|
assert safe_float(None, 999.0) == 999.0
|
|
|
|
|
|
class TestTradingCycleTelegramIntegration:
|
|
"""Test telegram notifications in trading_cycle function."""
|
|
|
|
@pytest.fixture
|
|
def mock_broker(self) -> MagicMock:
|
|
"""Create mock broker."""
|
|
broker = MagicMock()
|
|
broker.get_orderbook = AsyncMock(
|
|
return_value={
|
|
"output1": {
|
|
"stck_prpr": "50000",
|
|
"frgn_ntby_qty": "100",
|
|
}
|
|
}
|
|
)
|
|
broker.get_balance = AsyncMock(
|
|
return_value={
|
|
"output2": [
|
|
{
|
|
"tot_evlu_amt": "10000000",
|
|
"dnca_tot_amt": "5000000",
|
|
"pchs_amt_smtl_amt": "5000000",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
|
return broker
|
|
|
|
@pytest.fixture
|
|
def mock_overseas_broker(self) -> MagicMock:
|
|
"""Create mock overseas broker."""
|
|
broker = MagicMock()
|
|
return broker
|
|
|
|
@pytest.fixture
|
|
def mock_scenario_engine(self) -> MagicMock:
|
|
"""Create mock scenario engine that returns BUY."""
|
|
engine = MagicMock(spec=ScenarioEngine)
|
|
engine.evaluate = MagicMock(return_value=_make_buy_match())
|
|
return engine
|
|
|
|
@pytest.fixture
|
|
def mock_playbook(self) -> DayPlaybook:
|
|
"""Create a minimal day playbook."""
|
|
return _make_playbook()
|
|
|
|
@pytest.fixture
|
|
def mock_risk(self) -> MagicMock:
|
|
"""Create mock risk manager."""
|
|
risk = MagicMock()
|
|
risk.validate_order = MagicMock()
|
|
return risk
|
|
|
|
@pytest.fixture
|
|
def mock_db(self) -> MagicMock:
|
|
"""Create mock database connection."""
|
|
return MagicMock()
|
|
|
|
@pytest.fixture
|
|
def mock_decision_logger(self) -> MagicMock:
|
|
"""Create mock decision logger."""
|
|
logger = MagicMock()
|
|
logger.log_decision = MagicMock()
|
|
return logger
|
|
|
|
@pytest.fixture
|
|
def mock_context_store(self) -> MagicMock:
|
|
"""Create mock context store."""
|
|
store = MagicMock()
|
|
store.get_latest_timeframe = MagicMock(return_value=None)
|
|
return store
|
|
|
|
@pytest.fixture
|
|
def mock_criticality_assessor(self) -> MagicMock:
|
|
"""Create mock criticality assessor."""
|
|
assessor = MagicMock()
|
|
assessor.assess_market_conditions = MagicMock(
|
|
return_value=MagicMock(value="NORMAL")
|
|
)
|
|
assessor.get_timeout = MagicMock(return_value=5.0)
|
|
return assessor
|
|
|
|
@pytest.fixture
|
|
def mock_telegram(self) -> MagicMock:
|
|
"""Create mock telegram client."""
|
|
telegram = MagicMock()
|
|
telegram.notify_trade_execution = AsyncMock()
|
|
telegram.notify_fat_finger = AsyncMock()
|
|
telegram.notify_circuit_breaker = AsyncMock()
|
|
telegram.notify_scenario_matched = AsyncMock()
|
|
return telegram
|
|
|
|
@pytest.fixture
|
|
def mock_market(self) -> MagicMock:
|
|
"""Create mock market info."""
|
|
market = MagicMock()
|
|
market.name = "Korea"
|
|
market.code = "KR"
|
|
market.exchange_code = "KRX"
|
|
market.is_domestic = True
|
|
return market
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trade_execution_notification_sent(
|
|
self,
|
|
mock_broker: MagicMock,
|
|
mock_overseas_broker: MagicMock,
|
|
mock_scenario_engine: MagicMock,
|
|
mock_playbook: DayPlaybook,
|
|
mock_risk: MagicMock,
|
|
mock_db: MagicMock,
|
|
mock_decision_logger: MagicMock,
|
|
mock_context_store: MagicMock,
|
|
mock_criticality_assessor: MagicMock,
|
|
mock_telegram: MagicMock,
|
|
mock_market: MagicMock,
|
|
) -> None:
|
|
"""Test telegram notification sent on trade execution."""
|
|
with patch("src.main.log_trade"):
|
|
await trading_cycle(
|
|
broker=mock_broker,
|
|
overseas_broker=mock_overseas_broker,
|
|
scenario_engine=mock_scenario_engine,
|
|
playbook=mock_playbook,
|
|
risk=mock_risk,
|
|
db_conn=mock_db,
|
|
decision_logger=mock_decision_logger,
|
|
context_store=mock_context_store,
|
|
criticality_assessor=mock_criticality_assessor,
|
|
telegram=mock_telegram,
|
|
market=mock_market,
|
|
stock_code="005930",
|
|
scan_candidates={},
|
|
)
|
|
|
|
# Verify notification was sent
|
|
mock_telegram.notify_trade_execution.assert_called_once()
|
|
call_kwargs = mock_telegram.notify_trade_execution.call_args.kwargs
|
|
assert call_kwargs["stock_code"] == "005930"
|
|
assert call_kwargs["market"] == "Korea"
|
|
assert call_kwargs["action"] == "BUY"
|
|
assert call_kwargs["confidence"] == 85
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trade_execution_notification_failure_doesnt_crash(
|
|
self,
|
|
mock_broker: MagicMock,
|
|
mock_overseas_broker: MagicMock,
|
|
mock_scenario_engine: MagicMock,
|
|
mock_playbook: DayPlaybook,
|
|
mock_risk: MagicMock,
|
|
mock_db: MagicMock,
|
|
mock_decision_logger: MagicMock,
|
|
mock_context_store: MagicMock,
|
|
mock_criticality_assessor: MagicMock,
|
|
mock_telegram: MagicMock,
|
|
mock_market: MagicMock,
|
|
) -> None:
|
|
"""Test trading continues even if notification fails."""
|
|
# Make notification fail
|
|
mock_telegram.notify_trade_execution.side_effect = Exception("API error")
|
|
|
|
with patch("src.main.log_trade"):
|
|
# Should not raise exception
|
|
await trading_cycle(
|
|
broker=mock_broker,
|
|
overseas_broker=mock_overseas_broker,
|
|
scenario_engine=mock_scenario_engine,
|
|
playbook=mock_playbook,
|
|
risk=mock_risk,
|
|
db_conn=mock_db,
|
|
decision_logger=mock_decision_logger,
|
|
context_store=mock_context_store,
|
|
criticality_assessor=mock_criticality_assessor,
|
|
telegram=mock_telegram,
|
|
market=mock_market,
|
|
stock_code="005930",
|
|
scan_candidates={},
|
|
)
|
|
|
|
# Verify notification was attempted
|
|
mock_telegram.notify_trade_execution.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fat_finger_notification_sent(
|
|
self,
|
|
mock_broker: MagicMock,
|
|
mock_overseas_broker: MagicMock,
|
|
mock_scenario_engine: MagicMock,
|
|
mock_playbook: DayPlaybook,
|
|
mock_risk: MagicMock,
|
|
mock_db: MagicMock,
|
|
mock_decision_logger: MagicMock,
|
|
mock_context_store: MagicMock,
|
|
mock_criticality_assessor: MagicMock,
|
|
mock_telegram: MagicMock,
|
|
mock_market: MagicMock,
|
|
) -> None:
|
|
"""Test telegram notification sent on fat finger rejection."""
|
|
# Make risk manager reject the order
|
|
mock_risk.validate_order.side_effect = FatFingerRejected(
|
|
order_amount=2000000,
|
|
total_cash=5000000,
|
|
max_pct=30.0,
|
|
)
|
|
|
|
with patch("src.main.log_trade"):
|
|
with pytest.raises(FatFingerRejected):
|
|
await trading_cycle(
|
|
broker=mock_broker,
|
|
overseas_broker=mock_overseas_broker,
|
|
scenario_engine=mock_scenario_engine,
|
|
playbook=mock_playbook,
|
|
risk=mock_risk,
|
|
db_conn=mock_db,
|
|
decision_logger=mock_decision_logger,
|
|
context_store=mock_context_store,
|
|
criticality_assessor=mock_criticality_assessor,
|
|
telegram=mock_telegram,
|
|
market=mock_market,
|
|
stock_code="005930",
|
|
scan_candidates={},
|
|
)
|
|
|
|
# Verify notification was sent
|
|
mock_telegram.notify_fat_finger.assert_called_once()
|
|
call_kwargs = mock_telegram.notify_fat_finger.call_args.kwargs
|
|
assert call_kwargs["stock_code"] == "005930"
|
|
assert call_kwargs["order_amount"] == 2000000
|
|
assert call_kwargs["total_cash"] == 5000000
|
|
assert call_kwargs["max_pct"] == 30.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fat_finger_notification_failure_still_raises(
|
|
self,
|
|
mock_broker: MagicMock,
|
|
mock_overseas_broker: MagicMock,
|
|
mock_scenario_engine: MagicMock,
|
|
mock_playbook: DayPlaybook,
|
|
mock_risk: MagicMock,
|
|
mock_db: MagicMock,
|
|
mock_decision_logger: MagicMock,
|
|
mock_context_store: MagicMock,
|
|
mock_criticality_assessor: MagicMock,
|
|
mock_telegram: MagicMock,
|
|
mock_market: MagicMock,
|
|
) -> None:
|
|
"""Test fat finger exception still raised even if notification fails."""
|
|
# Make risk manager reject the order
|
|
mock_risk.validate_order.side_effect = FatFingerRejected(
|
|
order_amount=2000000,
|
|
total_cash=5000000,
|
|
max_pct=30.0,
|
|
)
|
|
# Make notification fail
|
|
mock_telegram.notify_fat_finger.side_effect = Exception("API error")
|
|
|
|
with patch("src.main.log_trade"):
|
|
with pytest.raises(FatFingerRejected):
|
|
await trading_cycle(
|
|
broker=mock_broker,
|
|
overseas_broker=mock_overseas_broker,
|
|
scenario_engine=mock_scenario_engine,
|
|
playbook=mock_playbook,
|
|
risk=mock_risk,
|
|
db_conn=mock_db,
|
|
decision_logger=mock_decision_logger,
|
|
context_store=mock_context_store,
|
|
criticality_assessor=mock_criticality_assessor,
|
|
telegram=mock_telegram,
|
|
market=mock_market,
|
|
stock_code="005930",
|
|
scan_candidates={},
|
|
)
|
|
|
|
# Verify notification was attempted
|
|
mock_telegram.notify_fat_finger.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_notification_on_hold_decision(
|
|
self,
|
|
mock_broker: MagicMock,
|
|
mock_overseas_broker: MagicMock,
|
|
mock_scenario_engine: MagicMock,
|
|
mock_playbook: DayPlaybook,
|
|
mock_risk: MagicMock,
|
|
mock_db: MagicMock,
|
|
mock_decision_logger: MagicMock,
|
|
mock_context_store: MagicMock,
|
|
mock_criticality_assessor: MagicMock,
|
|
mock_telegram: MagicMock,
|
|
mock_market: MagicMock,
|
|
) -> None:
|
|
"""Test no trade notification sent when decision is HOLD."""
|
|
# Scenario engine returns HOLD
|
|
mock_scenario_engine.evaluate = MagicMock(return_value=_make_hold_match())
|
|
|
|
with patch("src.main.log_trade"):
|
|
await trading_cycle(
|
|
broker=mock_broker,
|
|
overseas_broker=mock_overseas_broker,
|
|
scenario_engine=mock_scenario_engine,
|
|
playbook=mock_playbook,
|
|
risk=mock_risk,
|
|
db_conn=mock_db,
|
|
decision_logger=mock_decision_logger,
|
|
context_store=mock_context_store,
|
|
criticality_assessor=mock_criticality_assessor,
|
|
telegram=mock_telegram,
|
|
market=mock_market,
|
|
stock_code="005930",
|
|
scan_candidates={},
|
|
)
|
|
|
|
# Verify no trade notification sent
|
|
mock_telegram.notify_trade_execution.assert_not_called()
|
|
|
|
|
|
class TestRunFunctionTelegramIntegration:
|
|
"""Test telegram notifications in run function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_circuit_breaker_notification_sent(self) -> None:
|
|
"""Test telegram notification sent when circuit breaker trips."""
|
|
mock_telegram = MagicMock()
|
|
mock_telegram.notify_circuit_breaker = AsyncMock()
|
|
|
|
# Simulate circuit breaker exception
|
|
exc = CircuitBreakerTripped(pnl_pct=-3.5, threshold=-3.0)
|
|
|
|
# Test the notification logic
|
|
try:
|
|
await mock_telegram.notify_circuit_breaker(
|
|
pnl_pct=exc.pnl_pct,
|
|
threshold=exc.threshold,
|
|
)
|
|
except Exception:
|
|
pass # Ignore errors in notification
|
|
|
|
# Verify notification was called
|
|
mock_telegram.notify_circuit_breaker.assert_called_once_with(
|
|
pnl_pct=-3.5,
|
|
threshold=-3.0,
|
|
)
|
|
|
|
|
|
class TestOverseasBalanceParsing:
|
|
"""Test overseas balance output2 parsing handles different formats."""
|
|
|
|
@pytest.fixture
|
|
def mock_overseas_broker_with_list(self) -> MagicMock:
|
|
"""Create mock overseas broker returning list format."""
|
|
broker = MagicMock()
|
|
broker.get_overseas_price = AsyncMock(
|
|
return_value={"output": {"last": "150.50"}}
|
|
)
|
|
broker.get_overseas_balance = AsyncMock(
|
|
return_value={
|
|
"output2": [
|
|
{
|
|
"frcr_evlu_tota": "10000.00",
|
|
"frcr_dncl_amt_2": "5000.00",
|
|
"frcr_buy_amt_smtl": "4500.00",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
return broker
|
|
|
|
@pytest.fixture
|
|
def mock_overseas_broker_with_dict(self) -> MagicMock:
|
|
"""Create mock overseas broker returning dict format."""
|
|
broker = MagicMock()
|
|
broker.get_overseas_price = AsyncMock(
|
|
return_value={"output": {"last": "150.50"}}
|
|
)
|
|
broker.get_overseas_balance = AsyncMock(
|
|
return_value={
|
|
"output2": {
|
|
"frcr_evlu_tota": "10000.00",
|
|
"frcr_dncl_amt_2": "5000.00",
|
|
"frcr_buy_amt_smtl": "4500.00",
|
|
}
|
|
}
|
|
)
|
|
return broker
|
|
|
|
@pytest.fixture
|
|
def mock_overseas_broker_with_empty(self) -> MagicMock:
|
|
"""Create mock overseas broker returning empty output2."""
|
|
broker = MagicMock()
|
|
broker.get_overseas_price = AsyncMock(
|
|
return_value={"output": {"last": "150.50"}}
|
|
)
|
|
broker.get_overseas_balance = AsyncMock(return_value={"output2": []})
|
|
return broker
|
|
|
|
@pytest.fixture
|
|
def mock_overseas_broker_with_empty_price(self) -> MagicMock:
|
|
"""Create mock overseas broker returning empty string for price."""
|
|
broker = MagicMock()
|
|
broker.get_overseas_price = AsyncMock(
|
|
return_value={"output": {"last": ""}} # Empty string
|
|
)
|
|
broker.get_overseas_balance = AsyncMock(
|
|
return_value={
|
|
"output2": [
|
|
{
|
|
"frcr_evlu_tota": "10000.00",
|
|
"frcr_dncl_amt_2": "5000.00",
|
|
"frcr_buy_amt_smtl": "4500.00",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
return broker
|
|
|
|
@pytest.fixture
|
|
def mock_domestic_broker(self) -> MagicMock:
|
|
"""Create minimal mock domestic broker."""
|
|
broker = MagicMock()
|
|
return broker
|
|
|
|
@pytest.fixture
|
|
def mock_overseas_market(self) -> MagicMock:
|
|
"""Create mock overseas market info."""
|
|
market = MagicMock()
|
|
market.name = "NASDAQ"
|
|
market.code = "US_NASDAQ"
|
|
market.exchange_code = "NASD"
|
|
market.is_domestic = False
|
|
return market
|
|
|
|
@pytest.fixture
|
|
def mock_scenario_engine_hold(self) -> MagicMock:
|
|
"""Create mock scenario engine that always returns HOLD."""
|
|
engine = MagicMock(spec=ScenarioEngine)
|
|
engine.evaluate = MagicMock(return_value=_make_hold_match("AAPL"))
|
|
return engine
|
|
|
|
@pytest.fixture
|
|
def mock_playbook(self) -> DayPlaybook:
|
|
"""Create a minimal playbook."""
|
|
return _make_playbook("US")
|
|
|
|
@pytest.fixture
|
|
def mock_risk(self) -> MagicMock:
|
|
"""Create mock risk manager."""
|
|
return MagicMock()
|
|
|
|
@pytest.fixture
|
|
def mock_db(self) -> MagicMock:
|
|
"""Create mock database."""
|
|
return MagicMock()
|
|
|
|
@pytest.fixture
|
|
def mock_decision_logger(self) -> MagicMock:
|
|
"""Create mock decision logger."""
|
|
return MagicMock()
|
|
|
|
@pytest.fixture
|
|
def mock_context_store(self) -> MagicMock:
|
|
"""Create mock context store."""
|
|
store = MagicMock()
|
|
store.get_latest_timeframe = MagicMock(return_value=None)
|
|
return store
|
|
|
|
@pytest.fixture
|
|
def mock_criticality_assessor(self) -> MagicMock:
|
|
"""Create mock criticality assessor."""
|
|
assessor = MagicMock()
|
|
assessor.assess_market_conditions = MagicMock(
|
|
return_value=MagicMock(value="NORMAL")
|
|
)
|
|
assessor.get_timeout = MagicMock(return_value=5.0)
|
|
return assessor
|
|
|
|
@pytest.fixture
|
|
def mock_telegram(self) -> MagicMock:
|
|
"""Create mock telegram client."""
|
|
telegram = MagicMock()
|
|
telegram.notify_scenario_matched = AsyncMock()
|
|
return telegram
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_overseas_balance_list_format(
|
|
self,
|
|
mock_domestic_broker: MagicMock,
|
|
mock_overseas_broker_with_list: MagicMock,
|
|
mock_scenario_engine_hold: MagicMock,
|
|
mock_playbook: DayPlaybook,
|
|
mock_risk: MagicMock,
|
|
mock_db: MagicMock,
|
|
mock_decision_logger: MagicMock,
|
|
mock_context_store: MagicMock,
|
|
mock_criticality_assessor: MagicMock,
|
|
mock_telegram: MagicMock,
|
|
mock_overseas_market: MagicMock,
|
|
) -> None:
|
|
"""Test overseas balance parsing with list format (output2=[{...}])."""
|
|
with patch("src.main.log_trade"):
|
|
# Should not raise KeyError
|
|
await trading_cycle(
|
|
broker=mock_domestic_broker,
|
|
overseas_broker=mock_overseas_broker_with_list,
|
|
scenario_engine=mock_scenario_engine_hold,
|
|
playbook=mock_playbook,
|
|
risk=mock_risk,
|
|
db_conn=mock_db,
|
|
decision_logger=mock_decision_logger,
|
|
context_store=mock_context_store,
|
|
criticality_assessor=mock_criticality_assessor,
|
|
telegram=mock_telegram,
|
|
market=mock_overseas_market,
|
|
stock_code="AAPL",
|
|
scan_candidates={},
|
|
)
|
|
|
|
# Verify balance API was called
|
|
mock_overseas_broker_with_list.get_overseas_balance.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_overseas_balance_dict_format(
|
|
self,
|
|
mock_domestic_broker: MagicMock,
|
|
mock_overseas_broker_with_dict: MagicMock,
|
|
mock_scenario_engine_hold: MagicMock,
|
|
mock_playbook: DayPlaybook,
|
|
mock_risk: MagicMock,
|
|
mock_db: MagicMock,
|
|
mock_decision_logger: MagicMock,
|
|
mock_context_store: MagicMock,
|
|
mock_criticality_assessor: MagicMock,
|
|
mock_telegram: MagicMock,
|
|
mock_overseas_market: MagicMock,
|
|
) -> None:
|
|
"""Test overseas balance parsing with dict format (output2={...})."""
|
|
with patch("src.main.log_trade"):
|
|
# Should not raise KeyError
|
|
await trading_cycle(
|
|
broker=mock_domestic_broker,
|
|
overseas_broker=mock_overseas_broker_with_dict,
|
|
scenario_engine=mock_scenario_engine_hold,
|
|
playbook=mock_playbook,
|
|
risk=mock_risk,
|
|
db_conn=mock_db,
|
|
decision_logger=mock_decision_logger,
|
|
context_store=mock_context_store,
|
|
criticality_assessor=mock_criticality_assessor,
|
|
telegram=mock_telegram,
|
|
market=mock_overseas_market,
|
|
stock_code="AAPL",
|
|
scan_candidates={},
|
|
)
|
|
|
|
# Verify balance API was called
|
|
mock_overseas_broker_with_dict.get_overseas_balance.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_overseas_balance_empty_format(
|
|
self,
|
|
mock_domestic_broker: MagicMock,
|
|
mock_overseas_broker_with_empty: MagicMock,
|
|
mock_scenario_engine_hold: MagicMock,
|
|
mock_playbook: DayPlaybook,
|
|
mock_risk: MagicMock,
|
|
mock_db: MagicMock,
|
|
mock_decision_logger: MagicMock,
|
|
mock_context_store: MagicMock,
|
|
mock_criticality_assessor: MagicMock,
|
|
mock_telegram: MagicMock,
|
|
mock_overseas_market: MagicMock,
|
|
) -> None:
|
|
"""Test overseas balance parsing with empty output2."""
|
|
with patch("src.main.log_trade"):
|
|
# Should not raise KeyError, should default to 0
|
|
await trading_cycle(
|
|
broker=mock_domestic_broker,
|
|
overseas_broker=mock_overseas_broker_with_empty,
|
|
scenario_engine=mock_scenario_engine_hold,
|
|
playbook=mock_playbook,
|
|
risk=mock_risk,
|
|
db_conn=mock_db,
|
|
decision_logger=mock_decision_logger,
|
|
context_store=mock_context_store,
|
|
criticality_assessor=mock_criticality_assessor,
|
|
telegram=mock_telegram,
|
|
market=mock_overseas_market,
|
|
stock_code="AAPL",
|
|
scan_candidates={},
|
|
)
|
|
|
|
# Verify balance API was called
|
|
mock_overseas_broker_with_empty.get_overseas_balance.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_overseas_price_empty_string(
|
|
self,
|
|
mock_domestic_broker: MagicMock,
|
|
mock_overseas_broker_with_empty_price: MagicMock,
|
|
mock_scenario_engine_hold: MagicMock,
|
|
mock_playbook: DayPlaybook,
|
|
mock_risk: MagicMock,
|
|
mock_db: MagicMock,
|
|
mock_decision_logger: MagicMock,
|
|
mock_context_store: MagicMock,
|
|
mock_criticality_assessor: MagicMock,
|
|
mock_telegram: MagicMock,
|
|
mock_overseas_market: MagicMock,
|
|
) -> None:
|
|
"""Test overseas price parsing with empty string (issue #49)."""
|
|
with patch("src.main.log_trade"):
|
|
# Should not raise ValueError, should default to 0.0
|
|
await trading_cycle(
|
|
broker=mock_domestic_broker,
|
|
overseas_broker=mock_overseas_broker_with_empty_price,
|
|
scenario_engine=mock_scenario_engine_hold,
|
|
playbook=mock_playbook,
|
|
risk=mock_risk,
|
|
db_conn=mock_db,
|
|
decision_logger=mock_decision_logger,
|
|
context_store=mock_context_store,
|
|
criticality_assessor=mock_criticality_assessor,
|
|
telegram=mock_telegram,
|
|
market=mock_overseas_market,
|
|
stock_code="AAPL",
|
|
scan_candidates={},
|
|
)
|
|
|
|
# Verify price API was called
|
|
mock_overseas_broker_with_empty_price.get_overseas_price.assert_called_once()
|
|
|
|
|
|
class TestScenarioEngineIntegration:
|
|
"""Test scenario engine integration in trading_cycle."""
|
|
|
|
@pytest.fixture
|
|
def mock_broker(self) -> MagicMock:
|
|
"""Create mock broker with standard domestic data."""
|
|
broker = MagicMock()
|
|
broker.get_orderbook = AsyncMock(
|
|
return_value={
|
|
"output1": {"stck_prpr": "50000", "frgn_ntby_qty": "100"}
|
|
}
|
|
)
|
|
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:
|
|
"""Create mock KR market."""
|
|
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:
|
|
"""Create mock telegram with all notification methods."""
|
|
telegram = MagicMock()
|
|
telegram.notify_trade_execution = AsyncMock()
|
|
telegram.notify_scenario_matched = AsyncMock()
|
|
telegram.notify_fat_finger = AsyncMock()
|
|
return telegram
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scenario_engine_called_with_enriched_market_data(
|
|
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
|
|
) -> None:
|
|
"""Test scenario engine receives market_data enriched with scanner metrics."""
|
|
from src.analysis.smart_scanner import ScanCandidate
|
|
|
|
engine = MagicMock(spec=ScenarioEngine)
|
|
engine.evaluate = MagicMock(return_value=_make_hold_match())
|
|
playbook = _make_playbook()
|
|
|
|
candidate = ScanCandidate(
|
|
stock_code="005930", name="Samsung", price=50000,
|
|
volume=1000000, volume_ratio=3.5, rsi=25.0,
|
|
signal="oversold", score=85.0,
|
|
)
|
|
|
|
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=MagicMock(),
|
|
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={"KR": {"005930": candidate}},
|
|
)
|
|
|
|
# Verify evaluate was called
|
|
engine.evaluate.assert_called_once()
|
|
call_args = engine.evaluate.call_args
|
|
market_data = call_args[0][2] # 3rd positional arg
|
|
portfolio_data = call_args[0][3] # 4th positional arg
|
|
|
|
# Scanner data should be enriched into market_data
|
|
assert market_data["rsi"] == 25.0
|
|
assert market_data["volume_ratio"] == 3.5
|
|
assert market_data["current_price"] == 50000.0
|
|
|
|
# Portfolio data should include pnl
|
|
assert "portfolio_pnl_pct" in portfolio_data
|
|
assert "total_cash" in portfolio_data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_candidates_market_scoped(
|
|
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
|
|
) -> None:
|
|
"""Test scan_candidates uses market-scoped lookup, ignoring other markets."""
|
|
from src.analysis.smart_scanner import ScanCandidate
|
|
|
|
engine = MagicMock(spec=ScenarioEngine)
|
|
engine.evaluate = MagicMock(return_value=_make_hold_match())
|
|
|
|
# Candidate stored under US market — should NOT be found for KR market
|
|
us_candidate = ScanCandidate(
|
|
stock_code="005930", name="Overlap", price=100,
|
|
volume=500000, volume_ratio=5.0, rsi=15.0,
|
|
signal="oversold", score=90.0,
|
|
)
|
|
|
|
with patch("src.main.log_trade"):
|
|
await trading_cycle(
|
|
broker=mock_broker,
|
|
overseas_broker=MagicMock(),
|
|
scenario_engine=engine,
|
|
playbook=_make_playbook(),
|
|
risk=MagicMock(),
|
|
db_conn=MagicMock(),
|
|
decision_logger=MagicMock(),
|
|
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, # KR market
|
|
stock_code="005930",
|
|
scan_candidates={"US": {"005930": us_candidate}}, # Wrong market
|
|
)
|
|
|
|
# Should NOT have rsi/volume_ratio because candidate is under US, not KR
|
|
market_data = engine.evaluate.call_args[0][2]
|
|
assert "rsi" not in market_data
|
|
assert "volume_ratio" not in market_data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scenario_engine_called_without_scanner_data(
|
|
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
|
|
) -> None:
|
|
"""Test scenario engine works when stock has no scan candidate."""
|
|
engine = MagicMock(spec=ScenarioEngine)
|
|
engine.evaluate = MagicMock(return_value=_make_hold_match())
|
|
playbook = _make_playbook()
|
|
|
|
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=MagicMock(),
|
|
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={}, # No scanner data
|
|
)
|
|
|
|
# Should still work, just without rsi/volume_ratio
|
|
engine.evaluate.assert_called_once()
|
|
market_data = engine.evaluate.call_args[0][2]
|
|
assert "rsi" not in market_data
|
|
assert "volume_ratio" not in market_data
|
|
assert market_data["current_price"] == 50000.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scenario_matched_notification_sent(
|
|
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
|
|
) -> None:
|
|
"""Test telegram notification sent when a scenario matches."""
|
|
# Create a match with matched_scenario (not None)
|
|
scenario = StockScenario(
|
|
condition=StockCondition(rsi_below=30),
|
|
action=ScenarioAction.BUY,
|
|
confidence=88,
|
|
rationale="RSI oversold bounce",
|
|
)
|
|
match = ScenarioMatch(
|
|
stock_code="005930",
|
|
matched_scenario=scenario,
|
|
action=ScenarioAction.BUY,
|
|
confidence=88,
|
|
rationale="RSI oversold bounce",
|
|
match_details={"rsi": 25.0},
|
|
)
|
|
engine = MagicMock(spec=ScenarioEngine)
|
|
engine.evaluate = MagicMock(return_value=match)
|
|
|
|
with patch("src.main.log_trade"):
|
|
await trading_cycle(
|
|
broker=mock_broker,
|
|
overseas_broker=MagicMock(),
|
|
scenario_engine=engine,
|
|
playbook=_make_playbook(),
|
|
risk=MagicMock(),
|
|
db_conn=MagicMock(),
|
|
decision_logger=MagicMock(),
|
|
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={},
|
|
)
|
|
|
|
# Scenario matched notification should be sent
|
|
mock_telegram.notify_scenario_matched.assert_called_once()
|
|
call_kwargs = mock_telegram.notify_scenario_matched.call_args.kwargs
|
|
assert call_kwargs["stock_code"] == "005930"
|
|
assert call_kwargs["action"] == "BUY"
|
|
assert "rsi=25.0" in call_kwargs["condition_summary"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_scenario_matched_notification_on_default_hold(
|
|
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
|
|
) -> None:
|
|
"""Test no scenario notification when default HOLD is returned."""
|
|
engine = MagicMock(spec=ScenarioEngine)
|
|
engine.evaluate = MagicMock(return_value=_make_hold_match())
|
|
|
|
with patch("src.main.log_trade"):
|
|
await trading_cycle(
|
|
broker=mock_broker,
|
|
overseas_broker=MagicMock(),
|
|
scenario_engine=engine,
|
|
playbook=_make_playbook(),
|
|
risk=MagicMock(),
|
|
db_conn=MagicMock(),
|
|
decision_logger=MagicMock(),
|
|
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={},
|
|
)
|
|
|
|
# No scenario matched notification for default HOLD
|
|
mock_telegram.notify_scenario_matched.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decision_logger_receives_scenario_match_details(
|
|
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
|
|
) -> None:
|
|
"""Test decision logger context includes scenario match details."""
|
|
match = ScenarioMatch(
|
|
stock_code="005930",
|
|
matched_scenario=None,
|
|
action=ScenarioAction.HOLD,
|
|
confidence=0,
|
|
rationale="No match",
|
|
match_details={"rsi": 45.0, "volume_ratio": 1.2},
|
|
)
|
|
engine = MagicMock(spec=ScenarioEngine)
|
|
engine.evaluate = MagicMock(return_value=match)
|
|
decision_logger = MagicMock()
|
|
|
|
with patch("src.main.log_trade"):
|
|
await trading_cycle(
|
|
broker=mock_broker,
|
|
overseas_broker=MagicMock(),
|
|
scenario_engine=engine,
|
|
playbook=_make_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={},
|
|
)
|
|
|
|
decision_logger.log_decision.assert_called_once()
|
|
call_kwargs = decision_logger.log_decision.call_args.kwargs
|
|
assert "scenario_match" in call_kwargs["context_snapshot"]
|
|
assert call_kwargs["context_snapshot"]["scenario_match"]["rsi"] == 45.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reduce_all_does_not_execute_order(
|
|
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
|
|
) -> None:
|
|
"""Test REDUCE_ALL action does not trigger order execution."""
|
|
match = ScenarioMatch(
|
|
stock_code="005930",
|
|
matched_scenario=None,
|
|
action=ScenarioAction.REDUCE_ALL,
|
|
confidence=100,
|
|
rationale="Global rule: portfolio loss > 2%",
|
|
)
|
|
engine = MagicMock(spec=ScenarioEngine)
|
|
engine.evaluate = MagicMock(return_value=match)
|
|
|
|
with patch("src.main.log_trade"):
|
|
await trading_cycle(
|
|
broker=mock_broker,
|
|
overseas_broker=MagicMock(),
|
|
scenario_engine=engine,
|
|
playbook=_make_playbook(),
|
|
risk=MagicMock(),
|
|
db_conn=MagicMock(),
|
|
decision_logger=MagicMock(),
|
|
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={},
|
|
)
|
|
|
|
# REDUCE_ALL is not BUY or SELL — no order sent
|
|
mock_broker.send_order.assert_not_called()
|
|
mock_telegram.notify_trade_execution.assert_not_called()
|