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.
- ibcontroller/__init__.py +0 -0
- ibcontroller/actions.py +273 -0
- ibcontroller/agent_client.py +466 -0
- ibcontroller/app_dirs.py +44 -0
- ibcontroller/cli.py +211 -0
- ibcontroller/config.py +410 -0
- ibcontroller/control_loop.py +636 -0
- ibcontroller/data/builtin_settings.toml +37 -0
- ibcontroller/data/ibcontroller.toml.example +178 -0
- ibcontroller/data/ibkr_settings.toml.example +135 -0
- ibcontroller/data/labels.json +81 -0
- ibcontroller/data/labels.json.example +44 -0
- ibcontroller/diagnostics.py +119 -0
- ibcontroller/dispatch.py +288 -0
- ibcontroller/ibcontroller-agent.jar +0 -0
- ibcontroller/labels.py +242 -0
- ibcontroller/launcher.py +909 -0
- ibcontroller/logging_setup.py +277 -0
- ibcontroller/login.py +578 -0
- ibcontroller/main.py +156 -0
- ibcontroller/py.typed +0 -0
- ibcontroller/recognisers.py +448 -0
- ibcontroller/schedule.py +157 -0
- ibcontroller/settings.py +554 -0
- py_ib_controller-0.0.0.dist-info/METADATA +490 -0
- py_ib_controller-0.0.0.dist-info/RECORD +30 -0
- py_ib_controller-0.0.0.dist-info/WHEEL +5 -0
- py_ib_controller-0.0.0.dist-info/entry_points.txt +2 -0
- py_ib_controller-0.0.0.dist-info/licenses/LICENSE.txt +176 -0
- py_ib_controller-0.0.0.dist-info/top_level.txt +1 -0
ibcontroller/cli.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""ibcontroller CLI entrypoint. This is the `ibcontroller` command that is installed
|
|
2
|
+
by the package. It is a thin wrapper around `ibcontroller.main`, which does the actual
|
|
3
|
+
work of loading config/labels and running the control loop -- this module only parses
|
|
4
|
+
arguments, reports what happened in human-readable form, and maps exceptions to exit
|
|
5
|
+
codes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
from importlib.metadata import PackageNotFoundError
|
|
13
|
+
from importlib.metadata import version as _pkg_version
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import attrs
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from ibcontroller import main as _main
|
|
21
|
+
from ibcontroller.app_dirs import resolve_app_dirs
|
|
22
|
+
from ibcontroller.config import Config, ConfigError, TradingMode
|
|
23
|
+
from ibcontroller.control_loop import OPERATIONAL_ERRORS as _CYCLE_OPERATIONAL_ERRORS
|
|
24
|
+
|
|
25
|
+
# Known "operational" failure modes (config mistakes, an install that isn't where
|
|
26
|
+
# configured, a real login/settings failure) -- reported as a clean one-line message
|
|
27
|
+
# and exit 1, not a traceback. Anything not in this tuple is unexpected and should show
|
|
28
|
+
# its full traceback (a real bug report, not a deployment mistake to fix and retry).
|
|
29
|
+
# `ConfigError`/`RuntimeError` happen before a cycle even starts (config load); the
|
|
30
|
+
# rest is `control_loop.py`'s own set, reused rather than duplicated here.
|
|
31
|
+
_OPERATIONAL_ERRORS = (
|
|
32
|
+
ConfigError,
|
|
33
|
+
RuntimeError,
|
|
34
|
+
*_CYCLE_OPERATIONAL_ERRORS,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
app = typer.Typer(
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
help="Login/launch controller for TWS and IBKR Gateway.",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Real `Config` field names -- source of truth for `_cli_overrides` below, so a
|
|
43
|
+
# future field rename is caught immediately (KeyError-shaped, via the `in` check)
|
|
44
|
+
# rather than the override silently never reaching `Config`.
|
|
45
|
+
_CONFIG_FIELD_NAMES = frozenset(f.name for f in attrs.fields(Config))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cli_overrides(**kwargs: Any) -> dict[str, Any]:
|
|
49
|
+
"""Builds `load_config`'s `cli_overrides` dict from `run`'s own per-field CLI
|
|
50
|
+
options: drops unset (`None`) options -- a `None` would wrongly override a real
|
|
51
|
+
TOML/env value -- and stringifies `Path` options (typed-settings expects the same
|
|
52
|
+
string shape TOML/env values already arrive in)."""
|
|
53
|
+
return {
|
|
54
|
+
k: (str(v) if isinstance(v, Path) else v)
|
|
55
|
+
for k, v in kwargs.items()
|
|
56
|
+
if v is not None and k in _CONFIG_FIELD_NAMES
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def init(
|
|
62
|
+
force: bool = typer.Option(
|
|
63
|
+
False,
|
|
64
|
+
"--force",
|
|
65
|
+
help="Overwrite existing scaffold files with the bundled templates.",
|
|
66
|
+
),
|
|
67
|
+
app_dir: Path | None = typer.Option( # noqa: B008 -- typer's own idiom
|
|
68
|
+
None,
|
|
69
|
+
"--app-dir",
|
|
70
|
+
help="Override IBC_APP_DIR for this invocation -- config/log/run "
|
|
71
|
+
"all move under {app_dir}/{config,log,run} instead of the platform default.",
|
|
72
|
+
),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Scaffold the config directory with starter ibcontroller.toml/
|
|
75
|
+
ibkr_settings.toml.example/labels.json.example files. Never overwrites a file
|
|
76
|
+
that already exists unless --force is given."""
|
|
77
|
+
if app_dir is not None:
|
|
78
|
+
os.environ["IBC_APP_DIR"] = str(app_dir)
|
|
79
|
+
config_dir, log_dir = resolve_app_dirs()
|
|
80
|
+
written = _main.ensure_config_scaffold(config_dir, log_dir, force=force)
|
|
81
|
+
|
|
82
|
+
typer.echo(f"config dir: {config_dir}")
|
|
83
|
+
typer.echo(f"log dir: {log_dir}")
|
|
84
|
+
if written:
|
|
85
|
+
for path in written:
|
|
86
|
+
typer.echo(f" wrote {path.name}")
|
|
87
|
+
else:
|
|
88
|
+
typer.echo(
|
|
89
|
+
" nothing to do -- every scaffold file already exists "
|
|
90
|
+
"(use --force to overwrite)"
|
|
91
|
+
)
|
|
92
|
+
if "ibcontroller.toml" in {p.name for p in written} or force:
|
|
93
|
+
typer.echo(
|
|
94
|
+
f"\nEdit {config_dir / 'ibcontroller.toml'} (set trading_mode/tws_version) "
|
|
95
|
+
"and set IBC_USERID/IBC_PASSWORD before running "
|
|
96
|
+
"`ibcontroller run`."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def run( # noqa: PLR0913, PLR0917 -- one typer.Option per Config field, not
|
|
102
|
+
# actually 8 callers'-worth of complexity
|
|
103
|
+
dotenv: Path | None = typer.Option( # noqa: B008 -- typer's own idiom
|
|
104
|
+
None,
|
|
105
|
+
"--dotenv",
|
|
106
|
+
help="Optional .env file to load into the environment before reading config "
|
|
107
|
+
"(only fills variables not already set).",
|
|
108
|
+
),
|
|
109
|
+
app_dir: Path | None = typer.Option( # noqa: B008 -- typer's own idiom
|
|
110
|
+
None,
|
|
111
|
+
"--app-dir",
|
|
112
|
+
help="Override IBC_APP_DIR for this invocation -- config/log/run "
|
|
113
|
+
"all move under {app_dir}/{config,log,run} instead of the platform default.",
|
|
114
|
+
),
|
|
115
|
+
trading_mode: TradingMode | None = typer.Option( # noqa: B008
|
|
116
|
+
None,
|
|
117
|
+
"--trading-mode",
|
|
118
|
+
help="Override Config.trading_mode ('live'/'paper') for this invocation.",
|
|
119
|
+
),
|
|
120
|
+
tws_path: Path | None = typer.Option( # noqa: B008 -- typer's own idiom
|
|
121
|
+
None,
|
|
122
|
+
"--tws-path",
|
|
123
|
+
help="Override Config.tws_path (the TWS/Gateway install-path inference) "
|
|
124
|
+
"for this invocation.",
|
|
125
|
+
),
|
|
126
|
+
tws_channel: str | None = typer.Option(
|
|
127
|
+
None,
|
|
128
|
+
"--tws-channel",
|
|
129
|
+
help="Override Config.tws_channel ('stable'/'latest', filters install "
|
|
130
|
+
"auto-detection) for this invocation.",
|
|
131
|
+
),
|
|
132
|
+
program: str | None = typer.Option(
|
|
133
|
+
None,
|
|
134
|
+
"--program",
|
|
135
|
+
help="Override Config.program ('gateway'/'tws') for this invocation.",
|
|
136
|
+
),
|
|
137
|
+
tws_settings_path: Path | None = typer.Option( # noqa: B008
|
|
138
|
+
None,
|
|
139
|
+
"--tws-settings-path",
|
|
140
|
+
help="Override Config.tws_settings_path (where TWS/Gateway stores its own "
|
|
141
|
+
"settings) for this invocation.",
|
|
142
|
+
),
|
|
143
|
+
instance: str | None = typer.Option(
|
|
144
|
+
None,
|
|
145
|
+
"--instance",
|
|
146
|
+
help="Override Config.instance (per-instance log/trace/socket names) for "
|
|
147
|
+
"this invocation. Defaults to '{program}-{trading_mode}', so --trading-mode "
|
|
148
|
+
"alone is usually enough to keep paper/live instances apart.",
|
|
149
|
+
),
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Run one ibcontroller instance until it stops (Ctrl-C for a graceful shutdown,
|
|
152
|
+
or Gateway/TWS exiting or restarting on its own). Scaffolds the config directory
|
|
153
|
+
first if it's missing, same as `ibcontroller init`.
|
|
154
|
+
|
|
155
|
+
`--trading-mode`/`--tws-path`/`--tws-channel`/`--program`/`--tws-settings-path`/
|
|
156
|
+
`--instance` let one
|
|
157
|
+
invocation pick these `Config` fields directly, overriding TOML/env for this run
|
|
158
|
+
only -- e.g. `ibcontroller run --trading-mode=live --dotenv=.env-live` and
|
|
159
|
+
`ibcontroller run --trading-mode=paper --dotenv=.env-paper` run side by side from
|
|
160
|
+
one shared config."""
|
|
161
|
+
if app_dir is not None:
|
|
162
|
+
os.environ["IBC_APP_DIR"] = str(app_dir)
|
|
163
|
+
config_dir, log_dir = resolve_app_dirs()
|
|
164
|
+
cli_overrides = _cli_overrides(
|
|
165
|
+
trading_mode=trading_mode,
|
|
166
|
+
tws_path=tws_path,
|
|
167
|
+
tws_channel=tws_channel,
|
|
168
|
+
program=program,
|
|
169
|
+
tws_settings_path=tws_settings_path,
|
|
170
|
+
instance=instance,
|
|
171
|
+
)
|
|
172
|
+
try:
|
|
173
|
+
cause = asyncio.run(
|
|
174
|
+
_main.run_async(
|
|
175
|
+
config_dir,
|
|
176
|
+
log_dir,
|
|
177
|
+
dotenv_path=dotenv,
|
|
178
|
+
cli_overrides=cli_overrides,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
except ConfigError as exc:
|
|
182
|
+
typer.echo(f"error: {exc}", err=True)
|
|
183
|
+
typer.echo(
|
|
184
|
+
f"\nEdit {config_dir / 'ibcontroller.toml'} (run `ibcontroller init` "
|
|
185
|
+
"first if it doesn't exist yet) and set "
|
|
186
|
+
"IBC_USERID/IBC_PASSWORD.",
|
|
187
|
+
err=True,
|
|
188
|
+
)
|
|
189
|
+
raise typer.Exit(1) from exc
|
|
190
|
+
except _OPERATIONAL_ERRORS as exc:
|
|
191
|
+
typer.echo(f"error: {exc}", err=True)
|
|
192
|
+
raise typer.Exit(1) from exc
|
|
193
|
+
|
|
194
|
+
typer.echo(f"stopped: {cause.name}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command()
|
|
198
|
+
def version() -> None:
|
|
199
|
+
"""Print the installed ibcontroller version."""
|
|
200
|
+
try:
|
|
201
|
+
typer.echo(_pkg_version("ibcontroller"))
|
|
202
|
+
except PackageNotFoundError:
|
|
203
|
+
typer.echo("unknown (not installed)")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def main() -> None:
|
|
207
|
+
app()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
main()
|
ibcontroller/config.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ibcontroller config
|
|
3
|
+
|
|
4
|
+
Configuration settings hierarchy, precedence, and validation.
|
|
5
|
+
Credentials (userid/password) are deliberately environment-variable-only, never accepted
|
|
6
|
+
from a file. This is a security measure to avoid accidentally committing secrets to
|
|
7
|
+
source control, and to support Docker secrets and other secret-management patterns.
|
|
8
|
+
The config file (ibcontroller.toml) is intentionally flat (no [section] headers) to
|
|
9
|
+
simplify parsing and avoid confusion. The config file is optional; if it doesn't exist,
|
|
10
|
+
the defaults are used, and environment variables can override them.
|
|
11
|
+
|
|
12
|
+
The precedence is: defaults < config file < environment variables.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import tomllib
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from enum import StrEnum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import attrs
|
|
25
|
+
import typed_settings as ts
|
|
26
|
+
from dotenv import load_dotenv
|
|
27
|
+
from typed_settings.converters import Converter
|
|
28
|
+
from typed_settings.dict_utils import set_path
|
|
29
|
+
from typed_settings.exceptions import TsError
|
|
30
|
+
from typed_settings.loaders import (
|
|
31
|
+
DictLoader,
|
|
32
|
+
EnvLoader,
|
|
33
|
+
FileLoader,
|
|
34
|
+
LoadedSettings,
|
|
35
|
+
LoaderMeta,
|
|
36
|
+
TomlFormat,
|
|
37
|
+
)
|
|
38
|
+
from typed_settings.processors import FormatProcessor
|
|
39
|
+
from typed_settings.types import Secret
|
|
40
|
+
|
|
41
|
+
from ibcontroller.app_dirs import resolve_app_dirs
|
|
42
|
+
|
|
43
|
+
ENV_PREFIX = "IBC_"
|
|
44
|
+
ENV_SENSITIVE: list[str] = ["IBC_USERID", "IBC_PASSWORD"]
|
|
45
|
+
|
|
46
|
+
# Field/TOML key names that must never appear in the config file -- credentials are
|
|
47
|
+
# environment-variable-only (see module docstring). Mirrors config_old.py's own
|
|
48
|
+
# _CREDENTIAL_KEY_NAMES, updated for this module's `userid` field name.
|
|
49
|
+
_CREDENTIAL_KEY_NAMES = frozenset({"userid", "password", "tws_userid", "tws_password"})
|
|
50
|
+
|
|
51
|
+
_LOG_LEVELS = {
|
|
52
|
+
"debug": logging.DEBUG,
|
|
53
|
+
"info": logging.INFO,
|
|
54
|
+
"warning": logging.WARNING,
|
|
55
|
+
"error": logging.ERROR,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConfigError(Exception):
|
|
60
|
+
"""Bad or unsafe configuration -- an invalid value, a missing required setting.
|
|
61
|
+
The one exception type `load_config` raises; wraps typed-settings' own errors
|
|
62
|
+
(`TsError` subclasses) as well as the hand-written checks below (e.g. missing
|
|
63
|
+
credentials) so callers (`cli.py`) only ever need to catch one type."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _find_credential_keys(data: Mapping[str, Any], prefix: str = "") -> list[str]:
|
|
67
|
+
"""Recursively searches a nested mapping for any keys that look like credentials.
|
|
68
|
+
Returns the full dotted paths to those keys. Direct port of config_old.py's own
|
|
69
|
+
function of the same name."""
|
|
70
|
+
found = []
|
|
71
|
+
for key, value in data.items():
|
|
72
|
+
path = f"{prefix}{key}"
|
|
73
|
+
if key in _CREDENTIAL_KEY_NAMES:
|
|
74
|
+
found.append(path)
|
|
75
|
+
if isinstance(value, Mapping):
|
|
76
|
+
found.extend(_find_credential_keys(value, prefix=f"{path}."))
|
|
77
|
+
return found
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _reject_credentials_in_file(data: Mapping[str, Any], path: Path) -> None:
|
|
81
|
+
"""Raises ConfigError if any credential-shaped keys are found in the parsed TOML
|
|
82
|
+
data -- credentials are environment-variable-only, never accepted from a file
|
|
83
|
+
(direct port of config_old.py's function of the same name)."""
|
|
84
|
+
found = _find_credential_keys(data)
|
|
85
|
+
if found:
|
|
86
|
+
raise ConfigError(
|
|
87
|
+
f"{path}: credentials must not be set in the config file "
|
|
88
|
+
f"({', '.join(found)} found) -- use environment variables instead "
|
|
89
|
+
"(IBC_USERID/IBC_PASSWORD, each also accepting a "
|
|
90
|
+
"_FILE-suffixed variant)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _FileBackedEnvLoader(EnvLoader):
|
|
95
|
+
"""Docker-secrets pattern: for each env var, `{var}_FILE` wins if set (trimmed
|
|
96
|
+
file contents); otherwise falls back to the plain var, same as `EnvLoader`.
|
|
97
|
+
Direct port of config_old.py's own `_env_or_file`, adapted to the `Loader` protocol
|
|
98
|
+
(`__call__(settings_cls, options) -> LoadedSettings`, see loaders.py) rather than
|
|
99
|
+
a plain function -- typed-settings has no `_FILE` convention of its own."""
|
|
100
|
+
|
|
101
|
+
def __call__(self, settings_cls: object, options: object) -> LoadedSettings:
|
|
102
|
+
env = os.environ
|
|
103
|
+
values: dict[str, Any] = {}
|
|
104
|
+
for option in options: # type: ignore[attr-defined]
|
|
105
|
+
varname = self.get_envvar(option)
|
|
106
|
+
file_varname = f"{varname}_FILE"
|
|
107
|
+
if file_varname in env:
|
|
108
|
+
secret_path = Path(env[file_varname])
|
|
109
|
+
try:
|
|
110
|
+
value = secret_path.read_text(encoding="utf-8").strip()
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
raise ConfigError(f"{file_varname}={secret_path}: {exc}") from exc
|
|
113
|
+
set_path(values, option.path, value) # type: ignore[attr-defined]
|
|
114
|
+
elif varname in env:
|
|
115
|
+
set_path(values, option.path, env[varname]) # type: ignore[attr-defined]
|
|
116
|
+
return LoadedSettings(values, LoaderMeta(self))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _log_level_converter(raw: object) -> int:
|
|
120
|
+
"""
|
|
121
|
+
Accept the declared default (already a real `int`, e.g. `logging.INFO`) as well as a
|
|
122
|
+
raw name string from the environment/file layers.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(raw, int):
|
|
125
|
+
return raw
|
|
126
|
+
name = str(raw).strip().lower()
|
|
127
|
+
if name not in _LOG_LEVELS:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"log_level: expected one of {', '.join(sorted(_LOG_LEVELS))}, got {raw!r}"
|
|
130
|
+
)
|
|
131
|
+
return _LOG_LEVELS[name]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TradingMode(StrEnum):
|
|
135
|
+
LIVE = "live"
|
|
136
|
+
PAPER = "paper"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ExistingSessionAction(StrEnum):
|
|
140
|
+
"""ExistingSessionDetectedAction is the user's choice for what to do when IBC
|
|
141
|
+
detects an existing TWS/Gateway session (see `ExistingSessionRecognizer` in
|
|
142
|
+
recognisers.py). The default is MANUAL, which leaves the user to decide what to do
|
|
143
|
+
in the GUI. PRIMARY and SECONDARY are the two automatic options, and
|
|
144
|
+
PRIMARY_OVERRIDE is a special case that forces the primary session to close even if
|
|
145
|
+
it is logged in as a different user (see the `ExistingSessionRecognizer` docstring
|
|
146
|
+
for why this is a real risk).
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
MANUAL = "manual"
|
|
150
|
+
PRIMARY = "primary"
|
|
151
|
+
PRIMARY_OVERRIDE = "primaryoverride"
|
|
152
|
+
SECONDARY = "secondary"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class LogSink(StrEnum):
|
|
156
|
+
"""Where `configure_logging`/`configure_gateway_stdout` send their output --
|
|
157
|
+
exclusive, not additive: FILE is file-only (no console), STD is console-only
|
|
158
|
+
(no file). Deliberately excludes `configure_trace`'s NDJSON wire trace, which
|
|
159
|
+
stays file-only regardless (meant to be tailed, not mixed into a formatted
|
|
160
|
+
stdout stream). Default is STD (see `log_sink` field below for why)."""
|
|
161
|
+
|
|
162
|
+
FILE = "file"
|
|
163
|
+
STD = "std"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class DiagnosticScope(StrEnum):
|
|
167
|
+
"""Which windows `diagnostics.watch_for_diagnostics` dumps -- input from
|
|
168
|
+
IBC's `LogStructureScope`, not a straight copy (IBC also has `untitled`,
|
|
169
|
+
dropped here: `WindowInfo.title` already distinguishes a real untitled
|
|
170
|
+
window from one merely unmatched by any recogniser, so `UNKNOWN` alone
|
|
171
|
+
covers it)."""
|
|
172
|
+
|
|
173
|
+
KNOWN = "known"
|
|
174
|
+
UNKNOWN = "unknown"
|
|
175
|
+
ALL = "all"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class DiagnosticWhen(StrEnum):
|
|
179
|
+
"""When `diagnostics.watch_for_diagnostics` dumps a window -- input from
|
|
180
|
+
IBC's `LogStructureWhen`, minus `activate` (no matching `WindowEvent`
|
|
181
|
+
kind exists today; only `window_opened`/`window_closed`). NEVER is the
|
|
182
|
+
default -- diagnostics ship inert until explicitly opted into."""
|
|
183
|
+
|
|
184
|
+
OPEN = "open"
|
|
185
|
+
OPENCLOSE = "openclose"
|
|
186
|
+
NEVER = "never"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class AcceptIncomingConnections(StrEnum):
|
|
190
|
+
"""AcceptIncomingConnections is the user's choice for what to do when ibcontroller
|
|
191
|
+
detects incoming API connections (see `AcceptIncomingConnectionsRecognizer` in
|
|
192
|
+
recognisers.py). The default is MANUAL, which leaves the user to decide what to do
|
|
193
|
+
in the GUI. ACCEPT and REJECT are the two automatic options, and MANUAL is a special
|
|
194
|
+
case that forces the user to decide what to do in the GUI.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
ACCEPT = "accept"
|
|
198
|
+
REJECT = "reject"
|
|
199
|
+
MANUAL = "manual"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@ts.settings
|
|
203
|
+
class Config:
|
|
204
|
+
"""
|
|
205
|
+
IBController's own config file, `ibcontroller.toml`.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
# The IBKR settings file to load (if any) -- see `ibkr_settings.toml.example`.
|
|
209
|
+
# Defaults to `None` (inert) until a user points `[settings] file` at a renamed
|
|
210
|
+
# copy of the example file. See module docstring for why this is deliberate.
|
|
211
|
+
settings_file: str | None = None
|
|
212
|
+
# instance default: f"{program}-{trading_mode.value}".
|
|
213
|
+
instance: str = "{program}-{trading_mode}"
|
|
214
|
+
program: str = "gateway" # "gateway" or "tws"
|
|
215
|
+
# None -> auto-detected from the real install directory (launcher.
|
|
216
|
+
# _detect_tws_version), channel-aware via tws_channel below.
|
|
217
|
+
tws_version: str | None = None
|
|
218
|
+
# Filters auto-detection to installs carrying this channel (see
|
|
219
|
+
# launcher._detect_tws_version); "stable"/"latest" as directly observed on real
|
|
220
|
+
# installs (not a strict enum). Defaults to "stable", the safer pick when several
|
|
221
|
+
# channels are installed side by side.
|
|
222
|
+
tws_channel: str = "stable"
|
|
223
|
+
tws_path: str | None = None
|
|
224
|
+
# Where TWS/Gateway itself stores its settings (IBC's own TWS_SETTINGS_PATH) --
|
|
225
|
+
# deliberately NOT the same as tws_path (the install location). None defaults,
|
|
226
|
+
# per instance, in launcher.py's resolve_tws_settings_path.
|
|
227
|
+
tws_settings_path: str | None = None
|
|
228
|
+
# IBC-key-compatible settings (file or env, never a secret).
|
|
229
|
+
trading_mode: TradingMode = TradingMode.PAPER
|
|
230
|
+
# IBC: ReadOnlyLogin -- loaded but not yet wired to any behavior.
|
|
231
|
+
read_only_login: bool = False
|
|
232
|
+
read_only_api: bool | None = None # None = leave the existing setting unchanged
|
|
233
|
+
accept_incoming_connections: AcceptIncomingConnections = (
|
|
234
|
+
AcceptIncomingConnections.MANUAL
|
|
235
|
+
)
|
|
236
|
+
existing_session_action: ExistingSessionAction = ExistingSessionAction.MANUAL
|
|
237
|
+
|
|
238
|
+
# Login/MFA timeout and retry settings
|
|
239
|
+
# IBC: LoginDialogDisplayTimeout
|
|
240
|
+
login_dialog_display_timeout: float = 60.0
|
|
241
|
+
# IBC: SecondFactorAuthenticationTimeout
|
|
242
|
+
mfa_timeout: float = 180.0
|
|
243
|
+
# IBC: ReloginAfterSecondFactorAuthenticationTimeout
|
|
244
|
+
relogin_after_mfa_timeout: bool = False
|
|
245
|
+
# IBC: SecondFactorAuthenticationExitInterval
|
|
246
|
+
mfa_exit_interval: float = 60.0
|
|
247
|
+
|
|
248
|
+
# AutoRestartTime "hh:mm AM/PM" format (e.g. "08:00 AM")
|
|
249
|
+
auto_restart_time: str | None = None # None = leave the existing setting unchanged
|
|
250
|
+
# AutoLogoffTime "hh:mm AM/PM" format (e.g. "08:00 AM"); shares one Lock and
|
|
251
|
+
# Exit radio-button pair with auto_restart_time -- if both are set, the latter
|
|
252
|
+
# wins (applied last, matching builtin_settings.toml's order and IBC itself).
|
|
253
|
+
auto_logoff_time: str | None = None # None = leave the existing setting unchanged
|
|
254
|
+
|
|
255
|
+
# Self-scheduled shutdown actions (applies to TWS and Gateway alike), consumed
|
|
256
|
+
# by schedule.py/control_loop.py -- not GUI settings written to TWS/Gateway.
|
|
257
|
+
# ColdRestartTime "HH:MM" 24-hour local time; every Sunday, close the instance
|
|
258
|
+
# tidily and relaunch with a full fresh login (weekly reauth, Sunday 01:00
|
|
259
|
+
# US/Eastern token invalidation).
|
|
260
|
+
cold_restart_time: str | None = None # None = disable
|
|
261
|
+
# ClosedownAt "HH:MM" (daily) or "<Weekday> HH:MM" (weekly); close the instance
|
|
262
|
+
# tidily, no relaunch. If both cold_restart_time and closedown_at are set,
|
|
263
|
+
# whichever occurs first wins.
|
|
264
|
+
closedown_at: str | None = None # None = disable
|
|
265
|
+
|
|
266
|
+
# JVM heap for TWS/Gateway at launch, e.g. "1024m"/"4g". Overrides the
|
|
267
|
+
# -Xmx line in the installed .vmoptions file (ibgateway.vmoptions/tws.
|
|
268
|
+
# vmoptions); None = leave the file's value untouched.
|
|
269
|
+
java_heap_size: str | None = None
|
|
270
|
+
|
|
271
|
+
# Credentials -- environment-variable-only. Field name matches the real env var
|
|
272
|
+
# (IBC_USERID) directly -- no alias/mapping needed.
|
|
273
|
+
userid: Secret = ts.secret(default=None)
|
|
274
|
+
password: Secret = ts.secret(default=None)
|
|
275
|
+
|
|
276
|
+
# Logging and tracing -- log_dir is the one path ibcontroller's own log/trace files
|
|
277
|
+
# land in, always resolved: never None. Defaults to the platformdirs log dir
|
|
278
|
+
# (app_dirs.resolve_app_dirs, honoring IBC_APP_DIR's docker mode); a
|
|
279
|
+
# caller-supplied `load_config(log_dir=...)` is only a lower-priority default
|
|
280
|
+
# (DictLoader), so the config file's `log_dir` and the IBC_LOG_DIR env var
|
|
281
|
+
# genuinely override it. See load_config for the loader order.
|
|
282
|
+
trace_enabled: bool = False
|
|
283
|
+
log_dir: str = ts.option(factory=lambda: str(resolve_app_dirs()[1]))
|
|
284
|
+
# Logging level for ibcontroller's own log file (not the raw wire trace).
|
|
285
|
+
# Was previously unannotated (`log_level = logging.INFO`), which meant attrs
|
|
286
|
+
# never turned it into a real field at all -- not configurable, silently fixed
|
|
287
|
+
# at INFO, confirmed live via attrs.fields(Config).
|
|
288
|
+
log_level: int = ts.option(default=logging.INFO, converter=_log_level_converter)
|
|
289
|
+
# Where ibcontroller's own log (configure_logging) and Gateway/TWS's raw console
|
|
290
|
+
# output (configure_gateway_stdout) go -- "std" (console only) or "file" (only
|
|
291
|
+
# gateway-{instance}.log/ibcontroller-{instance}.log under log_dir), never both.
|
|
292
|
+
# Default is "std", not "file": this project's live-testing workflow relies on
|
|
293
|
+
# seeing logger output in the terminal during real login/restart sessions, and
|
|
294
|
+
# "std" is also what a Docker deployment wants with zero configuration (`docker
|
|
295
|
+
# logs` captures stdout/stderr, not files written inside the container). "file"
|
|
296
|
+
# is the explicit opt-in for a headless/service deployment that wants a
|
|
297
|
+
# persistent log file instead. Does not affect configure_trace's NDJSON wire
|
|
298
|
+
# trace, which stays file-only regardless (gitea #26).
|
|
299
|
+
log_sink: LogSink = LogSink.STD
|
|
300
|
+
|
|
301
|
+
# Diagnostics -- see diagnostics.py. Off by default (diagnostic_when=never);
|
|
302
|
+
# an opted-in deployment gets a structure dump logged for each matching
|
|
303
|
+
# window event, the replacement for socket-poking the agent by hand.
|
|
304
|
+
diagnostic_scope: DiagnosticScope = DiagnosticScope.KNOWN
|
|
305
|
+
diagnostic_when: DiagnosticWhen = DiagnosticWhen.NEVER
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _format_config(config: Config) -> list[str]:
|
|
309
|
+
"""One `key=value` line per `Config` field, sorted by name -- credentials already
|
|
310
|
+
masked (`Secret.__str__` returns `'*******'` unconditionally, even for `None`), for
|
|
311
|
+
startup/diagnostic display (gitea #45)."""
|
|
312
|
+
return [
|
|
313
|
+
f"{f.name}={getattr(config, f.name)!s}"
|
|
314
|
+
for f in sorted(attrs.fields(type(config)), key=lambda f: f.name)
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def load_config(
|
|
319
|
+
config_dir: str | Path,
|
|
320
|
+
log_dir: str | Path | None = None,
|
|
321
|
+
toml_path: str | Path | None = None,
|
|
322
|
+
dotenv_path: str | Path | None = None,
|
|
323
|
+
cli_overrides: Mapping[str, Any] | None = None,
|
|
324
|
+
) -> Config:
|
|
325
|
+
|
|
326
|
+
load_dotenv(dotenv_path, override=False)
|
|
327
|
+
sys.stdout.write("IBController > loaded from dotenv (override=False)\n")
|
|
328
|
+
env = os.environ
|
|
329
|
+
|
|
330
|
+
# display the env vars we actually read, but never print credentials in plaintext
|
|
331
|
+
sys.stdout.write("IBController > Environment variable: \n")
|
|
332
|
+
for key, val in env.items():
|
|
333
|
+
if key in ENV_SENSITIVE:
|
|
334
|
+
sys.stdout.write(f"IBController > env {key}=******\n")
|
|
335
|
+
elif key.startswith(ENV_PREFIX) and key not in ENV_SENSITIVE:
|
|
336
|
+
sys.stdout.write(f"IBController > env {key}={val}\n")
|
|
337
|
+
|
|
338
|
+
_config_file = (
|
|
339
|
+
Path(toml_path) if toml_path else Path(config_dir) / "ibcontroller.toml"
|
|
340
|
+
)
|
|
341
|
+
# Credentials are environment-variable-only -- reject them here, against the raw
|
|
342
|
+
# parsed TOML, before ts.load_settings ever sees the file (typed-settings has no
|
|
343
|
+
# per-key allow/deny-list hook of its own; userid/password are real Config fields,
|
|
344
|
+
# so InvalidOptionsError would not catch this). Silently skipped if the file
|
|
345
|
+
# doesn't exist, matching FileLoader's own optional-file handling.
|
|
346
|
+
try:
|
|
347
|
+
with _config_file.open("rb") as f:
|
|
348
|
+
_raw_toml = tomllib.load(f)
|
|
349
|
+
except OSError:
|
|
350
|
+
pass
|
|
351
|
+
else:
|
|
352
|
+
_reject_credentials_in_file(_raw_toml, _config_file)
|
|
353
|
+
|
|
354
|
+
# section=None -- the TOML file is flat, no [section] headers (see
|
|
355
|
+
# config_old.py's docstring). TomlFormat("") looks up settings[""] instead of
|
|
356
|
+
# returning the top-level dict, so it silently discards the whole file --
|
|
357
|
+
# confirmed live.
|
|
358
|
+
CONF_FORMATS = {"*.toml": TomlFormat(None)}
|
|
359
|
+
# A caller-supplied log_dir (platform dirs in production, a tmp dir in tests) is
|
|
360
|
+
# a low-priority DEFAULT -- first in the loader list, so every later loader wins
|
|
361
|
+
# over it and `log_dir` in the config file (or IBC_LOG_DIR) genuinely
|
|
362
|
+
# takes effect. The Config field's own factory default (resolve_app_dirs) is the
|
|
363
|
+
# even-lower built-in base when neither param nor file/env set it.
|
|
364
|
+
_log_dir_default: dict[str, str] = {}
|
|
365
|
+
if log_dir is not None:
|
|
366
|
+
_log_dir_default["log_dir"] = str(log_dir)
|
|
367
|
+
CONF_LOADERS: list[FileLoader | EnvLoader | DictLoader] = [
|
|
368
|
+
DictLoader(_log_dir_default),
|
|
369
|
+
ts.loaders.FileLoader(
|
|
370
|
+
files=[_config_file],
|
|
371
|
+
# pyrefly: ignore [bad-argument-type]
|
|
372
|
+
formats=CONF_FORMATS,
|
|
373
|
+
),
|
|
374
|
+
# Must come after FileLoader: later loaders win, so this reproduces
|
|
375
|
+
# defaults < file < env (config_old.py's own precedence). _FileBackedEnvLoader
|
|
376
|
+
# adds the _FILE-suffix Docker-secrets indirection on top of plain EnvLoader.
|
|
377
|
+
_FileBackedEnvLoader(prefix=ENV_PREFIX),
|
|
378
|
+
]
|
|
379
|
+
# CLI flags (cli.py) are the most explicit, per-invocation expression of intent --
|
|
380
|
+
# last loader wins, so these override file/env unconditionally. Only added when
|
|
381
|
+
# the caller actually set something, same reasoning as _log_dir_default above.
|
|
382
|
+
if cli_overrides:
|
|
383
|
+
CONF_LOADERS.append(DictLoader(dict(cli_overrides)))
|
|
384
|
+
CONF_PROCESSORS: list[FormatProcessor] = [FormatProcessor()]
|
|
385
|
+
CONF_CONVERTER: Converter = ts.converters.default_converter()
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
_config: Config = ts.load_settings(
|
|
389
|
+
Config,
|
|
390
|
+
loaders=CONF_LOADERS,
|
|
391
|
+
processors=CONF_PROCESSORS,
|
|
392
|
+
converter=CONF_CONVERTER,
|
|
393
|
+
)
|
|
394
|
+
except TsError as exc:
|
|
395
|
+
raise ConfigError(str(exc)) from exc
|
|
396
|
+
|
|
397
|
+
if (
|
|
398
|
+
_config.userid.get_secret_value() is None
|
|
399
|
+
or _config.password.get_secret_value() is None
|
|
400
|
+
):
|
|
401
|
+
raise ConfigError(
|
|
402
|
+
"credentials not found -- set IBC_USERID and "
|
|
403
|
+
"IBC_PASSWORD in the environment"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
sys.stdout.write("IBController > Config: \n")
|
|
407
|
+
for line in _format_config(_config):
|
|
408
|
+
sys.stdout.write(f"IBController > config {line}\n")
|
|
409
|
+
|
|
410
|
+
return _config
|