Port the trex-orbit Redis token-cache pattern so the KIS access token
survives daemon restarts and is shared across processes, avoiding KIS
token-issuance rate limits. KISBroker._ensure_token now resolves L1
in-memory -> L2 cache -> issue, storing issued tokens back to the cache.
- adapters/broker/token_cache.py: TokenCache protocol + RedisTokenCache
(key trex:broker:kis:{account_ref}:token, token+expiry+app_key suffix
JSON, ex=ttl, best-effort with graceful fallback) + InMemoryTokenCache.
- kis.py: optional token_cache injection.
- runtime config/daemon: TREX_KIS_REDIS_URL wires a RedisTokenCache.
- pyproject: redis>=5.
Verification: 96 passed, ruff clean — cache hit avoids issuance, cache is
shared across instances (restart/multi-process survival), backend errors
fall back to issuance (no regression), and app_key-suffix mismatch
invalidates a stale cache. Tested deterministically with an in-memory
double (no Redis server required). Processed via the minor-change fast
path: REQ-016 -> Implemented, BAT-005 (batch-lite) -> release-candidate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
102 lines
3.3 KiB
Python
102 lines
3.3 KiB
Python
"""Slot-core daemon entrypoint (REQ-005, REQ-012/013/014).
|
|
|
|
Builds the FastAPI app wired to PostgreSQL adapters + a broker (paper|KIS),
|
|
applies migrations and preallocates slots on startup, runs a background
|
|
execution loop (claim -> submit -> poll -> transition, with restart
|
|
reconciliation), and serves the /v1 API via uvicorn.
|
|
Console script: `trex-core-daemon`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import FastAPI
|
|
|
|
from trex_core.adapters.api.app import create_app
|
|
from trex_core.adapters.broker.paper import PaperBroker
|
|
from trex_core.adapters.persistence.migrate import apply_migrations
|
|
from trex_core.adapters.persistence.postgresql import (
|
|
PostgreSQLIntentStore,
|
|
PostgreSQLSlotRepository,
|
|
)
|
|
from trex_core.application.broker_port import BrokerPort
|
|
from trex_core.application.execution import ExecutionService
|
|
from trex_core.application.intents import IntentService
|
|
from trex_core.domain.slot import SlotService
|
|
from trex_core.runtime.config import DaemonConfig
|
|
|
|
|
|
def _build_broker(config: DaemonConfig) -> BrokerPort:
|
|
if config.broker == "kis":
|
|
from trex_core.adapters.broker.kis import KISBroker
|
|
|
|
k = config.kis
|
|
token_cache = None
|
|
if k.redis_url:
|
|
from trex_core.adapters.broker.token_cache import RedisTokenCache
|
|
|
|
token_cache = RedisTokenCache(k.redis_url)
|
|
return KISBroker(
|
|
k.account_ref, app_key=k.app_key, app_secret=k.app_secret,
|
|
base_url=k.base_url, timeout=k.timeout, allow_live=k.allow_live,
|
|
token_cache=token_cache,
|
|
)
|
|
return PaperBroker()
|
|
|
|
|
|
def build_app(config: DaemonConfig) -> FastAPI:
|
|
slot_repo = PostgreSQLSlotRepository(config.dsn)
|
|
intent_store = PostgreSQLIntentStore(config.dsn)
|
|
slot_service = SlotService(slot_repo)
|
|
intent_service = IntentService(slot_service, slot_repo, intent_store)
|
|
broker = _build_broker(config)
|
|
execution = ExecutionService(slot_service, slot_repo, broker)
|
|
|
|
base = create_app(
|
|
slot_service=slot_service,
|
|
slot_repo=slot_repo,
|
|
intent_service=intent_service,
|
|
default_total_slots=config.total_slots,
|
|
)
|
|
|
|
stop = threading.Event()
|
|
|
|
def _loop() -> None:
|
|
while not stop.wait(config.execution_interval_sec):
|
|
try:
|
|
execution.run_once()
|
|
except Exception: # noqa: BLE001 - loop must survive transient errors
|
|
continue
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
apply_migrations(config.dsn)
|
|
slot_service.preallocate_daily_slots(
|
|
datetime.now(tz=timezone.utc).date(), config.total_slots
|
|
)
|
|
execution.reconcile() # restart recovery before serving (REQ-014)
|
|
worker = threading.Thread(target=_loop, name="trex-execution", daemon=True)
|
|
worker.start()
|
|
try:
|
|
yield
|
|
finally:
|
|
stop.set()
|
|
worker.join(timeout=config.execution_interval_sec * 3)
|
|
|
|
base.router.lifespan_context = lifespan
|
|
return base
|
|
|
|
|
|
def main() -> None: # pragma: no cover - process entrypoint
|
|
import uvicorn
|
|
|
|
config = DaemonConfig.from_env()
|
|
app = build_app(config)
|
|
uvicorn.run(app, host=config.host, port=config.port)
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
main()
|