walrasquant-lib 0.4.20__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.
- walrasquant/__init__.py +7 -0
- walrasquant/aggregation.py +449 -0
- walrasquant/backends/__init__.py +5 -0
- walrasquant/backends/db.py +109 -0
- walrasquant/backends/db_memory.py +61 -0
- walrasquant/backends/db_postgresql.py +321 -0
- walrasquant/backends/db_sqlite.py +310 -0
- walrasquant/base/__init__.py +24 -0
- walrasquant/base/api_client.py +46 -0
- walrasquant/base/connector.py +863 -0
- walrasquant/base/ems.py +794 -0
- walrasquant/base/exchange.py +213 -0
- walrasquant/base/oms.py +428 -0
- walrasquant/base/retry.py +220 -0
- walrasquant/base/sms.py +545 -0
- walrasquant/base/ws_client.py +408 -0
- walrasquant/config.py +284 -0
- walrasquant/constants.py +413 -0
- walrasquant/core/__init__.py +0 -0
- walrasquant/core/cache.py +688 -0
- walrasquant/core/clock.py +59 -0
- walrasquant/core/connection.py +41 -0
- walrasquant/core/entity.py +504 -0
- walrasquant/core/nautilius_core.py +103 -0
- walrasquant/core/registry.py +41 -0
- walrasquant/engine.py +745 -0
- walrasquant/error.py +34 -0
- walrasquant/exchange/__init__.py +13 -0
- walrasquant/exchange/base_factory.py +172 -0
- walrasquant/exchange/binance/__init__.py +30 -0
- walrasquant/exchange/binance/connector.py +1093 -0
- walrasquant/exchange/binance/constants.py +934 -0
- walrasquant/exchange/binance/ems.py +140 -0
- walrasquant/exchange/binance/error.py +48 -0
- walrasquant/exchange/binance/exchange.py +144 -0
- walrasquant/exchange/binance/factory.py +115 -0
- walrasquant/exchange/binance/oms.py +1807 -0
- walrasquant/exchange/binance/rest_api.py +1653 -0
- walrasquant/exchange/binance/schema.py +1063 -0
- walrasquant/exchange/binance/websockets.py +389 -0
- walrasquant/exchange/bitget/__init__.py +28 -0
- walrasquant/exchange/bitget/connector.py +578 -0
- walrasquant/exchange/bitget/constants.py +392 -0
- walrasquant/exchange/bitget/ems.py +202 -0
- walrasquant/exchange/bitget/error.py +36 -0
- walrasquant/exchange/bitget/exchange.py +128 -0
- walrasquant/exchange/bitget/factory.py +135 -0
- walrasquant/exchange/bitget/oms.py +1619 -0
- walrasquant/exchange/bitget/rest_api.py +610 -0
- walrasquant/exchange/bitget/schema.py +885 -0
- walrasquant/exchange/bitget/websockets.py +753 -0
- walrasquant/exchange/bybit/__init__.py +32 -0
- walrasquant/exchange/bybit/connector.py +819 -0
- walrasquant/exchange/bybit/constants.py +479 -0
- walrasquant/exchange/bybit/ems.py +93 -0
- walrasquant/exchange/bybit/error.py +36 -0
- walrasquant/exchange/bybit/exchange.py +108 -0
- walrasquant/exchange/bybit/factory.py +128 -0
- walrasquant/exchange/bybit/oms.py +1195 -0
- walrasquant/exchange/bybit/rest_api.py +570 -0
- walrasquant/exchange/bybit/schema.py +867 -0
- walrasquant/exchange/bybit/websockets.py +307 -0
- walrasquant/exchange/hyperliquid/__init__.py +28 -0
- walrasquant/exchange/hyperliquid/connector.py +370 -0
- walrasquant/exchange/hyperliquid/constants.py +371 -0
- walrasquant/exchange/hyperliquid/ems.py +156 -0
- walrasquant/exchange/hyperliquid/error.py +48 -0
- walrasquant/exchange/hyperliquid/exchange.py +120 -0
- walrasquant/exchange/hyperliquid/factory.py +135 -0
- walrasquant/exchange/hyperliquid/oms.py +1081 -0
- walrasquant/exchange/hyperliquid/rest_api.py +348 -0
- walrasquant/exchange/hyperliquid/schema.py +583 -0
- walrasquant/exchange/hyperliquid/websockets.py +592 -0
- walrasquant/exchange/okx/__init__.py +25 -0
- walrasquant/exchange/okx/connector.py +931 -0
- walrasquant/exchange/okx/constants.py +518 -0
- walrasquant/exchange/okx/ems.py +144 -0
- walrasquant/exchange/okx/error.py +66 -0
- walrasquant/exchange/okx/exchange.py +102 -0
- walrasquant/exchange/okx/factory.py +138 -0
- walrasquant/exchange/okx/oms.py +1199 -0
- walrasquant/exchange/okx/rest_api.py +799 -0
- walrasquant/exchange/okx/schema.py +1449 -0
- walrasquant/exchange/okx/websockets.py +420 -0
- walrasquant/exchange/registry.py +201 -0
- walrasquant/execution/__init__.py +24 -0
- walrasquant/execution/algorithm.py +968 -0
- walrasquant/execution/algorithms/__init__.py +3 -0
- walrasquant/execution/algorithms/twap.py +392 -0
- walrasquant/execution/config.py +34 -0
- walrasquant/execution/constants.py +27 -0
- walrasquant/execution/schema.py +62 -0
- walrasquant/indicator.py +382 -0
- walrasquant/push.py +77 -0
- walrasquant/schema.py +755 -0
- walrasquant/strategy.py +1805 -0
- walrasquant/tools/__init__.py +0 -0
- walrasquant/tools/pm2_wrapper.py +1016 -0
- walrasquant/web/__init__.py +26 -0
- walrasquant/web/app.py +157 -0
- walrasquant/web/server.py +92 -0
- walrasquant_lib-0.4.20.dist-info/METADATA +162 -0
- walrasquant_lib-0.4.20.dist-info/RECORD +105 -0
- walrasquant_lib-0.4.20.dist-info/WHEEL +4 -0
- walrasquant_lib-0.4.20.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import date, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich import box
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _pm2_jlist() -> list[dict]:
|
|
18
|
+
"""Run pm2 jlist and return parsed JSON."""
|
|
19
|
+
try:
|
|
20
|
+
result = subprocess.run(
|
|
21
|
+
["pm2", "jlist"],
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
check=True,
|
|
25
|
+
)
|
|
26
|
+
return json.loads(result.stdout)
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
click.echo(
|
|
29
|
+
"Error: pm2 not found. Install it with: npm install -g pm2", err=True
|
|
30
|
+
)
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
except subprocess.CalledProcessError as e:
|
|
33
|
+
click.echo(f"Error running pm2 jlist: {e.stderr}", err=True)
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
except json.JSONDecodeError as e:
|
|
36
|
+
click.echo(f"Error parsing pm2 jlist output: {e}", err=True)
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _find_process(processes: list[dict], name: str) -> dict | None:
|
|
41
|
+
"""Find a PM2 process by name or numeric pm_id."""
|
|
42
|
+
# Numeric ID lookup
|
|
43
|
+
if name.isdigit():
|
|
44
|
+
pm_id = int(name)
|
|
45
|
+
for proc in processes:
|
|
46
|
+
if proc.get("pm_id") == pm_id:
|
|
47
|
+
return proc
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
matches = [p for p in processes if p.get("name") == name]
|
|
51
|
+
if not matches:
|
|
52
|
+
return None
|
|
53
|
+
if len(matches) == 1:
|
|
54
|
+
return matches[0]
|
|
55
|
+
|
|
56
|
+
# Multiple matches: prefer online
|
|
57
|
+
online = [p for p in matches if p.get("pm2_env", {}).get("status") == "online"]
|
|
58
|
+
return online[0] if online else matches[0]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_script_target(target: str) -> bool:
|
|
62
|
+
"""Return True when a CLI target should be treated as a script path."""
|
|
63
|
+
path = Path(target).expanduser()
|
|
64
|
+
if path.exists():
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
if target.endswith(".py"):
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
path_markers = (os.sep, "/", "\\", ".", "~")
|
|
71
|
+
return target.startswith(path_markers) or (
|
|
72
|
+
os.altsep is not None and os.altsep in target
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_settings_attr(attr_path: str):
|
|
77
|
+
"""Traverse a dotted attribute path on the walrasquant settings object."""
|
|
78
|
+
from walrasquant.constants import settings
|
|
79
|
+
|
|
80
|
+
obj = settings
|
|
81
|
+
for part in attr_path.split("."):
|
|
82
|
+
obj = getattr(obj, part)
|
|
83
|
+
return obj
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _extract_log_filename(script_path: str) -> str | None:
|
|
87
|
+
"""Extract LogConfig filename from a Python script via regex."""
|
|
88
|
+
try:
|
|
89
|
+
src = Path(script_path).read_text(encoding="utf-8", errors="ignore")
|
|
90
|
+
match = re.search(
|
|
91
|
+
r'LogConfig\s*\(.*?filename\s*=\s*["\']([^"\']+)["\']',
|
|
92
|
+
src,
|
|
93
|
+
re.DOTALL,
|
|
94
|
+
)
|
|
95
|
+
if match:
|
|
96
|
+
return match.group(1)
|
|
97
|
+
match = re.search(
|
|
98
|
+
r"LogConfig\s*\(.*?filename\s*=\s*settings\.([\w.]+)",
|
|
99
|
+
src,
|
|
100
|
+
re.DOTALL,
|
|
101
|
+
)
|
|
102
|
+
if match:
|
|
103
|
+
return str(_resolve_settings_attr(match.group(1)))
|
|
104
|
+
except (OSError, PermissionError):
|
|
105
|
+
pass
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _resolve_log_base(proc: dict) -> Path | None:
|
|
110
|
+
"""
|
|
111
|
+
Resolve the base log path (without date suffix) for a PM2 process.
|
|
112
|
+
|
|
113
|
+
Returns a Path like /path/to/cwd/logs/app (without extension),
|
|
114
|
+
or None if it cannot be determined.
|
|
115
|
+
"""
|
|
116
|
+
pm2_env = proc.get("pm2_env", {})
|
|
117
|
+
cwd = pm2_env.get("pm_cwd") or pm2_env.get("pm2_cwd") or ""
|
|
118
|
+
script_path = pm2_env.get("pm_exec_path", "")
|
|
119
|
+
|
|
120
|
+
cwd_path = Path(cwd) if cwd else Path.cwd()
|
|
121
|
+
|
|
122
|
+
# Try extracting from script source
|
|
123
|
+
if script_path and Path(script_path).exists():
|
|
124
|
+
filename = _extract_log_filename(script_path)
|
|
125
|
+
if filename:
|
|
126
|
+
log_path = Path(filename)
|
|
127
|
+
if not log_path.is_absolute():
|
|
128
|
+
log_path = cwd_path / log_path
|
|
129
|
+
# Strip extension to get base (e.g. logs/app from logs/app.log)
|
|
130
|
+
return log_path.with_suffix("")
|
|
131
|
+
|
|
132
|
+
# Fallback: scan cwd/logs/ for *{name}*.log
|
|
133
|
+
name = proc.get("name", "")
|
|
134
|
+
logs_dir = cwd_path / "logs"
|
|
135
|
+
if logs_dir.is_dir():
|
|
136
|
+
candidates = sorted(logs_dir.glob(f"{name}_????????.log"))
|
|
137
|
+
if candidates:
|
|
138
|
+
# Take stem of the latest file, strip date suffix (_YYYYMMDD)
|
|
139
|
+
stem = candidates[-1].stem
|
|
140
|
+
stem = re.sub(r"_\d{8}$", "", stem)
|
|
141
|
+
return logs_dir / stem
|
|
142
|
+
|
|
143
|
+
# Last resort: derive default path from process name (strategy_id.user_id)
|
|
144
|
+
# mirrors Config.__post_init__: logs/{strategy_id}_{user_id}.log
|
|
145
|
+
if name:
|
|
146
|
+
strategy_id, _, user_id = name.partition(".")
|
|
147
|
+
stem = f"{strategy_id}_{user_id}" if user_id else strategy_id
|
|
148
|
+
return cwd_path / "logs" / stem
|
|
149
|
+
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _date_rotated_files(log_base: Path, days: int) -> list[Path]:
|
|
154
|
+
"""
|
|
155
|
+
Find date-rotated log files matching {log_base}_YYYYMMDD.log
|
|
156
|
+
within the last `days` days (counting today).
|
|
157
|
+
"""
|
|
158
|
+
today = date.today()
|
|
159
|
+
cutoff = today - timedelta(days=days - 1)
|
|
160
|
+
|
|
161
|
+
pattern = f"{log_base.name}_*.log"
|
|
162
|
+
candidates = sorted(log_base.parent.glob(pattern))
|
|
163
|
+
|
|
164
|
+
result = []
|
|
165
|
+
for path in candidates:
|
|
166
|
+
m = re.search(r"_(\d{8})\.log$", path.name)
|
|
167
|
+
if m:
|
|
168
|
+
try:
|
|
169
|
+
file_date = date(
|
|
170
|
+
int(m.group(1)[:4]),
|
|
171
|
+
int(m.group(1)[4:6]),
|
|
172
|
+
int(m.group(1)[6:8]),
|
|
173
|
+
)
|
|
174
|
+
if cutoff <= file_date <= today:
|
|
175
|
+
result.append(path)
|
|
176
|
+
except ValueError:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _proc_log_summary(proc: dict) -> str:
|
|
183
|
+
"""Return a short log path string for display in list."""
|
|
184
|
+
log_base = _resolve_log_base(proc)
|
|
185
|
+
if log_base is None:
|
|
186
|
+
return "<unknown>"
|
|
187
|
+
today_str = date.today().strftime("%Y%m%d")
|
|
188
|
+
candidate = log_base.parent / f"{log_base.name}_{today_str}.log"
|
|
189
|
+
if candidate.exists():
|
|
190
|
+
return str(candidate)
|
|
191
|
+
# Show pattern if today's file doesn't exist yet
|
|
192
|
+
return str(log_base.parent / f"{log_base.name}_*.log")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# CLI
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@click.group()
|
|
201
|
+
def cli():
|
|
202
|
+
"""wq: PM2 + hl wrapper for WalrasQuant strategies."""
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _extract_config_field(script_path: str, field: str) -> str | None:
|
|
207
|
+
"""Extract a string field from Config(...) in a Python script via regex.
|
|
208
|
+
|
|
209
|
+
Tries string literals first, then falls back to settings.<path>.<field>.
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
src = Path(script_path).read_text()
|
|
213
|
+
match = re.search(
|
|
214
|
+
rf'Config\s*\(.*?{field}\s*=\s*["\']([^"\']+)["\']',
|
|
215
|
+
src,
|
|
216
|
+
re.DOTALL,
|
|
217
|
+
)
|
|
218
|
+
if match:
|
|
219
|
+
return match.group(1)
|
|
220
|
+
match = re.search(
|
|
221
|
+
rf"Config\s*\(.*?{field}\s*=\s*settings\.([\w.]+)",
|
|
222
|
+
src,
|
|
223
|
+
re.DOTALL,
|
|
224
|
+
)
|
|
225
|
+
if match:
|
|
226
|
+
return str(_resolve_settings_attr(match.group(1)))
|
|
227
|
+
except (OSError, PermissionError):
|
|
228
|
+
pass
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@cli.command("start")
|
|
233
|
+
@click.argument("targets", nargs=-1, required=True)
|
|
234
|
+
@click.option(
|
|
235
|
+
"--strategy-id",
|
|
236
|
+
"-s",
|
|
237
|
+
default=None,
|
|
238
|
+
help="Strategy identifier (extracted from script if omitted)",
|
|
239
|
+
)
|
|
240
|
+
@click.option(
|
|
241
|
+
"--user-id",
|
|
242
|
+
"-u",
|
|
243
|
+
default=None,
|
|
244
|
+
help="User identifier (extracted from script if omitted)",
|
|
245
|
+
)
|
|
246
|
+
def start_cmd(targets: tuple[str, ...], strategy_id: str | None, user_id: str | None):
|
|
247
|
+
"""Start PM2 processes or create a strategy process from a script.
|
|
248
|
+
|
|
249
|
+
When passed a script path, the PM2 process name will be set to
|
|
250
|
+
STRATEGY_ID.USER_ID. If --strategy-id or --user-id are omitted they are
|
|
251
|
+
extracted from the Config(...) definition inside the script.
|
|
252
|
+
|
|
253
|
+
When passed one or more PM2 process IDs or names, this forwards them to
|
|
254
|
+
``pm2 start`` so existing processes can be started in bulk.
|
|
255
|
+
|
|
256
|
+
Examples:\n
|
|
257
|
+
wq start strategy/buy_and_sell.py\n
|
|
258
|
+
wq start strategy/buy_and_sell.py -s buy_and_sell -u user_test\n
|
|
259
|
+
wq start 0 1 2\n
|
|
260
|
+
wq start buy_and_sell.alice mean_revert.bob\n
|
|
261
|
+
wq start /abs/path/to/strategy.py --strategy-id arb --user-id alice
|
|
262
|
+
"""
|
|
263
|
+
if (strategy_id is not None or user_id is not None) and len(targets) != 1:
|
|
264
|
+
raise click.UsageError(
|
|
265
|
+
"--strategy-id/--user-id can only be used when starting from a single script path."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if len(targets) > 1 or not _is_script_target(targets[0]):
|
|
269
|
+
result = subprocess.run(["pm2", "start", *targets])
|
|
270
|
+
sys.exit(result.returncode)
|
|
271
|
+
|
|
272
|
+
script = targets[0]
|
|
273
|
+
script_path = Path(script).resolve()
|
|
274
|
+
if not script_path.exists():
|
|
275
|
+
click.echo(f"Error: script '{script}' not found.", err=True)
|
|
276
|
+
sys.exit(1)
|
|
277
|
+
|
|
278
|
+
if strategy_id is None:
|
|
279
|
+
strategy_id = _extract_config_field(str(script_path), "strategy_id")
|
|
280
|
+
if strategy_id is None:
|
|
281
|
+
click.echo(
|
|
282
|
+
"Error: could not extract strategy_id from script. Pass it explicitly with -s.",
|
|
283
|
+
err=True,
|
|
284
|
+
)
|
|
285
|
+
sys.exit(1)
|
|
286
|
+
|
|
287
|
+
if user_id is None:
|
|
288
|
+
user_id = _extract_config_field(str(script_path), "user_id")
|
|
289
|
+
if user_id is None:
|
|
290
|
+
click.echo(
|
|
291
|
+
"Error: could not extract user_id from script. Pass it explicitly with -u.",
|
|
292
|
+
err=True,
|
|
293
|
+
)
|
|
294
|
+
sys.exit(1)
|
|
295
|
+
|
|
296
|
+
name = f"{strategy_id}.{user_id}"
|
|
297
|
+
|
|
298
|
+
# Duplicate check
|
|
299
|
+
processes = _pm2_jlist()
|
|
300
|
+
if any(p.get("name") == name for p in processes):
|
|
301
|
+
status = next(
|
|
302
|
+
p.get("pm2_env", {}).get("status", "unknown")
|
|
303
|
+
for p in processes
|
|
304
|
+
if p.get("name") == name
|
|
305
|
+
)
|
|
306
|
+
click.echo(
|
|
307
|
+
f"Error: PM2 process '{name}' already exists (status: {status}). "
|
|
308
|
+
"Use 'wq restart' or 'wq delete' first.",
|
|
309
|
+
err=True,
|
|
310
|
+
)
|
|
311
|
+
sys.exit(1)
|
|
312
|
+
|
|
313
|
+
cmd = ["pm2", "start", str(script_path), "--name", name]
|
|
314
|
+
result = subprocess.run(cmd)
|
|
315
|
+
sys.exit(result.returncode)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@cli.command("list")
|
|
319
|
+
def list_cmd():
|
|
320
|
+
"""List PM2 processes with resolved log file paths."""
|
|
321
|
+
processes = _pm2_jlist()
|
|
322
|
+
if not processes:
|
|
323
|
+
click.echo("No WalrasQuant PM2 processes found.")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
wq_procs = [(proc, _resolve_log_base(proc)) for proc in processes]
|
|
327
|
+
wq_procs = [(proc, lb) for proc, lb in wq_procs if lb is not None]
|
|
328
|
+
|
|
329
|
+
if not wq_procs:
|
|
330
|
+
click.echo("No WalrasQuant PM2 processes found.")
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
table = Table(box=box.ROUNDED, show_header=True, header_style="bold cyan")
|
|
334
|
+
table.add_column("ID", style="dim", width=5)
|
|
335
|
+
table.add_column("Strategy ID", style="bold")
|
|
336
|
+
table.add_column("User ID", style="bold")
|
|
337
|
+
table.add_column("Status", width=10)
|
|
338
|
+
table.add_column("PID", width=8)
|
|
339
|
+
table.add_column("Log File", style="dim")
|
|
340
|
+
|
|
341
|
+
for proc, _ in wq_procs:
|
|
342
|
+
pm_id = str(proc.get("pm_id", "-"))
|
|
343
|
+
name = proc.get("name", "")
|
|
344
|
+
strategy_id, _, user_id = name.partition(".")
|
|
345
|
+
pm2_env = proc.get("pm2_env", {})
|
|
346
|
+
status = pm2_env.get("status", "unknown")
|
|
347
|
+
pid = str(proc.get("pid", "-"))
|
|
348
|
+
log_file = _proc_log_summary(proc)
|
|
349
|
+
|
|
350
|
+
if status == "online":
|
|
351
|
+
status_str = f"[green]{status}[/green]"
|
|
352
|
+
elif status == "errored":
|
|
353
|
+
status_str = f"[red]{status}[/red]"
|
|
354
|
+
else:
|
|
355
|
+
status_str = f"[yellow]{status}[/yellow]"
|
|
356
|
+
|
|
357
|
+
table.add_row(pm_id, strategy_id, user_id or "-", status_str, pid, log_file)
|
|
358
|
+
|
|
359
|
+
console.print(table)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@cli.command(
|
|
363
|
+
"logs", context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
|
|
364
|
+
)
|
|
365
|
+
@click.argument("name")
|
|
366
|
+
@click.option(
|
|
367
|
+
"-F",
|
|
368
|
+
"--follow",
|
|
369
|
+
is_flag=True,
|
|
370
|
+
help="Live-tail mode (passes -F to hl, disables pager)",
|
|
371
|
+
)
|
|
372
|
+
@click.option(
|
|
373
|
+
"-d",
|
|
374
|
+
"--days",
|
|
375
|
+
default=1,
|
|
376
|
+
show_default=True,
|
|
377
|
+
help="Include last N days of rotated files",
|
|
378
|
+
)
|
|
379
|
+
@click.pass_context
|
|
380
|
+
def logs_cmd(ctx: click.Context, name: str, follow: bool, days: int):
|
|
381
|
+
"""View logs for a PM2 process using hl.
|
|
382
|
+
|
|
383
|
+
All unrecognised options are forwarded verbatim to hl.
|
|
384
|
+
|
|
385
|
+
Examples:\n
|
|
386
|
+
wq logs buy_and_sell\n
|
|
387
|
+
wq logs buy_and_sell -F\n
|
|
388
|
+
wq logs buy_and_sell -d 3\n
|
|
389
|
+
wq logs buy_and_sell -l e\n
|
|
390
|
+
wq logs buy_and_sell -q 'level > info'\n
|
|
391
|
+
wq logs buy_and_sell --since -1h --until now\n
|
|
392
|
+
wq logs buy_and_sell -f component=tsdb
|
|
393
|
+
"""
|
|
394
|
+
processes = _pm2_jlist()
|
|
395
|
+
proc = _find_process(processes, name)
|
|
396
|
+
if proc is None:
|
|
397
|
+
click.echo(f"Error: PM2 process '{name}' not found.", err=True)
|
|
398
|
+
click.echo(f"Available: {[p.get('name') for p in processes]}", err=True)
|
|
399
|
+
sys.exit(1)
|
|
400
|
+
|
|
401
|
+
log_base = _resolve_log_base(proc)
|
|
402
|
+
if log_base is None:
|
|
403
|
+
click.echo(
|
|
404
|
+
f"Error: Could not resolve log path for '{name}'.\n"
|
|
405
|
+
"Make sure the script contains LogConfig(filename=...) or has logs in cwd/logs/.",
|
|
406
|
+
err=True,
|
|
407
|
+
)
|
|
408
|
+
sys.exit(1)
|
|
409
|
+
|
|
410
|
+
log_files = _date_rotated_files(log_base, days)
|
|
411
|
+
if not log_files:
|
|
412
|
+
click.echo(
|
|
413
|
+
f"No log files found matching '{log_base}_*.log' within the last {days} day(s).",
|
|
414
|
+
err=True,
|
|
415
|
+
)
|
|
416
|
+
sys.exit(1)
|
|
417
|
+
|
|
418
|
+
hl_cmd: list[str] = ["hl"]
|
|
419
|
+
|
|
420
|
+
if follow:
|
|
421
|
+
hl_cmd.append("-F")
|
|
422
|
+
elif days > 1:
|
|
423
|
+
hl_cmd.append("-s")
|
|
424
|
+
|
|
425
|
+
# Forward all unrecognised args/options directly to hl
|
|
426
|
+
hl_cmd.extend(ctx.args)
|
|
427
|
+
|
|
428
|
+
hl_cmd.extend(str(f) for f in log_files)
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
os.execvp("hl", hl_cmd)
|
|
432
|
+
except FileNotFoundError:
|
|
433
|
+
click.echo(
|
|
434
|
+
"Error: hl not found. Install it from: https://github.com/pamburus/hl",
|
|
435
|
+
err=True,
|
|
436
|
+
)
|
|
437
|
+
sys.exit(1)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@cli.command("flush")
|
|
441
|
+
@click.argument("name")
|
|
442
|
+
@click.option(
|
|
443
|
+
"--yes",
|
|
444
|
+
"-y",
|
|
445
|
+
is_flag=True,
|
|
446
|
+
help="Skip confirmation prompt.",
|
|
447
|
+
)
|
|
448
|
+
def flush_cmd(name: str, yes: bool):
|
|
449
|
+
"""Flush strategy log files for a PM2 process, or remove all logs.
|
|
450
|
+
|
|
451
|
+
NAME can be a PM2 process ID, a process name (STRATEGY_ID.USER_ID),
|
|
452
|
+
or the special keyword 'all' to delete every strategy log file.
|
|
453
|
+
|
|
454
|
+
This operates on WalrasQuant strategy log files, not PM2 logs.
|
|
455
|
+
|
|
456
|
+
Examples:\n
|
|
457
|
+
wq flush buy_and_sell.alice\n
|
|
458
|
+
wq flush 3\n
|
|
459
|
+
wq flush all\n
|
|
460
|
+
wq flush all --yes
|
|
461
|
+
"""
|
|
462
|
+
processes = _pm2_jlist()
|
|
463
|
+
|
|
464
|
+
if name == "all":
|
|
465
|
+
# Collect log files from every known PM2 process
|
|
466
|
+
log_files: list[Path] = []
|
|
467
|
+
for proc in processes:
|
|
468
|
+
log_base = _resolve_log_base(proc)
|
|
469
|
+
if log_base is None:
|
|
470
|
+
continue
|
|
471
|
+
files = sorted(log_base.parent.glob(f"{log_base.name}_????????.log"))
|
|
472
|
+
base_log = log_base.with_suffix(".log")
|
|
473
|
+
if base_log.exists() and base_log not in files:
|
|
474
|
+
files.insert(0, base_log)
|
|
475
|
+
log_files.extend(files)
|
|
476
|
+
|
|
477
|
+
if not log_files:
|
|
478
|
+
click.echo("No strategy log files found.")
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
click.echo(f"Log files to remove ({len(log_files)} total):")
|
|
482
|
+
for f in log_files:
|
|
483
|
+
size = f.stat().st_size if f.exists() else 0
|
|
484
|
+
click.echo(f" {f} ({size:,} bytes)")
|
|
485
|
+
|
|
486
|
+
if not yes:
|
|
487
|
+
click.confirm("\nDelete all listed files?", abort=True)
|
|
488
|
+
|
|
489
|
+
removed, errors = 0, 0
|
|
490
|
+
for f in log_files:
|
|
491
|
+
try:
|
|
492
|
+
f.unlink()
|
|
493
|
+
removed += 1
|
|
494
|
+
except OSError as exc:
|
|
495
|
+
click.echo(f"Error removing {f}: {exc}", err=True)
|
|
496
|
+
errors += 1
|
|
497
|
+
|
|
498
|
+
click.echo(
|
|
499
|
+
f"Removed {removed} file(s)." + (f" {errors} error(s)." if errors else "")
|
|
500
|
+
)
|
|
501
|
+
if errors:
|
|
502
|
+
sys.exit(1)
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
proc = _find_process(processes, name)
|
|
506
|
+
if proc is None:
|
|
507
|
+
click.echo(f"Error: PM2 process '{name}' not found.", err=True)
|
|
508
|
+
click.echo(f"Available: {[p.get('name') for p in processes]}", err=True)
|
|
509
|
+
sys.exit(1)
|
|
510
|
+
|
|
511
|
+
log_base = _resolve_log_base(proc)
|
|
512
|
+
if log_base is None:
|
|
513
|
+
click.echo(
|
|
514
|
+
f"Error: Could not resolve log path for '{name}'.\n"
|
|
515
|
+
"Make sure the script contains LogConfig(filename=...) or has logs in cwd/logs/.",
|
|
516
|
+
err=True,
|
|
517
|
+
)
|
|
518
|
+
sys.exit(1)
|
|
519
|
+
|
|
520
|
+
# Find all rotated log files (any date); use exact 8-digit date pattern to avoid
|
|
521
|
+
# matching files whose names share a common prefix (e.g. "app" matching "app_usdt_*")
|
|
522
|
+
pattern = f"{log_base.name}_????????.log"
|
|
523
|
+
log_files = sorted(log_base.parent.glob(pattern))
|
|
524
|
+
# Also include the base log file without date suffix if it exists
|
|
525
|
+
base_log = log_base.with_suffix(".log")
|
|
526
|
+
if base_log.exists() and base_log not in log_files:
|
|
527
|
+
log_files.insert(0, base_log)
|
|
528
|
+
|
|
529
|
+
if not log_files:
|
|
530
|
+
click.echo(f"No log files found matching '{log_base}*.log'.")
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
proc_name = proc.get("name", name)
|
|
534
|
+
click.echo(f"Log files to flush for '{proc_name}':")
|
|
535
|
+
for f in log_files:
|
|
536
|
+
size = f.stat().st_size if f.exists() else 0
|
|
537
|
+
click.echo(f" {f} ({size:,} bytes)")
|
|
538
|
+
|
|
539
|
+
if not yes:
|
|
540
|
+
click.confirm("\nTruncate all listed files?", abort=True)
|
|
541
|
+
|
|
542
|
+
flushed, errors = 0, 0
|
|
543
|
+
for f in log_files:
|
|
544
|
+
try:
|
|
545
|
+
f.write_bytes(b"")
|
|
546
|
+
flushed += 1
|
|
547
|
+
except OSError as exc:
|
|
548
|
+
click.echo(f"Error flushing {f}: {exc}", err=True)
|
|
549
|
+
errors += 1
|
|
550
|
+
|
|
551
|
+
click.echo(
|
|
552
|
+
f"Flushed {flushed} file(s)." + (f" {errors} error(s)." if errors else "")
|
|
553
|
+
)
|
|
554
|
+
if errors:
|
|
555
|
+
sys.exit(1)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _detect_backend(script_path: str) -> str:
|
|
559
|
+
"""Return 'postgresql' if the script uses PostgreSQL storage, else 'sqlite'."""
|
|
560
|
+
try:
|
|
561
|
+
src = Path(script_path).read_text(encoding="utf-8", errors="ignore")
|
|
562
|
+
if re.search(
|
|
563
|
+
r"StorageType\.POSTGRESQL|storage_backend.*POSTGRESQL", src, re.IGNORECASE
|
|
564
|
+
):
|
|
565
|
+
return "postgresql"
|
|
566
|
+
except (OSError, PermissionError):
|
|
567
|
+
pass
|
|
568
|
+
return "sqlite"
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _resolve_db(proc: dict) -> tuple[str, str, str]:
|
|
572
|
+
"""Return (backend_type, db_path_or_empty, table_prefix) for a PM2 process."""
|
|
573
|
+
pm2_env = proc.get("pm2_env", {})
|
|
574
|
+
name = proc.get("name", "")
|
|
575
|
+
strategy_id, _, user_id = name.partition(".")
|
|
576
|
+
table_prefix = re.sub(r"[^a-zA-Z0-9_]", "_", f"{strategy_id}_{user_id}").lower()
|
|
577
|
+
|
|
578
|
+
script_path = pm2_env.get("pm_exec_path", "")
|
|
579
|
+
cwd = pm2_env.get("pm_cwd") or pm2_env.get("pm2_cwd") or ""
|
|
580
|
+
cwd_path = Path(cwd) if cwd else Path.cwd()
|
|
581
|
+
|
|
582
|
+
backend = _detect_backend(script_path)
|
|
583
|
+
|
|
584
|
+
if backend == "sqlite":
|
|
585
|
+
db_path_str = _extract_config_field(script_path, "db_path") or ".keys/cache.db"
|
|
586
|
+
db_path = Path(db_path_str)
|
|
587
|
+
if not db_path.is_absolute():
|
|
588
|
+
db_path = cwd_path / db_path
|
|
589
|
+
return "sqlite", str(db_path), table_prefix
|
|
590
|
+
|
|
591
|
+
return "postgresql", "", table_prefix
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _query_db(
|
|
595
|
+
backend: str, db_path: str, table_prefix: str, query: str, params: tuple = ()
|
|
596
|
+
):
|
|
597
|
+
"""Execute a SELECT and return rows."""
|
|
598
|
+
if backend == "sqlite":
|
|
599
|
+
import sqlite3
|
|
600
|
+
|
|
601
|
+
conn = sqlite3.connect(db_path)
|
|
602
|
+
try:
|
|
603
|
+
cur = conn.execute(query, params)
|
|
604
|
+
return cur.fetchall()
|
|
605
|
+
finally:
|
|
606
|
+
conn.close()
|
|
607
|
+
else:
|
|
608
|
+
import psycopg2
|
|
609
|
+
from walrasquant.constants import get_postgresql_config
|
|
610
|
+
|
|
611
|
+
conn = psycopg2.connect(**get_postgresql_config())
|
|
612
|
+
try:
|
|
613
|
+
cur = conn.cursor()
|
|
614
|
+
cur.execute(query, params)
|
|
615
|
+
return cur.fetchall()
|
|
616
|
+
finally:
|
|
617
|
+
conn.close()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
_POS_SORT_FIELDS = ("exchange", "symbol", "side", "amount")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@cli.command("pos")
|
|
624
|
+
@click.argument("name")
|
|
625
|
+
@click.option(
|
|
626
|
+
"--sort",
|
|
627
|
+
"-s",
|
|
628
|
+
"sort_by",
|
|
629
|
+
type=click.Choice(_POS_SORT_FIELDS, case_sensitive=False),
|
|
630
|
+
multiple=True,
|
|
631
|
+
help="Sort field(s). Repeat to sort by multiple fields. Default: exchange, symbol.",
|
|
632
|
+
)
|
|
633
|
+
def pos_cmd(name: str, sort_by: tuple):
|
|
634
|
+
"""Show open positions for a strategy process.
|
|
635
|
+
|
|
636
|
+
NAME can be a PM2 process name (STRATEGY_ID.USER_ID) or numeric PM2 id.
|
|
637
|
+
|
|
638
|
+
Examples:\n
|
|
639
|
+
wq pos buy_and_sell.alice\n
|
|
640
|
+
wq pos 0\n
|
|
641
|
+
wq pos 0 --sort symbol\n
|
|
642
|
+
wq pos 0 -s exchange -s symbol
|
|
643
|
+
"""
|
|
644
|
+
import json as _json
|
|
645
|
+
|
|
646
|
+
processes = _pm2_jlist()
|
|
647
|
+
proc = _find_process(processes, name)
|
|
648
|
+
if proc is None:
|
|
649
|
+
click.echo(f"Error: PM2 process '{name}' not found.", err=True)
|
|
650
|
+
click.echo(f"Available: {[p.get('name') for p in processes]}", err=True)
|
|
651
|
+
sys.exit(1)
|
|
652
|
+
|
|
653
|
+
backend, db_path, table_prefix = _resolve_db(proc)
|
|
654
|
+
|
|
655
|
+
if backend == "sqlite" and not Path(db_path).exists():
|
|
656
|
+
click.echo(f"Error: SQLite database not found: {db_path}", err=True)
|
|
657
|
+
sys.exit(1)
|
|
658
|
+
|
|
659
|
+
query = f"SELECT symbol, exchange, side, amount, data FROM {table_prefix}_positions"
|
|
660
|
+
try:
|
|
661
|
+
rows = _query_db(backend, db_path, table_prefix, query)
|
|
662
|
+
except Exception as e:
|
|
663
|
+
if "no such table" in str(e).lower() or "does not exist" in str(e).lower():
|
|
664
|
+
click.echo("No open positions found.")
|
|
665
|
+
return
|
|
666
|
+
click.echo(f"Error querying positions: {e}", err=True)
|
|
667
|
+
sys.exit(1)
|
|
668
|
+
|
|
669
|
+
# Filter out flat/null positions
|
|
670
|
+
open_rows = [
|
|
671
|
+
r for r in rows if r[2] and r[2].upper() not in ("FLAT", "NONE", "NULL")
|
|
672
|
+
]
|
|
673
|
+
if not open_rows:
|
|
674
|
+
click.echo("No open positions found.")
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
# Sort: columns are (symbol=0, exchange=1, side=2, amount=3, data=4)
|
|
678
|
+
_field_idx = {"symbol": 0, "exchange": 1, "side": 2, "amount": 3}
|
|
679
|
+
effective_sort = list(sort_by) if sort_by else ["exchange", "symbol"]
|
|
680
|
+
open_rows.sort(
|
|
681
|
+
key=lambda r: tuple((r[_field_idx[f]] or "").lower() for f in effective_sort)
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
table = Table(box=box.ROUNDED, show_header=True, header_style="bold cyan")
|
|
685
|
+
table.add_column("Symbol", style="bold")
|
|
686
|
+
table.add_column("Exchange")
|
|
687
|
+
table.add_column("Side", width=6)
|
|
688
|
+
table.add_column("Amount", justify="right")
|
|
689
|
+
table.add_column("Entry Price", justify="right")
|
|
690
|
+
table.add_column("Unrealized PnL", justify="right")
|
|
691
|
+
table.add_column("Realized PnL", justify="right")
|
|
692
|
+
|
|
693
|
+
for symbol, exchange, side, amount, data in open_rows:
|
|
694
|
+
try:
|
|
695
|
+
if isinstance(data, memoryview):
|
|
696
|
+
raw = bytes(data)
|
|
697
|
+
elif isinstance(data, str):
|
|
698
|
+
raw = data.encode()
|
|
699
|
+
else:
|
|
700
|
+
raw = data
|
|
701
|
+
d = _json.loads(raw)
|
|
702
|
+
entry_price = f"{d.get('entry_price', 0):.6g}"
|
|
703
|
+
unrealized = f"{d.get('unrealized_pnl', 0):.4f}"
|
|
704
|
+
realized = f"{d.get('realized_pnl', 0):.4f}"
|
|
705
|
+
except Exception:
|
|
706
|
+
entry_price = unrealized = realized = "?"
|
|
707
|
+
|
|
708
|
+
side_upper = (side or "").upper()
|
|
709
|
+
if side_upper in ("LONG", "BUY"):
|
|
710
|
+
side_str = f"[green]{side_upper}[/green]"
|
|
711
|
+
elif side_upper in ("SHORT", "SELL"):
|
|
712
|
+
side_str = f"[red]{side_upper}[/red]"
|
|
713
|
+
else:
|
|
714
|
+
side_str = side_upper
|
|
715
|
+
|
|
716
|
+
table.add_row(
|
|
717
|
+
symbol,
|
|
718
|
+
exchange or "",
|
|
719
|
+
side_str,
|
|
720
|
+
amount or "",
|
|
721
|
+
entry_price,
|
|
722
|
+
unrealized,
|
|
723
|
+
realized,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
console.print(table)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@cli.command("bal")
|
|
730
|
+
@click.argument("name")
|
|
731
|
+
@click.option(
|
|
732
|
+
"--all", "-a", "show_all", is_flag=True, help="Show zero-balance assets too."
|
|
733
|
+
)
|
|
734
|
+
def bal_cmd(name: str, show_all: bool):
|
|
735
|
+
"""Show non-zero account balances for a strategy process.
|
|
736
|
+
|
|
737
|
+
NAME can be a PM2 process name (STRATEGY_ID.USER_ID) or numeric PM2 id.
|
|
738
|
+
Zero-balance assets are hidden by default; use --all to show them.
|
|
739
|
+
|
|
740
|
+
Examples:\n
|
|
741
|
+
wq bal buy_and_sell.alice\n
|
|
742
|
+
wq bal 0\n
|
|
743
|
+
wq bal 0 --all
|
|
744
|
+
"""
|
|
745
|
+
from decimal import Decimal
|
|
746
|
+
|
|
747
|
+
processes = _pm2_jlist()
|
|
748
|
+
proc = _find_process(processes, name)
|
|
749
|
+
if proc is None:
|
|
750
|
+
click.echo(f"Error: PM2 process '{name}' not found.", err=True)
|
|
751
|
+
click.echo(f"Available: {[p.get('name') for p in processes]}", err=True)
|
|
752
|
+
sys.exit(1)
|
|
753
|
+
|
|
754
|
+
backend, db_path, table_prefix = _resolve_db(proc)
|
|
755
|
+
|
|
756
|
+
if backend == "sqlite" and not Path(db_path).exists():
|
|
757
|
+
click.echo(f"Error: SQLite database not found: {db_path}", err=True)
|
|
758
|
+
sys.exit(1)
|
|
759
|
+
|
|
760
|
+
query = (
|
|
761
|
+
f"SELECT asset, account_type, free, locked FROM {table_prefix}_balances "
|
|
762
|
+
"ORDER BY account_type, asset"
|
|
763
|
+
)
|
|
764
|
+
try:
|
|
765
|
+
rows = _query_db(backend, db_path, table_prefix, query)
|
|
766
|
+
except Exception as e:
|
|
767
|
+
if "no such table" in str(e).lower() or "does not exist" in str(e).lower():
|
|
768
|
+
click.echo("No balance data found.")
|
|
769
|
+
return
|
|
770
|
+
click.echo(f"Error querying balances: {e}", err=True)
|
|
771
|
+
sys.exit(1)
|
|
772
|
+
|
|
773
|
+
if not rows:
|
|
774
|
+
click.echo("No balance data found.")
|
|
775
|
+
return
|
|
776
|
+
|
|
777
|
+
table = Table(box=box.ROUNDED, show_header=True, header_style="bold cyan")
|
|
778
|
+
table.add_column("Asset", style="bold")
|
|
779
|
+
table.add_column("Account Type")
|
|
780
|
+
table.add_column("Free", justify="right")
|
|
781
|
+
table.add_column("Locked", justify="right")
|
|
782
|
+
table.add_column("Total", justify="right")
|
|
783
|
+
|
|
784
|
+
for asset, account_type, free_str, locked_str in rows:
|
|
785
|
+
try:
|
|
786
|
+
free = Decimal(free_str or "0")
|
|
787
|
+
locked = Decimal(locked_str or "0")
|
|
788
|
+
total = free + locked
|
|
789
|
+
except Exception:
|
|
790
|
+
free = locked = total = Decimal("0")
|
|
791
|
+
|
|
792
|
+
if not show_all and total == Decimal("0"):
|
|
793
|
+
continue
|
|
794
|
+
|
|
795
|
+
table.add_row(
|
|
796
|
+
asset or "",
|
|
797
|
+
account_type or "",
|
|
798
|
+
str(free),
|
|
799
|
+
str(locked),
|
|
800
|
+
str(total),
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
console.print(table)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
_SETTINGS_TEMPLATES: dict[str, str] = {
|
|
807
|
+
"toml": """\
|
|
808
|
+
# WalrasQuant settings
|
|
809
|
+
# Add non-secret configuration here (e.g. log paths, Redis, PostgreSQL)
|
|
810
|
+
|
|
811
|
+
# [REDIS]
|
|
812
|
+
# HOST = "localhost"
|
|
813
|
+
# PORT = 6379
|
|
814
|
+
# DB = 0
|
|
815
|
+
# PASSWORD = ""
|
|
816
|
+
|
|
817
|
+
# [POSTGRESQL]
|
|
818
|
+
# HOST = "localhost"
|
|
819
|
+
# PORT = 5432
|
|
820
|
+
# USER = "postgres"
|
|
821
|
+
# PASSWORD = ""
|
|
822
|
+
# DATABASE = "postgres"
|
|
823
|
+
""",
|
|
824
|
+
"yaml": """\
|
|
825
|
+
# WalrasQuant settings
|
|
826
|
+
# Add non-secret configuration here (e.g. log paths, Redis, PostgreSQL)
|
|
827
|
+
|
|
828
|
+
# REDIS:
|
|
829
|
+
# HOST: localhost
|
|
830
|
+
# PORT: 6379
|
|
831
|
+
# DB: 0
|
|
832
|
+
# PASSWORD: ""
|
|
833
|
+
|
|
834
|
+
# POSTGRESQL:
|
|
835
|
+
# HOST: localhost
|
|
836
|
+
# PORT: 5432
|
|
837
|
+
# USER: postgres
|
|
838
|
+
# PASSWORD: ""
|
|
839
|
+
# DATABASE: postgres
|
|
840
|
+
""",
|
|
841
|
+
"json": """\
|
|
842
|
+
{
|
|
843
|
+
"_comment": "WalrasQuant settings - add non-secret configuration here",
|
|
844
|
+
"REDIS": {
|
|
845
|
+
"HOST": "localhost",
|
|
846
|
+
"PORT": 6379,
|
|
847
|
+
"DB": 0,
|
|
848
|
+
"PASSWORD": ""
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
""",
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
_SECRETS_TEMPLATES: dict[str, str] = {
|
|
855
|
+
"toml": """\
|
|
856
|
+
# WalrasQuant secrets - DO NOT commit this file
|
|
857
|
+
# Exchange API credentials
|
|
858
|
+
|
|
859
|
+
# [BYBIT.LIVE.ACCOUNT1]
|
|
860
|
+
# API_KEY = "your_api_key"
|
|
861
|
+
# SECRET = "your_secret"
|
|
862
|
+
|
|
863
|
+
# [BINANCE.LIVE.ACCOUNT1]
|
|
864
|
+
# API_KEY = "your_api_key"
|
|
865
|
+
# SECRET = "your_secret"
|
|
866
|
+
|
|
867
|
+
# [OKX.LIVE.ACCOUNT1]
|
|
868
|
+
# API_KEY = "your_api_key"
|
|
869
|
+
# SECRET = "your_secret"
|
|
870
|
+
# PASSPHRASE = "your_passphrase"
|
|
871
|
+
""",
|
|
872
|
+
"yaml": """\
|
|
873
|
+
# WalrasQuant secrets - DO NOT commit this file
|
|
874
|
+
# Exchange API credentials
|
|
875
|
+
|
|
876
|
+
# BYBIT:
|
|
877
|
+
# LIVE:
|
|
878
|
+
# ACCOUNT1:
|
|
879
|
+
# API_KEY: your_api_key
|
|
880
|
+
# SECRET: your_secret
|
|
881
|
+
|
|
882
|
+
# BINANCE:
|
|
883
|
+
# LIVE:
|
|
884
|
+
# ACCOUNT1:
|
|
885
|
+
# API_KEY: your_api_key
|
|
886
|
+
# SECRET: your_secret
|
|
887
|
+
|
|
888
|
+
# OKX:
|
|
889
|
+
# LIVE:
|
|
890
|
+
# ACCOUNT1:
|
|
891
|
+
# API_KEY: your_api_key
|
|
892
|
+
# SECRET: your_secret
|
|
893
|
+
# PASSPHRASE: your_passphrase
|
|
894
|
+
""",
|
|
895
|
+
"json": """\
|
|
896
|
+
{
|
|
897
|
+
"_comment": "WalrasQuant secrets - DO NOT commit this file",
|
|
898
|
+
"BYBIT": {
|
|
899
|
+
"LIVE": {
|
|
900
|
+
"ACCOUNT1": {
|
|
901
|
+
"API_KEY": "your_api_key",
|
|
902
|
+
"SECRET": "your_secret"
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
"BINANCE": {
|
|
907
|
+
"LIVE": {
|
|
908
|
+
"ACCOUNT1": {
|
|
909
|
+
"API_KEY": "your_api_key",
|
|
910
|
+
"SECRET": "your_secret"
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
""",
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
_GITIGNORE_CONTENT = """\
|
|
919
|
+
config.cfg
|
|
920
|
+
*.pem
|
|
921
|
+
|
|
922
|
+
# Ignore dynaconf secret files
|
|
923
|
+
.secrets.*
|
|
924
|
+
.settings.*
|
|
925
|
+
*.db
|
|
926
|
+
"""
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
@cli.command("init")
|
|
930
|
+
@click.option(
|
|
931
|
+
"--format",
|
|
932
|
+
"-f",
|
|
933
|
+
"fmt",
|
|
934
|
+
type=click.Choice(["toml", "yaml", "json"], case_sensitive=False),
|
|
935
|
+
default="toml",
|
|
936
|
+
show_default=True,
|
|
937
|
+
help="Config file format.",
|
|
938
|
+
)
|
|
939
|
+
@click.option(
|
|
940
|
+
"--force",
|
|
941
|
+
is_flag=True,
|
|
942
|
+
help="Overwrite existing files.",
|
|
943
|
+
)
|
|
944
|
+
def init_cmd(fmt: str, force: bool):
|
|
945
|
+
"""Initialise a .keys directory with template config files.
|
|
946
|
+
|
|
947
|
+
Creates .keys/settings.<fmt>, .keys/.secrets.<fmt>, and .keys/.gitignore.
|
|
948
|
+
Secrets file is prefixed with a dot so it stays hidden by default.
|
|
949
|
+
|
|
950
|
+
Examples:\n
|
|
951
|
+
wq init\n
|
|
952
|
+
wq init --format yaml\n
|
|
953
|
+
wq init -f json\n
|
|
954
|
+
wq init --force
|
|
955
|
+
"""
|
|
956
|
+
keys_dir = Path(".keys")
|
|
957
|
+
keys_dir.mkdir(exist_ok=True)
|
|
958
|
+
|
|
959
|
+
fmt = fmt.lower()
|
|
960
|
+
files = {
|
|
961
|
+
keys_dir / f"settings.{fmt}": _SETTINGS_TEMPLATES[fmt],
|
|
962
|
+
keys_dir / f".secrets.{fmt}": _SECRETS_TEMPLATES[fmt],
|
|
963
|
+
keys_dir / ".gitignore": _GITIGNORE_CONTENT,
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
created, skipped = [], []
|
|
967
|
+
for path, content in files.items():
|
|
968
|
+
if path.exists() and not force:
|
|
969
|
+
skipped.append(str(path))
|
|
970
|
+
else:
|
|
971
|
+
path.write_text(content, encoding="utf-8")
|
|
972
|
+
created.append(str(path))
|
|
973
|
+
|
|
974
|
+
if created:
|
|
975
|
+
click.echo("Created:")
|
|
976
|
+
for f in created:
|
|
977
|
+
click.echo(f" {f}")
|
|
978
|
+
if skipped:
|
|
979
|
+
click.echo("Skipped (already exist; use --force to overwrite):")
|
|
980
|
+
for f in skipped:
|
|
981
|
+
click.echo(f" {f}")
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
@cli.command("stop")
|
|
985
|
+
@click.argument("names", nargs=-1, required=True)
|
|
986
|
+
def stop_cmd(names: tuple[str, ...]):
|
|
987
|
+
"""Stop one or more PM2 processes."""
|
|
988
|
+
subprocess.run(["pm2", "stop", *names])
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
@cli.command("restart")
|
|
992
|
+
@click.argument("names", nargs=-1, required=True)
|
|
993
|
+
def restart_cmd(names: tuple[str, ...]):
|
|
994
|
+
"""Restart one or more PM2 processes."""
|
|
995
|
+
subprocess.run(["pm2", "restart", *names])
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
@cli.command("delete")
|
|
999
|
+
@click.argument("names", nargs=-1, required=True)
|
|
1000
|
+
def delete_cmd(names: tuple[str, ...]):
|
|
1001
|
+
"""Delete one or more PM2 processes."""
|
|
1002
|
+
subprocess.run(["pm2", "delete", *names])
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
cli.add_command(list_cmd, name="ls")
|
|
1006
|
+
cli.add_command(logs_cmd, name="log")
|
|
1007
|
+
cli.add_command(flush_cmd, name="clear")
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def main():
|
|
1011
|
+
"""Entry point for wq command."""
|
|
1012
|
+
cli()
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
if __name__ == "__main__":
|
|
1016
|
+
main()
|