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()