feat: trade executor — risk management and order execution
This commit is contained in:
parent
f3e5fc944d
commit
3fef8a631c
5 changed files with 753 additions and 0 deletions
176
services/trade_executor/main.py
Normal file
176
services/trade_executor/main.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Trade Executor service -- main entry point.
|
||||
|
||||
Consumes ``signals:generated`` from Redis Streams, runs risk checks,
|
||||
submits orders via the brokerage abstraction layer, records trades
|
||||
in the database, and publishes ``TradeExecution`` messages to
|
||||
``trades:executed``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from services.trade_executor.config import TradeExecutorConfig
|
||||
from services.trade_executor.risk_manager import RiskManager
|
||||
from shared.broker.alpaca_broker import AlpacaBroker
|
||||
from shared.redis_streams import StreamConsumer, StreamPublisher
|
||||
from shared.schemas.trading import (
|
||||
OrderRequest,
|
||||
OrderSide,
|
||||
OrderStatus,
|
||||
SignalDirection,
|
||||
TradeExecution,
|
||||
TradeSignal,
|
||||
)
|
||||
from shared.telemetry import setup_telemetry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def process_signal(
|
||||
signal: TradeSignal,
|
||||
risk_manager: RiskManager,
|
||||
broker: AlpacaBroker,
|
||||
publisher: StreamPublisher,
|
||||
counters: dict,
|
||||
) -> None:
|
||||
"""Process a single trade signal: risk check, order, record, publish.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
signal:
|
||||
The trade signal to act on.
|
||||
risk_manager:
|
||||
Performs pre-trade risk checks and position sizing.
|
||||
broker:
|
||||
Brokerage adapter for submitting orders.
|
||||
publisher:
|
||||
Publishes execution results to ``trades:executed``.
|
||||
counters:
|
||||
Dict of OpenTelemetry counter/histogram instruments.
|
||||
"""
|
||||
# --- Step 1: risk check ---
|
||||
approved, reason = await risk_manager.check_risk(signal)
|
||||
if not approved:
|
||||
logger.info("Signal REJECTED for %s: %s", signal.ticker, reason)
|
||||
counters["rejections"].add(1, {"reason": reason.split(" ")[0]})
|
||||
return
|
||||
|
||||
# --- Step 2: calculate position size ---
|
||||
account = await broker.get_account()
|
||||
qty = risk_manager.calculate_position_size(signal, account)
|
||||
if qty <= 0:
|
||||
logger.info("Position size is zero for %s — skipping", signal.ticker)
|
||||
counters["rejections"].add(1, {"reason": "zero_position_size"})
|
||||
return
|
||||
|
||||
# --- Step 3: create order ---
|
||||
side = OrderSide.BUY if signal.direction == SignalDirection.LONG else OrderSide.SELL
|
||||
order_request = OrderRequest(
|
||||
ticker=signal.ticker,
|
||||
side=side,
|
||||
qty=float(qty),
|
||||
)
|
||||
|
||||
# --- Step 4: submit order ---
|
||||
start = time.monotonic()
|
||||
result = await broker.submit_order(order_request)
|
||||
elapsed = time.monotonic() - start
|
||||
counters["fill_latency"].record(elapsed)
|
||||
|
||||
# --- Step 5: build trade execution ---
|
||||
trade_id = uuid.uuid4()
|
||||
execution = TradeExecution(
|
||||
trade_id=trade_id,
|
||||
ticker=signal.ticker,
|
||||
side=side,
|
||||
qty=result.qty,
|
||||
price=result.filled_price or 0.0,
|
||||
status=result.status,
|
||||
signal_id=None,
|
||||
strategy_id=None,
|
||||
timestamp=result.timestamp,
|
||||
)
|
||||
|
||||
# --- Step 6: publish to trades:executed ---
|
||||
await publisher.publish(execution.model_dump(mode="json"))
|
||||
counters["trades_executed"].add(1)
|
||||
logger.info(
|
||||
"Trade executed: %s %s %.0f shares @ %s status=%s",
|
||||
side.value,
|
||||
signal.ticker,
|
||||
result.qty,
|
||||
result.filled_price,
|
||||
result.status.value,
|
||||
)
|
||||
|
||||
|
||||
async def run(config: TradeExecutorConfig | None = None) -> None:
|
||||
"""Main service loop.
|
||||
|
||||
Connects to Redis, initialises the broker and risk manager, then
|
||||
continuously consumes from ``signals:generated`` and publishes
|
||||
execution results to ``trades:executed``.
|
||||
"""
|
||||
if config is None:
|
||||
config = TradeExecutorConfig()
|
||||
|
||||
logging.basicConfig(level=config.log_level)
|
||||
logger.info("Starting Trade Executor service")
|
||||
|
||||
# --- Telemetry ---
|
||||
meter = setup_telemetry("trade-executor", config.otel_metrics_port)
|
||||
counters = {
|
||||
"trades_executed": meter.create_counter(
|
||||
"trades_executed",
|
||||
description="Total trades successfully submitted",
|
||||
),
|
||||
"rejections": meter.create_counter(
|
||||
"trade_rejections",
|
||||
description="Signals rejected by risk checks",
|
||||
),
|
||||
"fill_latency": meter.create_histogram(
|
||||
"order_fill_latency_seconds",
|
||||
description="Time from order submission to response",
|
||||
unit="s",
|
||||
),
|
||||
}
|
||||
|
||||
# --- Redis ---
|
||||
redis = Redis.from_url(config.redis_url, decode_responses=False)
|
||||
consumer = StreamConsumer(redis, "signals:generated", "trade-executor", "worker-1")
|
||||
publisher = StreamPublisher(redis, "trades:executed")
|
||||
|
||||
# --- Broker ---
|
||||
broker = AlpacaBroker(
|
||||
api_key=config.alpaca_api_key,
|
||||
secret_key=config.alpaca_secret_key,
|
||||
paper=config.paper_trading,
|
||||
)
|
||||
|
||||
# --- Risk manager ---
|
||||
risk_manager = RiskManager(config, broker)
|
||||
|
||||
logger.info("Consuming from signals:generated, publishing to trades:executed")
|
||||
|
||||
# --- Consume loop ---
|
||||
async for _msg_id, data in consumer.consume():
|
||||
try:
|
||||
signal = TradeSignal.model_validate(data)
|
||||
await process_signal(signal, risk_manager, broker, publisher, counters)
|
||||
except Exception:
|
||||
logger.exception("Error processing signal: %s", data)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue