- executor: EXIT/SELL signals close the FULL held broker position (not a target_dollars-sized fresh order) and skip when flat - executor: book realized P&L on the closing trade ((fill - avg_entry)*qty) so the dashboard P&L + win-rate populate; entries leave pnl=None - exit scanner: wired into the bridge run loop on kevin_bridge_exit_scan_cron (daily ET gate; croniter intentionally not a dependency) plus an offsetting-SELL guard so it only emits exits for currently-held tickers [ci skip] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
279 lines
9.2 KiB
Python
279 lines
9.2 KiB
Python
"""Smoke-level orchestrator tests for the Kevin signal bridge."""
|
|
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from services.kevin_signal_bridge.main import KevinBridge, should_run_exit_scan
|
|
|
|
_ET = ZoneInfo("America/New_York")
|
|
|
|
|
|
async def test_bridge_dry_run_writes_audit_does_not_publish():
|
|
"""When TRADING_KEVIN_ENABLE_TRADING=false the bridge writes an audit row
|
|
and never calls publisher.publish."""
|
|
config = MagicMock(kevin_enable_trading=False, kevin_stop_loss_pct=0.08, kevin_take_profit_pct=0.20, kevin_avoid_blocks_days=7)
|
|
cursor = AsyncMock()
|
|
cursor.last_seen_id.return_value = 0
|
|
publisher = AsyncMock()
|
|
aggregator = AsyncMock()
|
|
aggregator.fetch_pending.return_value = [
|
|
MagicMock(
|
|
id=1,
|
|
symbol="NVDA",
|
|
action=MagicMock(value="buy"),
|
|
conviction=Decimal("0.8"),
|
|
time_horizon=MagicMock(value="weeks"),
|
|
),
|
|
]
|
|
strategy = AsyncMock()
|
|
strategy.evaluate_mention.return_value = MagicMock(
|
|
decision=MagicMock(value="open_long", name="OPEN_LONG"),
|
|
symbol="NVDA",
|
|
target_dollars=Decimal("3000"),
|
|
holding_days=10,
|
|
effective_conviction=Decimal("0.8"),
|
|
rationale="ok",
|
|
)
|
|
# Make the .decision attribute compare against KevinDecisionType.OPEN_LONG
|
|
from shared.schemas.kevin import KevinDecisionType
|
|
strategy.evaluate_mention.return_value.decision = KevinDecisionType.OPEN_LONG
|
|
|
|
audit_writer = AsyncMock()
|
|
broker = AsyncMock()
|
|
broker.is_asset_tradable.return_value = True
|
|
broker.get_latest_price.return_value = Decimal("100")
|
|
broker.get_account.return_value = MagicMock(
|
|
equity=Decimal("100000"), cash=Decimal("100000")
|
|
)
|
|
broker.get_positions.return_value = []
|
|
|
|
bridge = KevinBridge(
|
|
config=config,
|
|
cursor=cursor,
|
|
publisher=publisher,
|
|
aggregator=aggregator,
|
|
strategy=strategy,
|
|
audit_writer=audit_writer,
|
|
broker=broker,
|
|
)
|
|
await bridge.process_one_pass()
|
|
|
|
publisher.publish.assert_not_called()
|
|
audit_writer.write.assert_awaited()
|
|
args = audit_writer.write.call_args
|
|
assert "dry_run" in str(args).lower()
|
|
|
|
|
|
async def test_bridge_kill_switch_on_publishes_to_stream():
|
|
config = MagicMock(
|
|
kevin_enable_trading=True,
|
|
kevin_stop_loss_pct=0.08,
|
|
kevin_take_profit_pct=0.20,
|
|
kevin_avoid_blocks_days=7,
|
|
)
|
|
cursor = AsyncMock()
|
|
cursor.last_seen_id.return_value = 0
|
|
publisher = AsyncMock()
|
|
publisher.publish.return_value = "1234-0"
|
|
aggregator = AsyncMock()
|
|
aggregator.fetch_pending.return_value = [
|
|
MagicMock(
|
|
id=1,
|
|
symbol="NVDA",
|
|
action=MagicMock(value="buy"),
|
|
conviction=Decimal("0.8"),
|
|
effective_conviction=Decimal("0.8"),
|
|
time_horizon=MagicMock(value="weeks"),
|
|
),
|
|
]
|
|
strategy = AsyncMock()
|
|
from shared.schemas.kevin import KevinDecisionType
|
|
strategy.evaluate_mention.return_value = MagicMock(
|
|
decision=KevinDecisionType.OPEN_LONG,
|
|
symbol="NVDA",
|
|
target_dollars=Decimal("3000"),
|
|
holding_days=10,
|
|
effective_conviction=Decimal("0.8"),
|
|
rationale="ok",
|
|
)
|
|
audit_writer = AsyncMock()
|
|
broker = AsyncMock()
|
|
broker.is_asset_tradable.return_value = True
|
|
broker.get_latest_price.return_value = Decimal("100")
|
|
broker.get_account.return_value = MagicMock(
|
|
equity=Decimal("100000"), cash=Decimal("100000")
|
|
)
|
|
broker.get_positions.return_value = []
|
|
|
|
bridge = KevinBridge(
|
|
config=config,
|
|
cursor=cursor,
|
|
publisher=publisher,
|
|
aggregator=aggregator,
|
|
strategy=strategy,
|
|
audit_writer=audit_writer,
|
|
broker=broker,
|
|
)
|
|
await bridge.process_one_pass()
|
|
|
|
publisher.publish.assert_awaited_once()
|
|
cursor.advance.assert_awaited_with(1)
|
|
|
|
|
|
async def test_bridge_attaches_current_price_to_signal():
|
|
"""The bridge must propagate broker.get_latest_price onto the published
|
|
TradeSignal so the executor can size the position."""
|
|
config = MagicMock(
|
|
kevin_enable_trading=True,
|
|
kevin_stop_loss_pct=0.08,
|
|
kevin_take_profit_pct=0.20,
|
|
kevin_avoid_blocks_days=7,
|
|
)
|
|
cursor = AsyncMock()
|
|
cursor.last_seen_id.return_value = 0
|
|
publisher = AsyncMock()
|
|
publisher.publish.return_value = "1234-0"
|
|
aggregator = AsyncMock()
|
|
aggregator.fetch_pending.return_value = [
|
|
MagicMock(
|
|
id=1,
|
|
symbol="NVDA",
|
|
action=MagicMock(value="buy"),
|
|
conviction=Decimal("0.8"),
|
|
effective_conviction=Decimal("0.8"),
|
|
time_horizon=MagicMock(value="weeks"),
|
|
),
|
|
]
|
|
strategy = AsyncMock()
|
|
from shared.schemas.kevin import KevinDecisionType
|
|
strategy.evaluate_mention.return_value = MagicMock(
|
|
decision=KevinDecisionType.OPEN_LONG,
|
|
symbol="NVDA",
|
|
target_dollars=Decimal("3000"),
|
|
holding_days=10,
|
|
effective_conviction=Decimal("0.8"),
|
|
rationale="ok",
|
|
)
|
|
audit_writer = AsyncMock()
|
|
broker = AsyncMock()
|
|
broker.is_asset_tradable.return_value = True
|
|
broker.get_latest_price.return_value = Decimal("123.45")
|
|
broker.get_account.return_value = MagicMock(
|
|
equity=Decimal("100000"), cash=Decimal("100000")
|
|
)
|
|
broker.get_positions.return_value = []
|
|
|
|
bridge = KevinBridge(
|
|
config=config,
|
|
cursor=cursor,
|
|
publisher=publisher,
|
|
aggregator=aggregator,
|
|
strategy=strategy,
|
|
audit_writer=audit_writer,
|
|
broker=broker,
|
|
)
|
|
await bridge.process_one_pass()
|
|
|
|
publisher.publish.assert_awaited_once()
|
|
published_signal = publisher.publish.call_args[0][0]
|
|
assert published_signal.current_price == Decimal("123.45")
|
|
|
|
|
|
async def test_bridge_advances_cursor_only_after_publish():
|
|
"""Race-condition guard: cursor must NOT advance if publish raises."""
|
|
config = MagicMock(
|
|
kevin_enable_trading=True,
|
|
kevin_stop_loss_pct=0.08,
|
|
kevin_take_profit_pct=0.20,
|
|
kevin_avoid_blocks_days=7,
|
|
)
|
|
cursor = AsyncMock()
|
|
cursor.last_seen_id.return_value = 0
|
|
publisher = AsyncMock()
|
|
publisher.publish.side_effect = RuntimeError("XADD failed")
|
|
aggregator = AsyncMock()
|
|
aggregator.fetch_pending.return_value = [
|
|
MagicMock(
|
|
id=1,
|
|
symbol="NVDA",
|
|
action=MagicMock(value="buy"),
|
|
conviction=Decimal("0.8"),
|
|
effective_conviction=Decimal("0.8"),
|
|
time_horizon=MagicMock(value="weeks"),
|
|
),
|
|
]
|
|
strategy = AsyncMock()
|
|
from shared.schemas.kevin import KevinDecisionType
|
|
strategy.evaluate_mention.return_value = MagicMock(
|
|
decision=KevinDecisionType.OPEN_LONG,
|
|
symbol="NVDA",
|
|
target_dollars=Decimal("3000"),
|
|
holding_days=10,
|
|
effective_conviction=Decimal("0.8"),
|
|
rationale="ok",
|
|
)
|
|
audit_writer = AsyncMock()
|
|
broker = AsyncMock()
|
|
broker.is_asset_tradable.return_value = True
|
|
broker.get_latest_price.return_value = Decimal("100")
|
|
broker.get_account.return_value = MagicMock(
|
|
equity=Decimal("100000"), cash=Decimal("100000")
|
|
)
|
|
broker.get_positions.return_value = []
|
|
|
|
bridge = KevinBridge(
|
|
config=config,
|
|
cursor=cursor,
|
|
publisher=publisher,
|
|
aggregator=aggregator,
|
|
strategy=strategy,
|
|
audit_writer=audit_writer,
|
|
broker=broker,
|
|
)
|
|
await bridge.process_one_pass()
|
|
|
|
# cursor.advance must NOT have been called for mention 1
|
|
cursor.advance.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exit-scan cron gate (croniter unavailable → simple daily ET-weekday gate)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_CRON = "35 9 * * 1-5" # 09:35 ET, Mon-Fri
|
|
|
|
|
|
def test_exit_scan_gate_fires_at_or_after_time_on_weekday():
|
|
"""Wed 09:36 ET, not yet run today → fire."""
|
|
now = datetime(2026, 6, 3, 9, 36, tzinfo=_ET) # Wednesday
|
|
assert should_run_exit_scan(_CRON, now, last_run_date=None) is True
|
|
|
|
|
|
def test_exit_scan_gate_does_not_fire_before_time():
|
|
"""Wed 09:34 ET (one minute early) → do not fire."""
|
|
now = datetime(2026, 6, 3, 9, 34, tzinfo=_ET)
|
|
assert should_run_exit_scan(_CRON, now, last_run_date=None) is False
|
|
|
|
|
|
def test_exit_scan_gate_does_not_fire_on_weekend():
|
|
"""Saturday 10:00 ET → do not fire (cron DOW is 1-5)."""
|
|
now = datetime(2026, 6, 6, 10, 0, tzinfo=_ET) # Saturday
|
|
assert should_run_exit_scan(_CRON, now, last_run_date=None) is False
|
|
|
|
|
|
def test_exit_scan_gate_runs_once_per_day():
|
|
"""Already ran today → do not fire again the same ET day."""
|
|
now = datetime(2026, 6, 3, 11, 0, tzinfo=_ET) # Wednesday, well past 09:35
|
|
assert (
|
|
should_run_exit_scan(_CRON, now, last_run_date=date(2026, 6, 3)) is False
|
|
)
|
|
|
|
|
|
def test_exit_scan_gate_fires_next_day_after_prior_run():
|
|
"""Ran yesterday → fire again today once past the cron time."""
|
|
now = datetime(2026, 6, 4, 9, 40, tzinfo=_ET) # Thursday
|
|
assert (
|
|
should_run_exit_scan(_CRON, now, last_run_date=date(2026, 6, 3)) is True
|
|
)
|