Source code for trader.daemon.schedule
"""Turning the market clock into an action.
Pure: a `MarketClock` and a `now` in, a decision out. The loop does the
sleeping, so every case here is an equality assertion in a test rather than a
stopwatch.
"""
from dataclasses import dataclass
from datetime import datetime
from trader.config.schema import DaemonConfig
from trader.domain import MarketClock
__all__ = ["Action", "MarketSchedule", "RunNow", "SleepFor"]
# What to wait when the broker says "closed" but `next_open` has already
# passed. The two clocks disagree — usually a few seconds of skew around the
# open — and `max(0, ...)` would re-read the clock as fast as the network
# allows. Small enough not to miss the open by anything that matters, large
# enough not to be a busy loop against a rate limit.
_STALE_CLOCK_SLEEP_SECONDS = 5.0
[docs]
@dataclass(frozen=True, slots=True)
class RunNow:
"""Run a cycle immediately."""
[docs]
@dataclass(frozen=True, slots=True)
class SleepFor:
"""Wait, and say why in terms an operator can check against a clock."""
seconds: float
reason: str
Action = RunNow | SleepFor
[docs]
class MarketSchedule:
"""Decides only the market-state wait.
Deliberately not the other two waits in this system: the gap between cycles
of an open market belongs to the loop, and the delay after a failure
belongs to `Backoff`. Three sleeps with three owners means no test has to
work out which one produced a duration.
"""
def __init__(self, config: DaemonConfig) -> None:
self._max_sleep_seconds = float(config.closed_poll_max_minutes * 60)
[docs]
def next_action(self, clock: MarketClock, now: datetime) -> Action:
"""Run now if the market is open, otherwise wait for the open.
Branches on `is_open` rather than comparing `now` against the
timestamps: while the market is open, `next_open` is *tomorrow's*
session, so inferring the state from the timestamps would read an open
market as one that opens in 20 hours.
"""
if clock.is_open:
return RunNow()
seconds = (clock.next_open - now).total_seconds()
if seconds <= 0:
return SleepFor(
_STALE_CLOCK_SLEEP_SECONDS,
f"market reported closed but the next open ("
f"{_format(clock.next_open)}) has passed; re-reading the clock",
)
capped = min(seconds, self._max_sleep_seconds)
return SleepFor(capped, f"market closed until {_format(clock.next_open)}")
def _format(moment: datetime) -> str:
"""A timestamp an operator can compare against a wall clock."""
return moment.strftime("%Y-%m-%d %H:%M %Z") or moment.isoformat()