py-ib-controller 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ """schedule.py -- pure wall-clock scheduling math for the self-scheduled
2
+ shutdown actions (`Config.cold_restart_time`/`closedown_at`), consumed by
3
+ `control_loop.py`. No I/O, no asyncio -- same split discipline as
4
+ `launcher.build_launch_plan` vs `launch_instance`: testable against a
5
+ synthetic `datetime`, no live Gateway/TWS needed.
6
+
7
+ Both settings come from IBC's own `config.ini` (`ColdRestartTime`/
8
+ `ClosedownAt`) but, unlike `auto_restart_time`/`auto_logoff_time`, are not GUI
9
+ settings at all -- IBC implements them itself as a self-scheduled tidy
10
+ close-down (`IbcTws.java`'s own `startShutdownTimerIfRequired`/
11
+ `getColdRestartTime`/`getShutdownTime`), which this module ports the
12
+ scheduling math for. Applies to TWS and Gateway alike: `IbcTws.java` is IBC's
13
+ shared base class for both programs, and `IbcGateway.java`'s near-empty (38
14
+ lines: `setupDefaultEnvironment` + `IbcTws.load()`) body is exactly *why*
15
+ Gateway inherits the whole shutdown timer, not proof it's TWS-only.
16
+ `control_loop.py` owns actually acting on the result: ending the READY wait
17
+ so the existing `clean_shutdown` call performs the real menu-based close
18
+ (`File > Exit` on TWS, `File > Close` on Gateway), and -- for a cold restart
19
+ -- forcing a fresh relaunch via `launch_instance` with no restart hash (this
20
+ is ibcontroller's own supervisor-driven relaunch, not IBC's native-launcher
21
+ `File > Restart`/`COLDRESTART`-flag mechanism)."""
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from dataclasses import dataclass
27
+ from datetime import datetime, time, timedelta
28
+ from enum import Enum, auto
29
+
30
+ from ibcontroller.config import Config
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _WEEKDAY_NAMES = [
35
+ "Monday",
36
+ "Tuesday",
37
+ "Wednesday",
38
+ "Thursday",
39
+ "Friday",
40
+ "Saturday",
41
+ "Sunday",
42
+ ]
43
+ # Index matches datetime.weekday() (Monday=0..Sunday=6) directly.
44
+ _WEEKDAY_INDEX = {name: i for i, name in enumerate(_WEEKDAY_NAMES)}
45
+ _COLD_RESTART_WEEKDAY = _WEEKDAY_INDEX["Sunday"]
46
+
47
+
48
+ class ScheduleError(Exception):
49
+ """A malformed `cold_restart_time`/`closedown_at` value."""
50
+
51
+
52
+ class ScheduledAction(Enum):
53
+ COLD_RESTART = auto()
54
+ TIDY_CLOSEDOWN = auto()
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ScheduledShutdown:
59
+ at: datetime
60
+ action: ScheduledAction
61
+
62
+
63
+ def _parse_hhmm(value: str, *, setting_name: str) -> time:
64
+ """Strict 24-hour `"HH:MM"` -- mirrors IBC's own
65
+ `SimpleDateFormat("HH:mm")` strictness (unlike `auto_restart_time`/
66
+ `auto_logoff_time`'s 12-hour `"hh:mm AM/PM"`, these two settings are
67
+ always 24-hour in IBC's own `config.ini`)."""
68
+ try:
69
+ parsed = datetime.strptime(value.strip(), "%H:%M")
70
+ except ValueError:
71
+ raise ScheduleError(
72
+ f'{setting_name} setting must be 24-hour "HH:MM", got {value!r}'
73
+ ) from None
74
+ return parsed.time()
75
+
76
+
77
+ def parse_closedown_at(value: str) -> tuple[int | None, time]:
78
+ """Parses `"HH:MM"` (every day) or `"<Weekday> HH:MM"` (one day a week --
79
+ `Weekday` a full English name, `Monday`..`Sunday`, matched case-
80
+ insensitively). Deliberately NOT locale-dependent (`%A`/`strptime`) --
81
+ IBC's own `config.ini` comment flags exactly this class of bug for
82
+ non-Latin-1 locales (Unicode-escaping a day name); a fixed English table
83
+ sidesteps it entirely rather than porting the same fragility. Returns
84
+ `(weekday index matching datetime.weekday(), time)`, weekday `None` for
85
+ "every day"."""
86
+ parts = value.strip().rsplit(" ", 1)
87
+ if len(parts) == 2: # noqa: PLR2004
88
+ day_name = parts[0].strip().title()
89
+ if day_name not in _WEEKDAY_INDEX:
90
+ raise ScheduleError(
91
+ 'closedown_at setting must be "HH:MM" or "<Weekday> HH:MM" '
92
+ f"(Weekday one of {', '.join(_WEEKDAY_NAMES)}), got {value!r}"
93
+ )
94
+ return _WEEKDAY_INDEX[day_name], _parse_hhmm(
95
+ parts[1], setting_name="closedown_at"
96
+ )
97
+ return None, _parse_hhmm(value, setting_name="closedown_at")
98
+
99
+
100
+ def _next_occurrence(target: time, weekday: int | None, now: datetime) -> datetime:
101
+ """Next wall-clock instant strictly after `now` matching `target`/
102
+ `weekday` (`weekday=None` -- every day). Wraps to the next matching day
103
+ if today's slot has already passed (or is exactly `now`)."""
104
+ candidate = now.replace(
105
+ hour=target.hour, minute=target.minute, second=0, microsecond=0
106
+ )
107
+ if weekday is None:
108
+ if candidate <= now:
109
+ candidate += timedelta(days=1)
110
+ return candidate
111
+ candidate += timedelta(days=(weekday - now.weekday()) % 7)
112
+ if candidate <= now:
113
+ candidate += timedelta(days=7)
114
+ return candidate
115
+
116
+
117
+ def next_scheduled_shutdown(config: Config, now: datetime) -> ScheduledShutdown | None:
118
+ """Earliest of `Config.cold_restart_time` (every Sunday) and
119
+ `Config.closedown_at` (daily or weekly) -- applies to TWS and Gateway
120
+ alike. A malformed value is logged and that one field is skipped
121
+ (matching `settings.py`'s own per-entry error isolation) rather than
122
+ aborting the other field or raising out of this function. Returns `None`
123
+ if nothing applies."""
124
+ candidates: list[ScheduledShutdown] = []
125
+ if config.cold_restart_time:
126
+ try:
127
+ target = _parse_hhmm(
128
+ config.cold_restart_time, setting_name="cold_restart_time"
129
+ )
130
+ except ScheduleError:
131
+ logger.exception("IBController > invalid cold_restart_time -- ignoring")
132
+ else:
133
+ candidates.append(
134
+ ScheduledShutdown(
135
+ at=_next_occurrence(target, _COLD_RESTART_WEEKDAY, now),
136
+ action=ScheduledAction.COLD_RESTART,
137
+ )
138
+ )
139
+ if config.closedown_at:
140
+ try:
141
+ weekday, target = parse_closedown_at(config.closedown_at)
142
+ except ScheduleError:
143
+ logger.exception("IBController > invalid closedown_at -- ignoring")
144
+ else:
145
+ candidates.append(
146
+ ScheduledShutdown(
147
+ at=_next_occurrence(target, weekday, now),
148
+ action=ScheduledAction.TIDY_CLOSEDOWN,
149
+ )
150
+ )
151
+ if not candidates:
152
+ return None
153
+ # Ties break toward cold restart, matching IBC's own
154
+ # `coldRestartTime.before(shutdownTime)` check in
155
+ # startShutdownTimerIfRequired.
156
+ candidates.sort(key=lambda c: (c.at, c.action is not ScheduledAction.COLD_RESTART))
157
+ return candidates[0]