walrasquant-lib 0.5.2__tar.gz → 0.5.3__tar.gz
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.
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/PKG-INFO +1 -1
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/pyproject.toml +1 -1
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/config.py +33 -1
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/constants.py +6 -63
- walrasquant_lib-0.5.3/src/walrasquant/settings.py +404 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/tools/pm2_wrapper.py +297 -127
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/README.md +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/aggregation.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/backends/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/backends/db.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/backends/db_memory.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/backends/db_postgresql.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/backends/db_sqlite.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/api_client.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/connector.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/ems.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/exchange.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/oms.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/retry.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/sms.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/ws_client.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/cache.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/clock.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/connection.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/entity.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/nautilius_core.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/registry.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/engine.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/error.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/base_factory.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/connector.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/constants.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/ems.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/error.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/exchange.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/factory.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/oms.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/rest_api.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/schema.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/websockets.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/connector.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/constants.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/ems.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/error.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/exchange.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/factory.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/oms.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/rest_api.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/schema.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/websockets.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/connector.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/constants.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/ems.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/error.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/exchange.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/factory.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/oms.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/rest_api.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/schema.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/websockets.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/connector.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/constants.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/ems.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/error.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/exchange.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/factory.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/oms.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/rest_api.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/schema.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/websockets.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/connector.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/constants.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/ems.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/error.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/exchange.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/factory.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/oms.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/rest_api.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/schema.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/signing.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/websockets.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/connector.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/constants.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/ems.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/error.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/exchange.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/factory.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/oms.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/rest_api.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/schema.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/websockets.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/registry.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/algorithm.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/algorithms/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/algorithms/twap.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/config.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/constants.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/schema.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/indicator.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/push.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/schema.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/strategy.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/tools/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/web/__init__.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/web/app.py +0 -0
- {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/web/server.py +0 -0
|
@@ -74,6 +74,27 @@ class BasicConfig:
|
|
|
74
74
|
testnet: bool = False
|
|
75
75
|
passphrase: str | None = None
|
|
76
76
|
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_settings(cls, path: str, testnet: bool = False) -> "BasicConfig":
|
|
79
|
+
"""Resolve credentials from the ``.keys`` config section at *path*.
|
|
80
|
+
|
|
81
|
+
``BasicConfig.from_settings("DERIBIT.TESTNET", testnet=True)`` reads
|
|
82
|
+
``settings.DERIBIT.TESTNET.API_KEY`` / ``.SECRET`` and, when present,
|
|
83
|
+
``.PASSPHRASE``. A missing section or field raises
|
|
84
|
+
``MissingSettingError`` describing exactly what to add and where.
|
|
85
|
+
"""
|
|
86
|
+
from walrasquant.settings import settings
|
|
87
|
+
|
|
88
|
+
node = settings
|
|
89
|
+
for segment in path.split("."):
|
|
90
|
+
node = getattr(node, segment)
|
|
91
|
+
return cls(
|
|
92
|
+
api_key=node.API_KEY,
|
|
93
|
+
secret=node.SECRET,
|
|
94
|
+
testnet=testnet,
|
|
95
|
+
passphrase=node.get("PASSPHRASE"),
|
|
96
|
+
)
|
|
97
|
+
|
|
77
98
|
|
|
78
99
|
@dataclass
|
|
79
100
|
class PublicConnectorConfig:
|
|
@@ -261,7 +282,8 @@ class Config:
|
|
|
261
282
|
private_conn_config (Dict[ExchangeType, List[PrivateConnectorConfig]]):
|
|
262
283
|
Private connector configurations by exchange.
|
|
263
284
|
zero_mq_signal_config (ZeroMQSignalConfig | None): Configuration for ZeroMQ signal, if used.
|
|
264
|
-
db_path (str): Path to the database file. Defaults to ".keys/cache.db"
|
|
285
|
+
db_path (str): Path to the database file. Defaults to ".keys/cache.db",
|
|
286
|
+
anchored to the discovered .keys directory when one exists.
|
|
265
287
|
storage_backend (StorageType): Type of storage backend to use. Defaults to SQLITE.
|
|
266
288
|
cache_sync_interval (int): Interval in seconds for cache synchronization. Defaults to 60.
|
|
267
289
|
cache_expired_time (int): Time in seconds after which cache entries expire. Defaults to 3600.
|
|
@@ -303,3 +325,13 @@ class Config:
|
|
|
303
325
|
self.log_config.filename = (
|
|
304
326
|
f"{log_folder}/{self.strategy_id}_{self.user_id}.log"
|
|
305
327
|
)
|
|
328
|
+
|
|
329
|
+
# Anchor the default db_path to the discovered .keys directory so the
|
|
330
|
+
# cache lands in the same place regardless of cwd; explicit paths are
|
|
331
|
+
# left untouched.
|
|
332
|
+
if self.db_path == ".keys/cache.db":
|
|
333
|
+
from walrasquant.settings import discover
|
|
334
|
+
|
|
335
|
+
keys_dir = discover().keys_dir
|
|
336
|
+
if keys_dir is not None:
|
|
337
|
+
self.db_path = str(keys_dir / "cache.db")
|
|
@@ -1,71 +1,14 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import sys
|
|
3
1
|
from typing import Literal, Dict, List, TypedDict, NotRequired
|
|
4
2
|
from enum import Enum
|
|
5
|
-
from dynaconf import Dynaconf
|
|
6
3
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if not os.path.exists(".keys/"):
|
|
15
|
-
os.makedirs(".keys/")
|
|
16
|
-
|
|
17
|
-
_SECRETS_CANDIDATES = [
|
|
18
|
-
".keys/.secrets.toml",
|
|
19
|
-
".keys/.secrets.yaml",
|
|
20
|
-
".keys/.secrets.yml",
|
|
21
|
-
]
|
|
22
|
-
_SETTINGS_CANDIDATES = [
|
|
23
|
-
".keys/settings.toml",
|
|
24
|
-
".keys/settings.yaml",
|
|
25
|
-
".keys/settings.yml",
|
|
26
|
-
]
|
|
27
|
-
|
|
28
|
-
_secrets_file = next((f for f in _SECRETS_CANDIDATES if os.path.exists(f)), None)
|
|
29
|
-
_settings_file = next((f for f in _SETTINGS_CANDIDATES if os.path.exists(f)), None)
|
|
30
|
-
_config_files = [f for f in (_settings_file, _secrets_file) if f]
|
|
31
|
-
|
|
32
|
-
if _secrets_file is None and not is_sphinx_build():
|
|
33
|
-
raise FileNotFoundError(
|
|
34
|
-
"Config file not found, please create a secrets file at one of: "
|
|
35
|
-
+ ", ".join(_SECRETS_CANDIDATES)
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
settings = Dynaconf(
|
|
40
|
-
envvar_prefix="NEXUS",
|
|
41
|
-
settings_files=_config_files,
|
|
42
|
-
secrets=_secrets_file,
|
|
43
|
-
environments=False,
|
|
44
|
-
load_dotenv=True,
|
|
4
|
+
from walrasquant.settings import ( # noqa: F401 (re-exported for compat)
|
|
5
|
+
MissingSettingError,
|
|
6
|
+
get_postgresql_config,
|
|
7
|
+
get_redis_config,
|
|
8
|
+
settings,
|
|
45
9
|
)
|
|
46
10
|
|
|
47
|
-
|
|
48
|
-
def get_postgresql_config():
|
|
49
|
-
"""Get PostgreSQL configuration from environment variables or settings files."""
|
|
50
|
-
return {
|
|
51
|
-
"host": settings.get("PG_HOST", "localhost"),
|
|
52
|
-
"port": settings.get("PG_PORT", 5432),
|
|
53
|
-
"user": settings.get("PG_USER", "postgres"),
|
|
54
|
-
"password": settings.get("PG_PASSWORD", ""),
|
|
55
|
-
"database": settings.get("PG_DATABASE", "postgres"),
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def get_redis_config():
|
|
60
|
-
try:
|
|
61
|
-
return {
|
|
62
|
-
"host": settings.REDIS_HOST,
|
|
63
|
-
"port": settings.REDIS_PORT,
|
|
64
|
-
"db": settings.REDIS_DB,
|
|
65
|
-
"password": settings.REDIS_PASSWORD,
|
|
66
|
-
}
|
|
67
|
-
except Exception as e:
|
|
68
|
-
raise ValueError(f"Failed to get Redis password: {e}")
|
|
11
|
+
BACKEND_LITERAL = Literal["memory", "redis"]
|
|
69
12
|
|
|
70
13
|
|
|
71
14
|
IntervalType = Literal[
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Lazy, location-independent access to the ``.keys/`` configuration.
|
|
2
|
+
|
|
3
|
+
Importing this module never touches the filesystem. The ``settings`` object
|
|
4
|
+
below discovers and loads the dynaconf config on first attribute access:
|
|
5
|
+
|
|
6
|
+
1. ``NEXUS_KEYS_DIR`` environment variable, if set.
|
|
7
|
+
2. A directory passed to :func:`configure` before first access.
|
|
8
|
+
3. Walking up from the current working directory looking for a ``.keys/``
|
|
9
|
+
directory that contains a secrets or settings file, stopping at the
|
|
10
|
+
project root (a directory containing ``.git`` or ``pyproject.toml``).
|
|
11
|
+
|
|
12
|
+
If no config file is found this is not an error: settings are served from
|
|
13
|
+
``NEXUS_*`` environment variables only, and a missing key raises
|
|
14
|
+
:class:`MissingSettingError` describing the searched locations and how to fix
|
|
15
|
+
it. Section paths inside the config files are free-form — whatever path is
|
|
16
|
+
written in the file is the path read from code
|
|
17
|
+
(``[BYBIT.LIVE.ACCOUNT1]`` -> ``settings.BYBIT.LIVE.ACCOUNT1.API_KEY``).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import threading
|
|
24
|
+
from collections.abc import Iterator, Mapping
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from dynaconf import Dynaconf
|
|
30
|
+
|
|
31
|
+
KEYS_DIR_ENV = "NEXUS_KEYS_DIR"
|
|
32
|
+
|
|
33
|
+
SECRETS_FILENAMES = (".secrets.toml", ".secrets.yaml", ".secrets.yml")
|
|
34
|
+
SETTINGS_FILENAMES = ("settings.toml", "settings.yaml", "settings.yml")
|
|
35
|
+
|
|
36
|
+
_ROOT_MARKERS = (".git", "pyproject.toml")
|
|
37
|
+
|
|
38
|
+
_CREDENTIAL_FIELD_NAMES = frozenset({"API_KEY", "SECRET", "PASSPHRASE"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MissingSettingError(AttributeError):
|
|
42
|
+
"""A requested setting does not exist in any loaded source.
|
|
43
|
+
|
|
44
|
+
Subclasses :class:`AttributeError` so existing ``except AttributeError``
|
|
45
|
+
handlers and ``getattr(..., default)`` calls keep working.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class CredentialSpec:
|
|
51
|
+
"""Credential shape for one exchange: which fields a section needs."""
|
|
52
|
+
|
|
53
|
+
fields: tuple[str, ...]
|
|
54
|
+
notes: tuple[str, ...] = ()
|
|
55
|
+
placeholders: dict[str, str] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
def placeholder(self, field_name: str) -> str:
|
|
58
|
+
return self.placeholders.get(field_name, f"your_{field_name.lower()}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
GENERIC_CREDENTIAL_SPEC = CredentialSpec(fields=("API_KEY", "SECRET"))
|
|
62
|
+
|
|
63
|
+
# Single source of truth for credential shapes, consumed by three places:
|
|
64
|
+
# ``wq init`` template rendering, ``wq doctor`` validation, and the
|
|
65
|
+
# MissingSettingError fix snippet. Keys are matched case-sensitively against
|
|
66
|
+
# the first segment of a section path.
|
|
67
|
+
EXCHANGE_CREDENTIALS: dict[str, CredentialSpec] = {
|
|
68
|
+
"BINANCE": CredentialSpec(fields=("API_KEY", "SECRET")),
|
|
69
|
+
"OKX": CredentialSpec(fields=("API_KEY", "SECRET", "PASSPHRASE")),
|
|
70
|
+
"BYBIT": CredentialSpec(fields=("API_KEY", "SECRET")),
|
|
71
|
+
"BITGET": CredentialSpec(fields=("API_KEY", "SECRET", "PASSPHRASE")),
|
|
72
|
+
"DERIBIT": CredentialSpec(
|
|
73
|
+
fields=("API_KEY", "SECRET"),
|
|
74
|
+
notes=("API_KEY = client_id, SECRET = client_secret",),
|
|
75
|
+
placeholders={"API_KEY": "your_client_id", "SECRET": "your_client_secret"},
|
|
76
|
+
),
|
|
77
|
+
"HYPERLIQUID": CredentialSpec(
|
|
78
|
+
fields=("API_KEY", "SECRET"),
|
|
79
|
+
notes=(
|
|
80
|
+
"API_KEY = main wallet address (NOT the agent address)",
|
|
81
|
+
"SECRET = agent private key, see https://app.hyperliquid.xyz/API",
|
|
82
|
+
),
|
|
83
|
+
placeholders={
|
|
84
|
+
"API_KEY": "your_wallet_address",
|
|
85
|
+
"SECRET": "your_agent_private_key",
|
|
86
|
+
},
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Sentinel values emitted by ``wq init``; ``wq doctor`` flags them as unfilled.
|
|
91
|
+
PLACEHOLDER_VALUES: frozenset[str] = frozenset(
|
|
92
|
+
spec.placeholder(f)
|
|
93
|
+
for spec in (*EXCHANGE_CREDENTIALS.values(), GENERIC_CREDENTIAL_SPEC)
|
|
94
|
+
for f in spec.fields
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def credential_spec_for(section_path: str) -> CredentialSpec:
|
|
99
|
+
"""Spec for a dotted section path, chosen by its first segment.
|
|
100
|
+
|
|
101
|
+
Matching is case-sensitive; unknown first segments get the generic
|
|
102
|
+
API_KEY/SECRET spec.
|
|
103
|
+
"""
|
|
104
|
+
first = section_path.split(".", 1)[0]
|
|
105
|
+
return EXCHANGE_CREDENTIALS.get(first, GENERIC_CREDENTIAL_SPEC)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class DiscoveryResult:
|
|
110
|
+
"""Where the config was (or was not) found."""
|
|
111
|
+
|
|
112
|
+
keys_dir: Path | None
|
|
113
|
+
secrets_file: Path | None
|
|
114
|
+
settings_file: Path | None
|
|
115
|
+
searched: tuple[Path, ...]
|
|
116
|
+
source: str # "env" | "configure" | "search" | "none"
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def config_files(self) -> list[Path]:
|
|
120
|
+
return [f for f in (self.settings_file, self.secrets_file) if f is not None]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _find_file(directory: Path, names: tuple[str, ...]) -> Path | None:
|
|
124
|
+
for name in names:
|
|
125
|
+
candidate = directory / name
|
|
126
|
+
if candidate.is_file():
|
|
127
|
+
return candidate
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def find_project_root(start: Path | None = None) -> Path | None:
|
|
132
|
+
"""Nearest ancestor (including *start*) containing ``.git`` or
|
|
133
|
+
``pyproject.toml``; None if there is no such directory."""
|
|
134
|
+
current = (start or Path.cwd()).resolve()
|
|
135
|
+
for candidate in (current, *current.parents):
|
|
136
|
+
if any((candidate / marker).exists() for marker in _ROOT_MARKERS):
|
|
137
|
+
return candidate
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def discover(start: Path | None = None) -> DiscoveryResult:
|
|
142
|
+
"""Resolve the ``.keys/`` directory without depending on cwd.
|
|
143
|
+
|
|
144
|
+
Never raises: when nothing is found the result has ``source="none"`` and
|
|
145
|
+
records every searched location for error messages.
|
|
146
|
+
"""
|
|
147
|
+
env_dir = os.environ.get(KEYS_DIR_ENV)
|
|
148
|
+
if env_dir:
|
|
149
|
+
keys_dir = Path(env_dir).expanduser().resolve()
|
|
150
|
+
return DiscoveryResult(
|
|
151
|
+
keys_dir=keys_dir,
|
|
152
|
+
secrets_file=_find_file(keys_dir, SECRETS_FILENAMES),
|
|
153
|
+
settings_file=_find_file(keys_dir, SETTINGS_FILENAMES),
|
|
154
|
+
searched=(keys_dir,),
|
|
155
|
+
source="env",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if _configured_keys_dir is not None:
|
|
159
|
+
keys_dir = _configured_keys_dir
|
|
160
|
+
return DiscoveryResult(
|
|
161
|
+
keys_dir=keys_dir,
|
|
162
|
+
secrets_file=_find_file(keys_dir, SECRETS_FILENAMES),
|
|
163
|
+
settings_file=_find_file(keys_dir, SETTINGS_FILENAMES),
|
|
164
|
+
searched=(keys_dir,),
|
|
165
|
+
source="configure",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
current = (start or Path.cwd()).resolve()
|
|
169
|
+
searched: list[Path] = []
|
|
170
|
+
for candidate in (current, *current.parents):
|
|
171
|
+
keys_dir = candidate / ".keys"
|
|
172
|
+
searched.append(keys_dir)
|
|
173
|
+
if keys_dir.is_dir():
|
|
174
|
+
secrets_file = _find_file(keys_dir, SECRETS_FILENAMES)
|
|
175
|
+
settings_file = _find_file(keys_dir, SETTINGS_FILENAMES)
|
|
176
|
+
if secrets_file or settings_file:
|
|
177
|
+
return DiscoveryResult(
|
|
178
|
+
keys_dir=keys_dir,
|
|
179
|
+
secrets_file=secrets_file,
|
|
180
|
+
settings_file=settings_file,
|
|
181
|
+
searched=tuple(searched),
|
|
182
|
+
source="search",
|
|
183
|
+
)
|
|
184
|
+
if any((candidate / marker).exists() for marker in _ROOT_MARKERS):
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
return DiscoveryResult(
|
|
188
|
+
keys_dir=None,
|
|
189
|
+
secrets_file=None,
|
|
190
|
+
settings_file=None,
|
|
191
|
+
searched=tuple(searched),
|
|
192
|
+
source="none",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_configured_keys_dir: Path | None = None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def configure(keys_dir: str | Path) -> None:
|
|
200
|
+
"""Programmatically pin the ``.keys/`` directory.
|
|
201
|
+
|
|
202
|
+
Must be called before the first ``settings`` access; raises RuntimeError
|
|
203
|
+
once settings are already loaded (env ``NEXUS_KEYS_DIR`` still wins over
|
|
204
|
+
this when both are set).
|
|
205
|
+
"""
|
|
206
|
+
global _configured_keys_dir
|
|
207
|
+
if settings._loaded:
|
|
208
|
+
raise RuntimeError(
|
|
209
|
+
"walrasquant settings are already loaded; call configure() "
|
|
210
|
+
"before the first settings access"
|
|
211
|
+
)
|
|
212
|
+
_configured_keys_dir = Path(keys_dir).expanduser().resolve()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _render_missing(path: tuple[str, ...], discovery: DiscoveryResult) -> str:
|
|
216
|
+
dotted = ".".join(path)
|
|
217
|
+
lines = [f"Setting '{dotted}' not found."]
|
|
218
|
+
|
|
219
|
+
if discovery.config_files:
|
|
220
|
+
loaded = ", ".join(str(f) for f in discovery.config_files)
|
|
221
|
+
lines.append(f"Loaded config: {loaded}")
|
|
222
|
+
else:
|
|
223
|
+
lines.append("No config file was found. Searched:")
|
|
224
|
+
lines.extend(f" {p}" for p in discovery.searched)
|
|
225
|
+
lines.append(f"(create one with 'wq init -e <SECTION>', or set {KEYS_DIR_ENV})")
|
|
226
|
+
|
|
227
|
+
secrets_target = discovery.secrets_file or (
|
|
228
|
+
discovery.keys_dir / ".secrets.toml"
|
|
229
|
+
if discovery.keys_dir
|
|
230
|
+
else Path(".keys/.secrets.toml")
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
leaf_is_field = path[-1].upper() in _CREDENTIAL_FIELD_NAMES
|
|
234
|
+
env_var = "NEXUS_" + "__".join(path)
|
|
235
|
+
|
|
236
|
+
if len(path) == 1 and not leaf_is_field:
|
|
237
|
+
if path[0] in EXCHANGE_CREDENTIALS:
|
|
238
|
+
spec = EXCHANGE_CREDENTIALS[path[0]]
|
|
239
|
+
lines.append(f"Fix - add a section to {secrets_target}:")
|
|
240
|
+
lines.extend(f" # {note}" for note in spec.notes)
|
|
241
|
+
lines.append(
|
|
242
|
+
f" [{path[0]}.<ACCOUNT>] # section path = path read in code"
|
|
243
|
+
)
|
|
244
|
+
lines.extend(f' {name} = "..."' for name in spec.fields)
|
|
245
|
+
else:
|
|
246
|
+
lines.append(
|
|
247
|
+
f"Fix - add '{path[0]} = ...' to {secrets_target} or set env: {env_var}"
|
|
248
|
+
)
|
|
249
|
+
return "\n".join(lines)
|
|
250
|
+
|
|
251
|
+
section = path[:-1] if leaf_is_field else path
|
|
252
|
+
spec = credential_spec_for(section[0])
|
|
253
|
+
lines.append(f"Fix - add to {secrets_target}:")
|
|
254
|
+
lines.extend(f" # {note}" for note in spec.notes)
|
|
255
|
+
lines.append(f" [{'.'.join(section)}]")
|
|
256
|
+
lines.extend(f' {name} = "..."' for name in spec.fields)
|
|
257
|
+
if not leaf_is_field:
|
|
258
|
+
lines.append(" # the section path must match the path your code reads")
|
|
259
|
+
lines.append(f"or set env: {env_var}")
|
|
260
|
+
return "\n".join(lines)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _wrap(value: Any, path: tuple[str, ...], root: "LazySettings") -> Any:
|
|
264
|
+
if isinstance(value, Mapping):
|
|
265
|
+
return _SettingsNode(value, path, root)
|
|
266
|
+
return value
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class _SettingsNode(Mapping):
|
|
270
|
+
"""Path-tracking view over a nested config mapping.
|
|
271
|
+
|
|
272
|
+
Behaves as a read-only Mapping; attribute access on a missing key raises
|
|
273
|
+
:class:`MissingSettingError` with the full dotted path and a fix snippet.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
__slots__ = ("_box", "_path", "_root")
|
|
277
|
+
|
|
278
|
+
def __init__(
|
|
279
|
+
self, box: Mapping, path: tuple[str, ...], root: "LazySettings"
|
|
280
|
+
) -> None:
|
|
281
|
+
object.__setattr__(self, "_box", box)
|
|
282
|
+
object.__setattr__(self, "_path", path)
|
|
283
|
+
object.__setattr__(self, "_root", root)
|
|
284
|
+
|
|
285
|
+
def __getattr__(self, name: str) -> Any:
|
|
286
|
+
try:
|
|
287
|
+
value = getattr(self._box, name)
|
|
288
|
+
except AttributeError:
|
|
289
|
+
raise MissingSettingError(
|
|
290
|
+
_render_missing((*self._path, name), self._root._discovery)
|
|
291
|
+
) from None
|
|
292
|
+
return _wrap(value, (*self._path, name), self._root)
|
|
293
|
+
|
|
294
|
+
def __getitem__(self, key: Any) -> Any:
|
|
295
|
+
value = self._box[key] # missing -> BoxKeyError (a KeyError), as Mapping
|
|
296
|
+
return _wrap(value, (*self._path, str(key)), self._root)
|
|
297
|
+
|
|
298
|
+
def __iter__(self) -> Iterator:
|
|
299
|
+
return iter(self._box)
|
|
300
|
+
|
|
301
|
+
def __len__(self) -> int:
|
|
302
|
+
return len(self._box)
|
|
303
|
+
|
|
304
|
+
def as_dict(self) -> dict:
|
|
305
|
+
return {key: value for key, value in self._box.items()}
|
|
306
|
+
|
|
307
|
+
def __repr__(self) -> str:
|
|
308
|
+
return f"<Settings {'.'.join(self._path)} {list(self._box)}>"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class LazySettings:
|
|
312
|
+
"""Dynaconf facade that loads on first access and never fails at import."""
|
|
313
|
+
|
|
314
|
+
def __init__(self) -> None:
|
|
315
|
+
self._lock = threading.Lock()
|
|
316
|
+
self._dynaconf: Dynaconf | None = None
|
|
317
|
+
self._discovery: DiscoveryResult = DiscoveryResult(
|
|
318
|
+
keys_dir=None,
|
|
319
|
+
secrets_file=None,
|
|
320
|
+
settings_file=None,
|
|
321
|
+
searched=(),
|
|
322
|
+
source="none",
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def _loaded(self) -> bool:
|
|
327
|
+
return self._dynaconf is not None
|
|
328
|
+
|
|
329
|
+
def _load(self) -> Dynaconf:
|
|
330
|
+
if self._dynaconf is None:
|
|
331
|
+
with self._lock:
|
|
332
|
+
if self._dynaconf is None:
|
|
333
|
+
discovery = discover()
|
|
334
|
+
self._discovery = discovery
|
|
335
|
+
self._dynaconf = Dynaconf(
|
|
336
|
+
envvar_prefix="NEXUS",
|
|
337
|
+
settings_files=[str(f) for f in discovery.config_files],
|
|
338
|
+
secrets=(
|
|
339
|
+
str(discovery.secrets_file)
|
|
340
|
+
if discovery.secrets_file
|
|
341
|
+
else None
|
|
342
|
+
),
|
|
343
|
+
environments=False,
|
|
344
|
+
load_dotenv=True,
|
|
345
|
+
)
|
|
346
|
+
return self._dynaconf
|
|
347
|
+
|
|
348
|
+
def _reset(self) -> None:
|
|
349
|
+
"""Drop the loaded state so the next access re-discovers (tests only)."""
|
|
350
|
+
global _configured_keys_dir
|
|
351
|
+
with self._lock:
|
|
352
|
+
self._dynaconf = None
|
|
353
|
+
_configured_keys_dir = None
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def discovery(self) -> DiscoveryResult:
|
|
357
|
+
"""Discovery outcome of the loaded config (loads on first use)."""
|
|
358
|
+
self._load()
|
|
359
|
+
return self._discovery
|
|
360
|
+
|
|
361
|
+
def __getattr__(self, name: str) -> Any:
|
|
362
|
+
dynaconf = self._load()
|
|
363
|
+
try:
|
|
364
|
+
value = getattr(dynaconf, name)
|
|
365
|
+
except AttributeError:
|
|
366
|
+
raise MissingSettingError(
|
|
367
|
+
_render_missing((name,), self._discovery)
|
|
368
|
+
) from None
|
|
369
|
+
return _wrap(value, (name,), self)
|
|
370
|
+
|
|
371
|
+
def __getitem__(self, key: Any) -> Any:
|
|
372
|
+
value = self._load()[key]
|
|
373
|
+
return _wrap(value, (str(key),), self)
|
|
374
|
+
|
|
375
|
+
def __contains__(self, key: Any) -> bool:
|
|
376
|
+
return key in self._load()
|
|
377
|
+
|
|
378
|
+
def __repr__(self) -> str:
|
|
379
|
+
if not self._loaded:
|
|
380
|
+
return "<LazySettings (not loaded)>"
|
|
381
|
+
return f"<LazySettings {self._discovery.source}: {self._discovery.keys_dir}>"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
settings = LazySettings()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def get_postgresql_config() -> dict:
|
|
388
|
+
"""Get PostgreSQL configuration from environment variables or settings files."""
|
|
389
|
+
return {
|
|
390
|
+
"host": settings.get("PG_HOST", "localhost"),
|
|
391
|
+
"port": settings.get("PG_PORT", 5432),
|
|
392
|
+
"user": settings.get("PG_USER", "postgres"),
|
|
393
|
+
"password": settings.get("PG_PASSWORD", ""),
|
|
394
|
+
"database": settings.get("PG_DATABASE", "postgres"),
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def get_redis_config() -> dict:
|
|
399
|
+
return {
|
|
400
|
+
"host": settings.REDIS_HOST,
|
|
401
|
+
"port": settings.REDIS_PORT,
|
|
402
|
+
"db": settings.REDIS_DB,
|
|
403
|
+
"password": settings.REDIS_PASSWORD,
|
|
404
|
+
}
|