triangular_arbitrage_bot/executor/__main__.py

133 lines
4.3 KiB
Python

"""
Executor process entry point.
Starts the Unix-socket signal server, REST API control interface,
and orchestrates clean shutdown on SIGTERM/SIGINT.
"""
import asyncio
import signal
from pathlib import Path
from typing import Optional
import structlog
import uvicorn
from common.log import configure_logging
from executor.config import Settings
from executor.executor import Executor
from executor.kucoin_api import KuCoinAPI
from executor.rest_api import create_app
from executor.socket_server import SignalSocketServer
from executor.ws_client import KuCoinWSClient
async def main() -> None:
config_path = Path("config.yaml")
settings = await Settings.from_yaml(config_path) if config_path.exists() else Settings()
configure_logging(settings.executor.log_level) # file I/O handled by Executor's _DualLogger
log = structlog.get_logger().bind(component="executor")
log.info("executor_starting", live_mode=settings.live_mode)
# Always initialise KuCoinAPI even in paper mode — symbol metadata is
# needed for size/precision validation regardless of execution mode.
api = KuCoinAPI(
api_key=settings.kucoin_api_key,
api_secret=settings.kucoin_api_secret,
api_passphrase=settings.kucoin_api_passphrase,
)
await api.fetch_symbols()
ws_client: Optional[KuCoinWSClient] = None
if settings.live_mode:
# Live mode requires the private WebSocket client to receive fill events.
ws_client = KuCoinWSClient(
kucoin_api=api,
private_token_url=settings.executor.private_token_url,
)
executor = Executor(
kucoin_api=api,
settings=settings.executor,
ws_client=ws_client,
log_file=settings.executor.log_file,
live_mode=settings.live_mode,
)
await executor.start()
should_exit = asyncio.Event()
def shutdown_callback() -> None:
should_exit.set()
rest_app = create_app(executor, shutdown_callback=shutdown_callback)
rest_config = uvicorn.Config(
rest_app,
host="127.0.0.1",
port=settings.executor.rest_port,
log_level="warning",
)
rest_server = uvicorn.Server(rest_config)
socket_server = SignalSocketServer(
socket_path=settings.executor.socket_path,
on_signal=executor.handle_signal,
)
socket_task: asyncio.Task | None = None
rest_task: asyncio.Task | None = None
exit_task: asyncio.Task | None = None
ws_task: asyncio.Task | None = None
async def shutdown(sig: signal.Signals) -> None:
"""Clean up on shutdown signal: pause executor, cancel tasks, close server."""
log.info("shutdown_signal_received", signal=sig.name)
await executor.pause()
await executor.close()
if socket_task is not None and not socket_task.done():
socket_task.cancel()
if rest_task is not None:
rest_server.should_exit = True
if ws_client is not None:
await ws_client.stop()
should_exit.set()
loop = asyncio.get_running_loop()
# Register signal handlers so shutdown runs in the asyncio event loop
# rather than in a plain threading context.
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(shutdown(s)))
socket_task = asyncio.create_task(socket_server.start())
rest_task = asyncio.create_task(rest_server.serve())
exit_task = asyncio.create_task(should_exit.wait())
if ws_client is not None:
ws_task = asyncio.create_task(ws_client.start())
log.info(
"executor_ready",
rest_endpoint=f"http://127.0.0.1:{settings.executor.rest_port}",
socket_path=str(settings.executor.socket_path),
live_mode=settings.live_mode,
)
tasks = {t for t in (socket_task, rest_task, exit_task, ws_task) if t is not None}
try:
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
except asyncio.CancelledError:
log.info("executor_cancelled")
finally:
rest_server.should_exit = True
for t in tasks:
if not t.done():
await asyncio.wait({t}, timeout=3.0)
if not t.done():
t.cancel()
log.info("executor_shutdown_complete")
if __name__ == "__main__":
asyncio.run(main())