feat(kevin_bridge): main orchestrator with dependency injection
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
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).
This commit is contained in:
parent
cd75c4ab7e
commit
adbd7f3c65
6 changed files with 461 additions and 2 deletions
0
tests/services/kevin_signal_bridge/__init__.py
Normal file
0
tests/services/kevin_signal_bridge/__init__.py
Normal file
175
tests/services/kevin_signal_bridge/test_main.py
Normal file
175
tests/services/kevin_signal_bridge/test_main.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue