trading/tests/services/kevin_signal_bridge/test_main.py
Viktor Barzin 52b3c76482 feat(kevin): correct exits, realized P&L, wire exit scanner
- 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>
2026-06-04 22:13:30 +00:00

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
)