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.
Files changed (117) hide show
  1. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/PKG-INFO +1 -1
  2. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/pyproject.toml +1 -1
  3. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/config.py +33 -1
  4. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/constants.py +6 -63
  5. walrasquant_lib-0.5.3/src/walrasquant/settings.py +404 -0
  6. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/tools/pm2_wrapper.py +297 -127
  7. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/README.md +0 -0
  8. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/__init__.py +0 -0
  9. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/aggregation.py +0 -0
  10. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/backends/__init__.py +0 -0
  11. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/backends/db.py +0 -0
  12. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/backends/db_memory.py +0 -0
  13. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/backends/db_postgresql.py +0 -0
  14. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/backends/db_sqlite.py +0 -0
  15. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/__init__.py +0 -0
  16. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/api_client.py +0 -0
  17. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/connector.py +0 -0
  18. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/ems.py +0 -0
  19. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/exchange.py +0 -0
  20. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/oms.py +0 -0
  21. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/retry.py +0 -0
  22. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/sms.py +0 -0
  23. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/base/ws_client.py +0 -0
  24. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/__init__.py +0 -0
  25. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/cache.py +0 -0
  26. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/clock.py +0 -0
  27. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/connection.py +0 -0
  28. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/entity.py +0 -0
  29. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/nautilius_core.py +0 -0
  30. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/core/registry.py +0 -0
  31. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/engine.py +0 -0
  32. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/error.py +0 -0
  33. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/__init__.py +0 -0
  34. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/base_factory.py +0 -0
  35. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/__init__.py +0 -0
  36. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/connector.py +0 -0
  37. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/constants.py +0 -0
  38. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/ems.py +0 -0
  39. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/error.py +0 -0
  40. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/exchange.py +0 -0
  41. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/factory.py +0 -0
  42. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/oms.py +0 -0
  43. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/rest_api.py +0 -0
  44. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/schema.py +0 -0
  45. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/binance/websockets.py +0 -0
  46. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/__init__.py +0 -0
  47. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/connector.py +0 -0
  48. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/constants.py +0 -0
  49. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/ems.py +0 -0
  50. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/error.py +0 -0
  51. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/exchange.py +0 -0
  52. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/factory.py +0 -0
  53. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/oms.py +0 -0
  54. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/rest_api.py +0 -0
  55. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/schema.py +0 -0
  56. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bitget/websockets.py +0 -0
  57. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/__init__.py +0 -0
  58. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/connector.py +0 -0
  59. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/constants.py +0 -0
  60. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/ems.py +0 -0
  61. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/error.py +0 -0
  62. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/exchange.py +0 -0
  63. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/factory.py +0 -0
  64. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/oms.py +0 -0
  65. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/rest_api.py +0 -0
  66. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/schema.py +0 -0
  67. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/bybit/websockets.py +0 -0
  68. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/__init__.py +0 -0
  69. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/connector.py +0 -0
  70. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/constants.py +0 -0
  71. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/ems.py +0 -0
  72. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/error.py +0 -0
  73. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/exchange.py +0 -0
  74. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/factory.py +0 -0
  75. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/oms.py +0 -0
  76. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/rest_api.py +0 -0
  77. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/schema.py +0 -0
  78. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/deribit/websockets.py +0 -0
  79. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/__init__.py +0 -0
  80. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/connector.py +0 -0
  81. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/constants.py +0 -0
  82. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/ems.py +0 -0
  83. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/error.py +0 -0
  84. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/exchange.py +0 -0
  85. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/factory.py +0 -0
  86. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/oms.py +0 -0
  87. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/rest_api.py +0 -0
  88. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/schema.py +0 -0
  89. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/signing.py +0 -0
  90. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/hyperliquid/websockets.py +0 -0
  91. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/__init__.py +0 -0
  92. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/connector.py +0 -0
  93. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/constants.py +0 -0
  94. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/ems.py +0 -0
  95. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/error.py +0 -0
  96. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/exchange.py +0 -0
  97. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/factory.py +0 -0
  98. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/oms.py +0 -0
  99. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/rest_api.py +0 -0
  100. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/schema.py +0 -0
  101. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/okx/websockets.py +0 -0
  102. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/exchange/registry.py +0 -0
  103. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/__init__.py +0 -0
  104. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/algorithm.py +0 -0
  105. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/algorithms/__init__.py +0 -0
  106. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/algorithms/twap.py +0 -0
  107. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/config.py +0 -0
  108. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/constants.py +0 -0
  109. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/execution/schema.py +0 -0
  110. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/indicator.py +0 -0
  111. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/push.py +0 -0
  112. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/schema.py +0 -0
  113. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/strategy.py +0 -0
  114. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/tools/__init__.py +0 -0
  115. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/web/__init__.py +0 -0
  116. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/web/app.py +0 -0
  117. {walrasquant_lib-0.5.2 → walrasquant_lib-0.5.3}/src/walrasquant/web/server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: walrasquant-lib
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: fastest python trading bot
5
5
  Author: River-Shi
6
6
  Author-email: River-Shi <nachuan.shi.quant@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "walrasquant-lib"
3
- version = "0.5.2"
3
+ version = "0.5.3"
4
4
  description = "fastest python trading bot"
5
5
  authors = [
6
6
  {name = "River-Shi",email = "nachuan.shi.quant@gmail.com"}
@@ -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
- BACKEND_LITERAL = Literal["memory", "redis"]
8
-
9
-
10
- def is_sphinx_build():
11
- return "sphinx" in sys.modules
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
+ }