Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
Composable: cursor/aggregator/strategy/publisher/audit_writer/broker all injected. Master kill-switch (kevin_enable_trading=false) routes to audit-only path. Cursor advances ONLY after XADD succeeds (race fix). Concrete collaborators wired in subsequent tasks. Also extends TradeSignal + SignalDirection.EXIT with the optional fields Kevin paths need (strategy_id, target_dollars, stop_loss_pct, take_profit_pct).
175 lines
5.6 KiB
Python
175 lines
5.6 KiB
Python
"""Smoke-level orchestrator tests for the Kevin signal bridge."""
|
|
|
|
from decimal import Decimal
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from services.kevin_signal_bridge.main import KevinBridge
|
|
|
|
|
|
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_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()
|