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.
Files changed (105) hide show
  1. walrasquant/__init__.py +7 -0
  2. walrasquant/aggregation.py +449 -0
  3. walrasquant/backends/__init__.py +5 -0
  4. walrasquant/backends/db.py +109 -0
  5. walrasquant/backends/db_memory.py +61 -0
  6. walrasquant/backends/db_postgresql.py +321 -0
  7. walrasquant/backends/db_sqlite.py +310 -0
  8. walrasquant/base/__init__.py +24 -0
  9. walrasquant/base/api_client.py +46 -0
  10. walrasquant/base/connector.py +863 -0
  11. walrasquant/base/ems.py +794 -0
  12. walrasquant/base/exchange.py +213 -0
  13. walrasquant/base/oms.py +428 -0
  14. walrasquant/base/retry.py +220 -0
  15. walrasquant/base/sms.py +545 -0
  16. walrasquant/base/ws_client.py +408 -0
  17. walrasquant/config.py +284 -0
  18. walrasquant/constants.py +413 -0
  19. walrasquant/core/__init__.py +0 -0
  20. walrasquant/core/cache.py +688 -0
  21. walrasquant/core/clock.py +59 -0
  22. walrasquant/core/connection.py +41 -0
  23. walrasquant/core/entity.py +504 -0
  24. walrasquant/core/nautilius_core.py +103 -0
  25. walrasquant/core/registry.py +41 -0
  26. walrasquant/engine.py +745 -0
  27. walrasquant/error.py +34 -0
  28. walrasquant/exchange/__init__.py +13 -0
  29. walrasquant/exchange/base_factory.py +172 -0
  30. walrasquant/exchange/binance/__init__.py +30 -0
  31. walrasquant/exchange/binance/connector.py +1093 -0
  32. walrasquant/exchange/binance/constants.py +934 -0
  33. walrasquant/exchange/binance/ems.py +140 -0
  34. walrasquant/exchange/binance/error.py +48 -0
  35. walrasquant/exchange/binance/exchange.py +144 -0
  36. walrasquant/exchange/binance/factory.py +115 -0
  37. walrasquant/exchange/binance/oms.py +1807 -0
  38. walrasquant/exchange/binance/rest_api.py +1653 -0
  39. walrasquant/exchange/binance/schema.py +1063 -0
  40. walrasquant/exchange/binance/websockets.py +389 -0
  41. walrasquant/exchange/bitget/__init__.py +28 -0
  42. walrasquant/exchange/bitget/connector.py +578 -0
  43. walrasquant/exchange/bitget/constants.py +392 -0
  44. walrasquant/exchange/bitget/ems.py +202 -0
  45. walrasquant/exchange/bitget/error.py +36 -0
  46. walrasquant/exchange/bitget/exchange.py +128 -0
  47. walrasquant/exchange/bitget/factory.py +135 -0
  48. walrasquant/exchange/bitget/oms.py +1619 -0
  49. walrasquant/exchange/bitget/rest_api.py +610 -0
  50. walrasquant/exchange/bitget/schema.py +885 -0
  51. walrasquant/exchange/bitget/websockets.py +753 -0
  52. walrasquant/exchange/bybit/__init__.py +32 -0
  53. walrasquant/exchange/bybit/connector.py +819 -0
  54. walrasquant/exchange/bybit/constants.py +479 -0
  55. walrasquant/exchange/bybit/ems.py +93 -0
  56. walrasquant/exchange/bybit/error.py +36 -0
  57. walrasquant/exchange/bybit/exchange.py +108 -0
  58. walrasquant/exchange/bybit/factory.py +128 -0
  59. walrasquant/exchange/bybit/oms.py +1195 -0
  60. walrasquant/exchange/bybit/rest_api.py +570 -0
  61. walrasquant/exchange/bybit/schema.py +867 -0
  62. walrasquant/exchange/bybit/websockets.py +307 -0
  63. walrasquant/exchange/hyperliquid/__init__.py +28 -0
  64. walrasquant/exchange/hyperliquid/connector.py +370 -0
  65. walrasquant/exchange/hyperliquid/constants.py +371 -0
  66. walrasquant/exchange/hyperliquid/ems.py +156 -0
  67. walrasquant/exchange/hyperliquid/error.py +48 -0
  68. walrasquant/exchange/hyperliquid/exchange.py +120 -0
  69. walrasquant/exchange/hyperliquid/factory.py +135 -0
  70. walrasquant/exchange/hyperliquid/oms.py +1081 -0
  71. walrasquant/exchange/hyperliquid/rest_api.py +348 -0
  72. walrasquant/exchange/hyperliquid/schema.py +583 -0
  73. walrasquant/exchange/hyperliquid/websockets.py +592 -0
  74. walrasquant/exchange/okx/__init__.py +25 -0
  75. walrasquant/exchange/okx/connector.py +931 -0
  76. walrasquant/exchange/okx/constants.py +518 -0
  77. walrasquant/exchange/okx/ems.py +144 -0
  78. walrasquant/exchange/okx/error.py +66 -0
  79. walrasquant/exchange/okx/exchange.py +102 -0
  80. walrasquant/exchange/okx/factory.py +138 -0
  81. walrasquant/exchange/okx/oms.py +1199 -0
  82. walrasquant/exchange/okx/rest_api.py +799 -0
  83. walrasquant/exchange/okx/schema.py +1449 -0
  84. walrasquant/exchange/okx/websockets.py +420 -0
  85. walrasquant/exchange/registry.py +201 -0
  86. walrasquant/execution/__init__.py +24 -0
  87. walrasquant/execution/algorithm.py +968 -0
  88. walrasquant/execution/algorithms/__init__.py +3 -0
  89. walrasquant/execution/algorithms/twap.py +392 -0
  90. walrasquant/execution/config.py +34 -0
  91. walrasquant/execution/constants.py +27 -0
  92. walrasquant/execution/schema.py +62 -0
  93. walrasquant/indicator.py +382 -0
  94. walrasquant/push.py +77 -0
  95. walrasquant/schema.py +755 -0
  96. walrasquant/strategy.py +1805 -0
  97. walrasquant/tools/__init__.py +0 -0
  98. walrasquant/tools/pm2_wrapper.py +1016 -0
  99. walrasquant/web/__init__.py +26 -0
  100. walrasquant/web/app.py +157 -0
  101. walrasquant/web/server.py +92 -0
  102. walrasquant_lib-0.4.20.dist-info/METADATA +162 -0
  103. walrasquant_lib-0.4.20.dist-info/RECORD +105 -0
  104. walrasquant_lib-0.4.20.dist-info/WHEEL +4 -0
  105. 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()