@bitget-ai/getagent-skill 0.2.1

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 (57) hide show
  1. package/.claude-plugin/marketplace.json +28 -0
  2. package/.claude-plugin/plugin.json +12 -0
  3. package/README.md +99 -0
  4. package/VERSION +1 -0
  5. package/bin/getagent-skill.js +140 -0
  6. package/package.json +45 -0
  7. package/skills/getagent/SKILL.md +129 -0
  8. package/skills/getagent/examples/btc-ema-cross-demo/README.md +61 -0
  9. package/skills/getagent/examples/btc-ema-cross-demo/backtest.yaml +33 -0
  10. package/skills/getagent/examples/btc-ema-cross-demo/manifest.yaml +94 -0
  11. package/skills/getagent/examples/btc-ema-cross-demo/src/main.py +88 -0
  12. package/skills/getagent/examples/btc-ema-cross-demo/src/strategy.py +118 -0
  13. package/skills/getagent/references/api/enable.md +95 -0
  14. package/skills/getagent/references/api/error-responses.md +77 -0
  15. package/skills/getagent/references/api/index.md +38 -0
  16. package/skills/getagent/references/api/list.md +80 -0
  17. package/skills/getagent/references/api/my-playbooks.md +41 -0
  18. package/skills/getagent/references/api/publish.md +76 -0
  19. package/skills/getagent/references/api/run.md +149 -0
  20. package/skills/getagent/references/api/upload.md +76 -0
  21. package/skills/getagent/references/backtest-engine.md +438 -0
  22. package/skills/getagent/references/package-schema.md +552 -0
  23. package/skills/getagent/references/sandbox-runtime.md +201 -0
  24. package/skills/getagent/references/sdk/backtest/catalog.md +208 -0
  25. package/skills/getagent/references/sdk/data/arxiv.md +41 -0
  26. package/skills/getagent/references/sdk/data/catalog.md +56 -0
  27. package/skills/getagent/references/sdk/data/commodity.md +226 -0
  28. package/skills/getagent/references/sdk/data/coverage.md +82 -0
  29. package/skills/getagent/references/sdk/data/crypto.md +2906 -0
  30. package/skills/getagent/references/sdk/data/currency.md +123 -0
  31. package/skills/getagent/references/sdk/data/derivatives.md +269 -0
  32. package/skills/getagent/references/sdk/data/economy.md +1348 -0
  33. package/skills/getagent/references/sdk/data/equity.md +2120 -0
  34. package/skills/getagent/references/sdk/data/etf.md +372 -0
  35. package/skills/getagent/references/sdk/data/famafrench.md +201 -0
  36. package/skills/getagent/references/sdk/data/fixedincome.md +804 -0
  37. package/skills/getagent/references/sdk/data/imf_utils.md +225 -0
  38. package/skills/getagent/references/sdk/data/index.md +216 -0
  39. package/skills/getagent/references/sdk/data/news.md +149 -0
  40. package/skills/getagent/references/sdk/data/playbook-supported.md +9871 -0
  41. package/skills/getagent/references/sdk/data/regulators.md +299 -0
  42. package/skills/getagent/references/sdk/data/sentiment.md +323 -0
  43. package/skills/getagent/references/sdk/data/uscongress.md +126 -0
  44. package/skills/getagent/references/sdk/data/web_search.md +68 -0
  45. package/skills/getagent/references/sdk/data/wikipedia.md +97 -0
  46. package/skills/getagent/references/sdk/llm/catalog.md +117 -0
  47. package/skills/getagent/references/sdk/runtime/catalog.md +195 -0
  48. package/skills/getagent/references/sdk/trade/account.md +61 -0
  49. package/skills/getagent/references/sdk/trade/catalog.md +35 -0
  50. package/skills/getagent/references/sdk/trade/contract.md +331 -0
  51. package/skills/getagent/references/sdk/trade/helpers.md +466 -0
  52. package/skills/getagent/references/sdk/trade/market.md +28 -0
  53. package/skills/getagent/references/sdk/trade/patterns.md +102 -0
  54. package/skills/getagent/references/sdk/trade/spot.md +165 -0
  55. package/skills/getagent/references/sdk.md +198 -0
  56. package/skills/getagent/scripts/validate.py +965 -0
  57. package/skills/getagent/scripts/version_check.sh +62 -0
@@ -0,0 +1,965 @@
1
+ #!/usr/bin/env python3
2
+ """Playbook package Local validation script。
3
+
4
+ Usage:
5
+ conda activate get_agent_test
6
+ python scripts/validate.py ./my-strategy/
7
+
8
+ Checks:
9
+ 1. Directory structure complete(manifest.yaml, src/main.py)
10
+ 2. manifest.yaml Required fields and public contract
11
+ 3. optional backtest.yaml shape
12
+ 4. all Python files under src/ compile and pass the import allowlist
13
+ 5. Nautilus lifecycle calls match the runner's installed API
14
+ 6. local-only directories are not included in the upload package
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ast
20
+ import re
21
+ import sys
22
+ from pathlib import Path, PurePosixPath
23
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
24
+
25
+ try:
26
+ import yaml
27
+ except ImportError:
28
+ print("WARNING: PyYAML not installed, falling back to basic parsing")
29
+ yaml = None # type: ignore[assignment]
30
+
31
+
32
+ REQUIRED_FILES = [
33
+ "README.md",
34
+ "manifest.yaml",
35
+ "src/main.py",
36
+ ]
37
+
38
+ MANIFEST_REQUIRED_FIELDS = [
39
+ "name",
40
+ "display_name",
41
+ "version",
42
+ "description",
43
+ "long_description",
44
+ "market_type",
45
+ "trading_symbols",
46
+ "decision_mode",
47
+ "backtest_support",
48
+ "runtime_profile",
49
+ "execution_mode",
50
+ "follow_trade_supported",
51
+ ]
52
+
53
+ NAME_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
54
+ BACKTEST_BAR_FIELD_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
55
+ CRON_EVERY_MINUTES_PATTERN = re.compile(r"^\*/(\d+)$")
56
+ MIN_SCHEDULE_INTERVAL_MINUTES = 10
57
+ DEFAULT_SCHEDULE_TZ = "Asia/Shanghai"
58
+ DECISION_MODES = {"deterministic", "llm_assisted", "agentic"}
59
+ BACKTEST_SUPPORT_VALUES = {"full", "none"}
60
+ RUNTIME_PROFILES = {"deterministic", "llm_bounded", "agentic"}
61
+ EXECUTION_MODES = {"signal_only", "follow_trade"}
62
+ LOCAL_ONLY_TOP_LEVEL = {
63
+ "tests",
64
+ "notebooks",
65
+ "research",
66
+ "data",
67
+ "backtest_results",
68
+ "logs",
69
+ "output",
70
+ ".venv",
71
+ "__pycache__",
72
+ ".pytest_cache",
73
+ }
74
+
75
+ ALLOWED_IMPORTS = {
76
+ "getagent", "getclaw", "nautilus_trader", "pandas", "numpy", "json", "math",
77
+ "datetime", "pathlib", "asyncio", "typing",
78
+ "dataclasses", "collections", "functools",
79
+ "re", "decimal", "statistics", "itertools",
80
+ "operator", "copy", "enum", "abc", "numbers",
81
+ "fractions",
82
+ }
83
+
84
+ BACKTEST_INSTRUMENT_KINDS = {"spot", "currency_pair", "perpetual", "perpetual_contract", "perp"}
85
+ NAUTILUS_INSTRUMENT_REQUIRED_METHODS = {"cancel_all_orders", "close_all_positions"}
86
+ README_REQUIRED_PHRASES = ("策略", "开仓", "平仓", "风险")
87
+
88
+ LONG_DESCRIPTION_MIN_WORDS = 250
89
+ LONG_DESCRIPTION_MAX_WORDS = 500
90
+ LONG_DESCRIPTION_TARGET_RANGE = (300, 400)
91
+
92
+ LONG_DESCRIPTION_SECTION_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
93
+ (
94
+ "what it captures (§1 thesis)",
95
+ (
96
+ "capture", "captures", "tries to", "thesis", "aim", "aims",
97
+ "objective", "designed", "seeks", "intended", "goal", "approach",
98
+ "built on", "assumption", "purpose",
99
+ ),
100
+ ),
101
+ (
102
+ "entry logic (§2 entry)",
103
+ (
104
+ "enter", "enters", "entry", "entering", "opens", "go long",
105
+ "go short", "going long", "going short", "long position",
106
+ "short position",
107
+ ),
108
+ ),
109
+ (
110
+ "exit / stop logic (§3 exit)",
111
+ (
112
+ "exit", "exits", "close", "closes", "closing", "stop",
113
+ "take profit", "take-profit", "stop-loss", "stop loss",
114
+ ),
115
+ ),
116
+ (
117
+ "tunable parameters (§4 tunables)",
118
+ (
119
+ "parameter", "parameters", "tunable", "leverage", "margin",
120
+ "configurable", "adjust", "adjusts", "tune", "subscriber",
121
+ "subscribers",
122
+ ),
123
+ ),
124
+ (
125
+ "risks / unsuitable conditions (§5 risks)",
126
+ (
127
+ "risk", "risks", "drawdown", "drawdowns", "lose money",
128
+ "loses money", "lost money", "loss", "losses", "underperform",
129
+ "underperforms", "unsuitable", "fails", "weakness", "worst",
130
+ "warning",
131
+ ),
132
+ ),
133
+ )
134
+
135
+ LONG_DESCRIPTION_FORBIDDEN_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
136
+ (
137
+ re.compile(
138
+ r"\b(?:EMA|SMA|WMA|MA|RSI|MACD|ATR|VWAP|ADX|Stoch(?:astic)?|Bollinger|MFI|CCI|OBV|DMI|TRIX|KDJ)"
139
+ r"[\s_/-]*\d+",
140
+ re.IGNORECASE,
141
+ ),
142
+ "indicator with a numeric period (e.g. 'EMA 12', 'RSI 14') leaks strategy parameters; "
143
+ "describe the indicator category instead",
144
+ ),
145
+ (
146
+ re.compile(
147
+ r"\b\d+(?:\.\d+)?[\s/-]*"
148
+ r"(?:bar|bars|candle|candles|period|periods|day|days|hour|hours|"
149
+ r"minute|minutes|week|weeks|second|seconds|tick|ticks)\b",
150
+ re.IGNORECASE,
151
+ ),
152
+ "numeric lookback window (e.g. '14 bars', '20 days', '5-minute') leaks strategy parameters; "
153
+ "describe the timeframe qualitatively",
154
+ ),
155
+ (
156
+ re.compile(r"(?<![\w.])(?:>=|<=|>|<|==)\s*-?\d"),
157
+ "explicit numeric threshold (e.g. '> 30', '<= 0.7') leaks decision boundaries; "
158
+ "describe direction without numbers",
159
+ ),
160
+ (
161
+ re.compile(r"\b\d+(?:\.\d+)?\s*%"),
162
+ "explicit percentage threshold (e.g. '3%', '10%') leaks decision boundaries; "
163
+ "describe direction without numbers",
164
+ ),
165
+ (
166
+ re.compile(r"\b\d+(?:\.\d+)?\s*x\b", re.IGNORECASE),
167
+ "explicit multiplier (e.g. '1.5x', '10x') leaks parameter; describe behavior qualitatively",
168
+ ),
169
+ (
170
+ re.compile(r"\b\d+\s*:\s*\d+\b"),
171
+ "explicit ratio (e.g. '3:1', '2:1') leaks decision boundary; describe behavior qualitatively",
172
+ ),
173
+ )
174
+ POSITION_SELECTION_HELPERS = {"select_contract_position", "find_contract_position"}
175
+ POSITION_SELECTION_INVALID_ATTRS = {
176
+ "open_price",
177
+ "openPrice",
178
+ "entry_price",
179
+ "entryPrice",
180
+ "avg_price",
181
+ "avgPrice",
182
+ "average_open_price",
183
+ "averageOpenPrice",
184
+ }
185
+ CONTRACT_ORDER_HELPERS = {
186
+ "open_long_market",
187
+ "open_short_market",
188
+ }
189
+ CONTRACT_TPSL_HELPER = "resolve_contract_tpsl"
190
+ CONTRACT_TPSL_HELPER_KEYWORDS = {
191
+ "symbol",
192
+ "side",
193
+ "leverage",
194
+ "tp_trigger_price",
195
+ "sl_trigger_price",
196
+ "reference_price",
197
+ "product_type",
198
+ }
199
+ TRADE_MUTATION_METHODS = {
200
+ "cancel_order",
201
+ "change_leverage",
202
+ "close_position",
203
+ "market_buy",
204
+ "market_sell",
205
+ "modify_limit_order",
206
+ "modify_stop_loss",
207
+ "modify_take_profit",
208
+ "open_long_limit",
209
+ "open_long_market",
210
+ "open_short_limit",
211
+ "open_short_market",
212
+ "place_order",
213
+ "transfer",
214
+ }
215
+ CONTRACT_TRIGGER_PRICE_KEYWORDS = {"tp_trigger_price", "sl_trigger_price"}
216
+
217
+ BLOCKED_IMPORTS = {
218
+ "requests", "httpx", "trade_sdk", "ccxt", "subprocess",
219
+ "os", "sys", "importlib", "socket", "urllib",
220
+ "http", "ftplib", "smtplib", "shutil",
221
+ "sqlalchemy", "redis", "pymongo", "fastapi", "flask",
222
+ "telegram", "slack_sdk", "discord", "multiprocessing",
223
+ }
224
+
225
+
226
+ class ValidationResult:
227
+ def __init__(self) -> None:
228
+ self.errors: list[str] = []
229
+ self.warnings: list[str] = []
230
+
231
+ def error(self, msg: str) -> None:
232
+ self.errors.append(msg)
233
+
234
+ def warn(self, msg: str) -> None:
235
+ self.warnings.append(msg)
236
+
237
+ @property
238
+ def passed(self) -> bool:
239
+ return len(self.errors) == 0
240
+
241
+
242
+ def _load_yaml(path: Path) -> dict | None:
243
+ if yaml is None:
244
+ return {}
245
+ try:
246
+ with open(path) as f:
247
+ return yaml.safe_load(f) or {}
248
+ except Exception:
249
+ return None
250
+
251
+
252
+ def validate_structure(pkg_dir: Path, result: ValidationResult) -> None:
253
+ for rel in REQUIRED_FILES:
254
+ if not (pkg_dir / rel).exists():
255
+ result.error(f"Missing required file: {rel}")
256
+
257
+ for child in pkg_dir.iterdir():
258
+ if child.name in LOCAL_ONLY_TOP_LEVEL:
259
+ result.error(f"Local-only path must not be included in upload package: {child.name}")
260
+
261
+ readme_path = pkg_dir / "README.md"
262
+ if readme_path.exists():
263
+ text = readme_path.read_text(encoding="utf-8", errors="ignore").strip()
264
+ if len(text) < 200:
265
+ result.error("README.md: must be a human-readable strategy explanation of at least 200 characters")
266
+ missing = [phrase for phrase in README_REQUIRED_PHRASES if phrase not in text]
267
+ if missing:
268
+ result.error(f"README.md: missing required plain-language sections or keywords: {', '.join(missing)}")
269
+
270
+
271
+ def _validate_long_description(
272
+ raw_value: object,
273
+ description: str,
274
+ result: ValidationResult,
275
+ ) -> None:
276
+ """Hard-fail manifest.long_description authoring rules.
277
+
278
+ Rules:
279
+ - present, string, non-empty
280
+ - 250..500 words (target 300..400)
281
+ - covers all 5 required sections by keyword cluster
282
+ - does not leak indicator periods, lookback windows, numeric thresholds,
283
+ percentages, multipliers, or ratios
284
+ - is not a near-duplicate of `description`
285
+ """
286
+
287
+ if raw_value is None:
288
+ return
289
+
290
+ if not isinstance(raw_value, str):
291
+ result.error("manifest.yaml: 'long_description' must be a string")
292
+ return
293
+
294
+ text = raw_value.strip()
295
+ if not text:
296
+ result.error("manifest.yaml: 'long_description' must not be empty")
297
+ return
298
+
299
+ word_count = len(text.split())
300
+ if word_count < LONG_DESCRIPTION_MIN_WORDS:
301
+ result.error(
302
+ f"manifest.yaml: 'long_description' is {word_count} words; "
303
+ f"must be at least {LONG_DESCRIPTION_MIN_WORDS} (target "
304
+ f"{LONG_DESCRIPTION_TARGET_RANGE[0]}-{LONG_DESCRIPTION_TARGET_RANGE[1]})"
305
+ )
306
+ elif word_count > LONG_DESCRIPTION_MAX_WORDS:
307
+ result.error(
308
+ f"manifest.yaml: 'long_description' is {word_count} words; "
309
+ f"must be at most {LONG_DESCRIPTION_MAX_WORDS} (target "
310
+ f"{LONG_DESCRIPTION_TARGET_RANGE[0]}-{LONG_DESCRIPTION_TARGET_RANGE[1]})"
311
+ )
312
+ elif not (
313
+ LONG_DESCRIPTION_TARGET_RANGE[0] <= word_count <= LONG_DESCRIPTION_TARGET_RANGE[1]
314
+ ):
315
+ result.warn(
316
+ f"manifest.yaml: 'long_description' is {word_count} words; "
317
+ f"target range is {LONG_DESCRIPTION_TARGET_RANGE[0]}-{LONG_DESCRIPTION_TARGET_RANGE[1]}"
318
+ )
319
+
320
+ for label, keywords in LONG_DESCRIPTION_SECTION_KEYWORDS:
321
+ keyword_re = re.compile(
322
+ r"\b(?:" + "|".join(re.escape(k) for k in keywords) + r")\b",
323
+ re.IGNORECASE,
324
+ )
325
+ if not keyword_re.search(text):
326
+ result.error(
327
+ f"manifest.yaml: 'long_description' is missing required section coverage: {label}. "
328
+ f"At least one of these must appear: {', '.join(keywords)}"
329
+ )
330
+
331
+ for pattern, reason in LONG_DESCRIPTION_FORBIDDEN_PATTERNS:
332
+ match = pattern.search(text)
333
+ if match:
334
+ snippet = match.group(0)
335
+ result.error(
336
+ f"manifest.yaml: 'long_description' contains forbidden content {snippet!r}: {reason}"
337
+ )
338
+
339
+ if description and isinstance(description, str):
340
+ d_norm = " ".join(description.lower().split())
341
+ l_norm = " ".join(text.lower().split())
342
+ if d_norm and (d_norm in l_norm) and len(d_norm) > 30 and len(l_norm) < 2 * len(d_norm):
343
+ result.error(
344
+ "manifest.yaml: 'long_description' appears to be a near-duplicate of 'description'; "
345
+ "rewrite it as a 300-400 word strategy summary covering thesis, entry, exit, "
346
+ "tunables, and risks"
347
+ )
348
+
349
+
350
+ def validate_manifest(pkg_dir: Path, result: ValidationResult) -> dict:
351
+ path = pkg_dir / "manifest.yaml"
352
+ if not path.exists():
353
+ return {}
354
+
355
+ data = _load_yaml(path)
356
+ if data is None:
357
+ result.error("manifest.yaml: invalid YAML syntax")
358
+ return {}
359
+
360
+ for field in MANIFEST_REQUIRED_FIELDS:
361
+ if field not in data:
362
+ result.error(f"manifest.yaml: missing required field '{field}'")
363
+
364
+ name = data.get("name", "")
365
+ if name and not NAME_PATTERN.match(name):
366
+ result.error(
367
+ f"manifest.yaml: 'name' must be lowercase alphanumeric with hyphens "
368
+ f"(DNS label format), got: '{name}'"
369
+ )
370
+
371
+ market_type = data.get("market_type", "")
372
+ if market_type and market_type not in ("spot", "contract"):
373
+ result.error(f"manifest.yaml: 'market_type' must be 'spot' or 'contract', got: '{market_type}'")
374
+
375
+ symbols = data.get("trading_symbols", [])
376
+ if not isinstance(symbols, list) or not all(isinstance(item, str) and item.strip() for item in symbols):
377
+ result.error("manifest.yaml: 'trading_symbols' must be a non-empty list of strings")
378
+
379
+ decision_mode = data.get("decision_mode", "")
380
+ if decision_mode and decision_mode not in DECISION_MODES:
381
+ result.error(f"manifest.yaml: 'decision_mode' must be one of {sorted(DECISION_MODES)}")
382
+
383
+ backtest_support = data.get("backtest_support", "")
384
+ if backtest_support and backtest_support not in BACKTEST_SUPPORT_VALUES:
385
+ result.error(
386
+ f"manifest.yaml: 'backtest_support' must be one of {sorted(BACKTEST_SUPPORT_VALUES)}"
387
+ )
388
+
389
+ runtime_profile = data.get("runtime_profile", "")
390
+ if runtime_profile and runtime_profile not in RUNTIME_PROFILES:
391
+ result.error(
392
+ f"manifest.yaml: 'runtime_profile' must be one of {sorted(RUNTIME_PROFILES)}"
393
+ )
394
+
395
+ execution_mode = data.get("execution_mode", "")
396
+ if execution_mode and execution_mode not in EXECUTION_MODES:
397
+ result.error(
398
+ f"manifest.yaml: 'execution_mode' must be one of {sorted(EXECUTION_MODES)}"
399
+ )
400
+
401
+ follow_trade_supported = data.get("follow_trade_supported")
402
+ if follow_trade_supported is not None and not isinstance(follow_trade_supported, bool):
403
+ result.error("manifest.yaml: 'follow_trade_supported' must be a boolean")
404
+
405
+ if decision_mode == "agentic" and runtime_profile != "agentic":
406
+ result.error("manifest.yaml: 'decision_mode=agentic' requires 'runtime_profile=agentic'")
407
+ if runtime_profile == "llm_bounded" and backtest_support == "full":
408
+ result.error("manifest.yaml: 'runtime_profile=llm_bounded' requires 'backtest_support=none'")
409
+
410
+ if execution_mode == "follow_trade" and follow_trade_supported is not True:
411
+ result.error("manifest.yaml: 'execution_mode=follow_trade' requires 'follow_trade_supported=true'")
412
+
413
+ if backtest_support == "none" and execution_mode == "follow_trade":
414
+ result.error("manifest.yaml: live-only playbooks cannot default to 'execution_mode=follow_trade'")
415
+
416
+ schedule = data.get("schedule")
417
+ if isinstance(schedule, dict):
418
+ cron_expr = str(schedule.get("cron", "") or "").strip()
419
+ schedule_tz = str(schedule.get("tz") or schedule.get("timezone") or "").strip()
420
+ if cron_expr:
421
+ if not schedule_tz:
422
+ result.error(
423
+ "manifest.yaml.schedule.tz: scheduled Playbooks must declare "
424
+ f"an instance-default IANA timezone, e.g. {DEFAULT_SCHEDULE_TZ}"
425
+ )
426
+ else:
427
+ try:
428
+ ZoneInfo(schedule_tz)
429
+ except (ZoneInfoNotFoundError, KeyError):
430
+ result.error(
431
+ "manifest.yaml.schedule.tz: must be a valid IANA timezone "
432
+ f"(for example {DEFAULT_SCHEDULE_TZ})"
433
+ )
434
+ parts = cron_expr.split()
435
+ if len(parts) not in (5, 6):
436
+ result.error("manifest.yaml.schedule.cron: must be a 5- or 6-field cron expression")
437
+ else:
438
+ minute_field = parts[0]
439
+ match = CRON_EVERY_MINUTES_PATTERN.fullmatch(minute_field)
440
+ if match and int(match.group(1)) < MIN_SCHEDULE_INTERVAL_MINUTES:
441
+ result.error(
442
+ "manifest.yaml.schedule.cron: scheduled Playbooks must not run more often "
443
+ f"than every {MIN_SCHEDULE_INTERVAL_MINUTES} minutes"
444
+ )
445
+ elif minute_field == "*":
446
+ result.error(
447
+ "manifest.yaml.schedule.cron: scheduled Playbooks must not run every minute; "
448
+ f"minimum interval is {MIN_SCHEDULE_INTERVAL_MINUTES} minutes"
449
+ )
450
+
451
+ _validate_long_description(
452
+ data.get("long_description"),
453
+ str(data.get("description") or ""),
454
+ result,
455
+ )
456
+
457
+ return data
458
+
459
+
460
+ def validate_backtest_yaml(pkg_dir: Path, manifest: dict, result: ValidationResult) -> None:
461
+ def _is_number(value: object) -> bool:
462
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
463
+
464
+ def _string_field(payload: dict, field: str, *, prefix: str) -> None:
465
+ if not str(payload.get(field, "") or "").strip():
466
+ result.error(f"{prefix}: missing '{field}'")
467
+
468
+ def _validate_instrument(item: dict, *, prefix: str) -> None:
469
+ kind = str(item.get("kind", "") or "").strip().lower()
470
+ if kind not in BACKTEST_INSTRUMENT_KINDS:
471
+ result.error(f"{prefix}: 'kind' must be one of {sorted(BACKTEST_INSTRUMENT_KINDS)}")
472
+ _string_field(item, "id", prefix=prefix)
473
+ _string_field(item, "bar_type", prefix=prefix)
474
+ _string_field(item, "base_currency", prefix=prefix)
475
+ _string_field(item, "quote_currency", prefix=prefix)
476
+ if not str(item.get("raw_symbol", "") or item.get("symbol", "")).strip():
477
+ result.error(f"{prefix}: either 'raw_symbol' or 'symbol' is required")
478
+ for field in ("price_precision", "size_precision"):
479
+ if not isinstance(item.get(field), int):
480
+ result.error(f"{prefix}: '{field}' must be an integer")
481
+ for field in ("price_increment", "size_increment"):
482
+ if not str(item.get(field, "") or "").strip():
483
+ result.error(f"{prefix}: missing '{field}'")
484
+ for field in ("maker_fee", "taker_fee"):
485
+ if field not in item:
486
+ result.error(f"{prefix}: missing '{field}' (set explicit exchange fee rate; do not rely on zero-fee backtests)")
487
+ elif not _is_number(item.get(field)) and not str(item.get(field, "") or "").strip():
488
+ result.error(f"{prefix}: '{field}' must be a numeric fee rate")
489
+ if kind in {"perpetual", "perpetual_contract", "perp"}:
490
+ _string_field(item, "settlement_currency", prefix=prefix)
491
+
492
+ def _validate_required_bar_fields(payload: object) -> None:
493
+ if payload is None:
494
+ return
495
+ if not isinstance(payload, list) or not payload:
496
+ result.error(
497
+ "backtest.yaml.data_requirements.required_bar_fields: must be a non-empty list when provided"
498
+ )
499
+ return
500
+
501
+ seen: set[str] = set()
502
+ for index, raw_field in enumerate(payload):
503
+ field = str(raw_field or "").strip()
504
+ prefix = f"backtest.yaml.data_requirements.required_bar_fields[{index}]"
505
+ if not field:
506
+ result.error(f"{prefix}: field name must be a non-empty string")
507
+ continue
508
+ if not BACKTEST_BAR_FIELD_PATTERN.fullmatch(field):
509
+ result.error(f"{prefix}: must use lower_snake_case, got '{field}'")
510
+ continue
511
+ if field in seen:
512
+ result.error(f"{prefix}: duplicate field '{field}'")
513
+ continue
514
+ seen.add(field)
515
+
516
+ path = pkg_dir / "backtest.yaml"
517
+ if not path.exists():
518
+ return
519
+
520
+ data = _load_yaml(path)
521
+ if data is None:
522
+ result.error("backtest.yaml: invalid YAML syntax")
523
+ return
524
+
525
+ if manifest.get("backtest_support") != "full":
526
+ result.error("backtest.yaml is only allowed when manifest.yaml sets backtest_support: full")
527
+
528
+ venue = data.get("venue")
529
+ if not isinstance(venue, dict):
530
+ result.error("backtest.yaml: 'venue' must be a mapping")
531
+ else:
532
+ for field in ("name", "account_type", "oms_type"):
533
+ _string_field(venue, field, prefix="backtest.yaml.venue")
534
+ balances = venue.get("starting_balances")
535
+ if not isinstance(balances, list) or not balances:
536
+ result.error("backtest.yaml.venue: 'starting_balances' must be a non-empty list")
537
+ else:
538
+ for index, balance in enumerate(balances):
539
+ if isinstance(balance, str):
540
+ if len(balance.strip().split()) < 2:
541
+ result.error(
542
+ f"backtest.yaml.venue.starting_balances[{index}]: "
543
+ "string balances must look like '<amount> <CURRENCY>'"
544
+ )
545
+ elif isinstance(balance, dict):
546
+ if not _is_number(balance.get("amount")):
547
+ result.error(
548
+ f"backtest.yaml.venue.starting_balances[{index}]: 'amount' must be a number"
549
+ )
550
+ if not str(balance.get("currency", "") or "").strip():
551
+ result.error(
552
+ f"backtest.yaml.venue.starting_balances[{index}]: 'currency' is required"
553
+ )
554
+ else:
555
+ result.error(
556
+ f"backtest.yaml.venue.starting_balances[{index}]: entry must be a string or mapping"
557
+ )
558
+
559
+ strategy = data.get("strategy")
560
+ if not isinstance(strategy, dict):
561
+ result.error("backtest.yaml: 'strategy' must be a mapping")
562
+ else:
563
+ for field in ("module", "class"):
564
+ _string_field(strategy, field, prefix="backtest.yaml.strategy")
565
+ strategy_config = strategy.get("config", {})
566
+ if strategy_config is not None and not isinstance(strategy_config, dict):
567
+ result.error("backtest.yaml.strategy: 'config' must be a mapping when provided")
568
+
569
+ has_single = isinstance(data.get("instrument"), dict)
570
+ has_many = isinstance(data.get("instruments"), list)
571
+ if has_single and has_many:
572
+ result.error("backtest.yaml: use either 'instrument' or 'instruments', not both")
573
+ elif has_single:
574
+ _validate_instrument(data["instrument"], prefix="backtest.yaml.instrument")
575
+ elif has_many:
576
+ instruments = data.get("instruments") or []
577
+ if not instruments:
578
+ result.error("backtest.yaml: 'instruments' must not be empty")
579
+ for index, item in enumerate(instruments):
580
+ if not isinstance(item, dict):
581
+ result.error(f"backtest.yaml.instruments[{index}] must be a mapping")
582
+ continue
583
+ _validate_instrument(item, prefix=f"backtest.yaml.instruments[{index}]")
584
+ else:
585
+ result.error("backtest.yaml: missing 'instrument' or 'instruments'")
586
+
587
+ execution = data.get("execution")
588
+ if execution is not None and not isinstance(execution, dict):
589
+ result.error("backtest.yaml: 'execution' must be a mapping")
590
+
591
+ data_requirements = data.get("data_requirements")
592
+ if data_requirements is not None and not isinstance(data_requirements, dict):
593
+ result.error("backtest.yaml: 'data_requirements' must be a mapping")
594
+ elif isinstance(data_requirements, dict):
595
+ _validate_required_bar_fields(data_requirements.get("required_bar_fields"))
596
+
597
+
598
+ def _local_import_roots(pkg_dir: Path) -> set[str]:
599
+ roots = {"src"}
600
+ src_root = pkg_dir / "src"
601
+ if not src_root.exists():
602
+ return roots
603
+
604
+ for path in src_root.rglob("*.py"):
605
+ rel_parts = PurePosixPath(path.relative_to(src_root).as_posix()).parts
606
+ if not rel_parts:
607
+ continue
608
+ first = rel_parts[0]
609
+ if first.endswith(".py"):
610
+ stem = first[:-3]
611
+ if stem:
612
+ roots.add(stem)
613
+ continue
614
+ roots.add(first)
615
+
616
+ return roots
617
+
618
+
619
+ def _call_name(node: ast.AST) -> str:
620
+ if isinstance(node, ast.Name):
621
+ return node.id
622
+ if isinstance(node, ast.Attribute):
623
+ return node.attr
624
+ return ""
625
+
626
+
627
+ def _attribute_path(node: ast.AST) -> list[str]:
628
+ if isinstance(node, ast.Name):
629
+ return [node.id]
630
+ if isinstance(node, ast.Attribute):
631
+ return [*_attribute_path(node.value), node.attr]
632
+ if isinstance(node, ast.Call):
633
+ return _attribute_path(node.func)
634
+ return []
635
+
636
+
637
+ def _target_names(target: ast.AST) -> list[str]:
638
+ if isinstance(target, ast.Name):
639
+ return [target.id]
640
+ if isinstance(target, (ast.Tuple, ast.List)):
641
+ names: list[str] = []
642
+ for item in target.elts:
643
+ names.extend(_target_names(item))
644
+ return names
645
+ return []
646
+
647
+
648
+ def _position_selection_assignments(tree: ast.AST) -> dict[str, str]:
649
+ selections: dict[str, str] = {}
650
+ for node in ast.walk(tree):
651
+ value: ast.AST | None = None
652
+ targets: list[ast.AST] = []
653
+ if isinstance(node, ast.Assign):
654
+ value = node.value
655
+ targets = list(node.targets)
656
+ elif isinstance(node, ast.AnnAssign):
657
+ value = node.value
658
+ targets = [node.target]
659
+ elif isinstance(node, ast.NamedExpr):
660
+ value = node.value
661
+ targets = [node.target]
662
+ if not isinstance(value, ast.Call):
663
+ continue
664
+ helper_name = _call_name(value.func)
665
+ if helper_name not in POSITION_SELECTION_HELPERS:
666
+ continue
667
+ for target in targets:
668
+ for name in _target_names(target):
669
+ selections[name] = helper_name
670
+ return selections
671
+
672
+
673
+ def _check_position_selection_attributes(tree: ast.AST, *, source_path: str) -> list[str]:
674
+ selections = _position_selection_assignments(tree)
675
+ if not selections:
676
+ return []
677
+
678
+ errors: list[str] = []
679
+ for node in ast.walk(tree):
680
+ if (
681
+ isinstance(node, ast.Attribute)
682
+ and isinstance(node.value, ast.Name)
683
+ and node.value.id in selections
684
+ and node.attr in POSITION_SELECTION_INVALID_ATTRS
685
+ ):
686
+ errors.append(
687
+ f"{source_path}: PositionSelection returned by trade.helpers."
688
+ f"{selections[node.value.id]}() does not expose '.{node.attr}' "
689
+ f"(line {node.lineno}); use '.raw' or contract_position_records(...) "
690
+ "for exchange-specific position fields"
691
+ )
692
+ return errors
693
+
694
+
695
+ def _unwrap_str_call(node: ast.AST) -> ast.AST:
696
+ if (
697
+ isinstance(node, ast.Call)
698
+ and isinstance(node.func, ast.Name)
699
+ and node.func.id == "str"
700
+ and len(node.args) == 1
701
+ and not node.keywords
702
+ ):
703
+ return node.args[0]
704
+ return node
705
+
706
+
707
+ def _is_fixed_precision_round(node: ast.AST) -> bool:
708
+ unwrapped = _unwrap_str_call(node)
709
+ if not isinstance(unwrapped, ast.Call):
710
+ return False
711
+ if _call_name(unwrapped.func) != "round" or len(unwrapped.args) < 2:
712
+ return False
713
+ precision_arg = unwrapped.args[1]
714
+ return isinstance(precision_arg, ast.Constant) and isinstance(precision_arg.value, int)
715
+
716
+
717
+ def _check_contract_trigger_price_rounding(tree: ast.AST, *, source_path: str) -> list[str]:
718
+ errors: list[str] = []
719
+ for node in ast.walk(tree):
720
+ if not isinstance(node, ast.Call):
721
+ continue
722
+ if _call_name(node.func) not in CONTRACT_ORDER_HELPERS:
723
+ continue
724
+ for keyword in node.keywords:
725
+ if keyword.arg not in CONTRACT_TRIGGER_PRICE_KEYWORDS:
726
+ continue
727
+ if _is_fixed_precision_round(keyword.value):
728
+ errors.append(
729
+ f"{source_path}: {keyword.arg} passed to trade.contract."
730
+ f"{_call_name(node.func)}() uses fixed round(..., N) precision "
731
+ f"(line {keyword.value.lineno}); use trade.helpers.resolve_contract_tpsl(...) "
732
+ "or contract_rules(symbol).price_step to align trigger prices with exchange tick size"
733
+ )
734
+ return errors
735
+
736
+
737
+ def _check_contract_tpsl_helper_call(tree: ast.AST, *, source_path: str) -> list[str]:
738
+ errors: list[str] = []
739
+ for node in ast.walk(tree):
740
+ if not isinstance(node, ast.Call):
741
+ continue
742
+ if _attribute_path(node.func)[-3:] != ["trade", "helpers", CONTRACT_TPSL_HELPER]:
743
+ continue
744
+ if node.args:
745
+ errors.append(
746
+ f"{source_path}: trade.helpers.resolve_contract_tpsl() is keyword-only "
747
+ f"(line {node.lineno}); pass symbol=..., side=..., leverage=..., and optional "
748
+ "tp_trigger_price/sl_trigger_price/reference_price/product_type explicitly"
749
+ )
750
+ for keyword in node.keywords:
751
+ if keyword.arg is None:
752
+ errors.append(
753
+ f"{source_path}: trade.helpers.resolve_contract_tpsl() cannot be validated with **kwargs "
754
+ f"(line {node.lineno}); pass only explicit supported TP/SL keywords"
755
+ )
756
+ continue
757
+ if keyword.arg not in CONTRACT_TPSL_HELPER_KEYWORDS:
758
+ errors.append(
759
+ f"{source_path}: unsupported keyword '{keyword.arg}' passed to "
760
+ f"trade.helpers.resolve_contract_tpsl() (line {keyword.value.lineno}); "
761
+ "compute concrete tp_trigger_price/sl_trigger_price values instead of using "
762
+ "percentage override kwargs"
763
+ )
764
+ return errors
765
+
766
+
767
+ def _test_contains_follow_trade_guard(test: ast.AST) -> bool:
768
+ if isinstance(test, ast.Call):
769
+ return _attribute_path(test)[-2:] == ["runtime", "is_follow_trade"]
770
+ if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.And):
771
+ return any(_test_contains_follow_trade_guard(value) for value in test.values)
772
+ if isinstance(test, ast.Compare):
773
+ nodes = [test.left, *test.comparators]
774
+ has_follow_trade = any(
775
+ isinstance(node, ast.Constant) and node.value == "follow_trade"
776
+ for node in nodes
777
+ )
778
+ has_positive_operator = any(isinstance(op, (ast.Eq, ast.In)) for op in test.ops)
779
+ return has_follow_trade and has_positive_operator
780
+ return False
781
+
782
+
783
+ def _call_is_runtime_follow_wrapper(node: ast.Call) -> bool:
784
+ return _attribute_path(node.func)[-2:] == ["runtime", "emit_signal_or_follow"]
785
+
786
+
787
+ def _call_is_trade_mutation(node: ast.Call) -> bool:
788
+ path = _attribute_path(node.func)
789
+ return (
790
+ len(path) >= 3
791
+ and path[0] == "trade"
792
+ and path[-2] in {"account", "contract", "spot"}
793
+ and path[-1] in TRADE_MUTATION_METHODS
794
+ )
795
+
796
+
797
+ def _check_live_trade_mutation_guards(tree: ast.AST, *, source_path: str) -> list[str]:
798
+ """Require direct live trade mutations in run() to be follow-trade guarded."""
799
+ if source_path not in {"src/main.py", "src/main_live.py"}:
800
+ return []
801
+
802
+ errors: list[str] = []
803
+
804
+ def visit(node: ast.AST, *, inside_run: bool, guarded: bool) -> None:
805
+ next_inside_run = inside_run
806
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
807
+ next_inside_run = node.name == "run"
808
+
809
+ next_guarded = guarded
810
+ if next_inside_run and isinstance(node, ast.If) and _test_contains_follow_trade_guard(node.test):
811
+ next_guarded = True
812
+
813
+ if next_inside_run and isinstance(node, ast.Call) and _call_is_trade_mutation(node) and not next_guarded:
814
+ path = ".".join(_attribute_path(node.func))
815
+ errors.append(
816
+ f"{source_path}: {path}() in run() must be inside an "
817
+ "execution_mode == 'follow_trade' guard after emitting the signal; "
818
+ f"signal_only runs must only emit signals (line {node.lineno})"
819
+ )
820
+
821
+ if next_inside_run and isinstance(node, ast.Call) and _call_is_runtime_follow_wrapper(node):
822
+ execute_trade_keywords = {
823
+ keyword
824
+ for keyword in node.keywords
825
+ if keyword.arg == "execute_trade"
826
+ }
827
+ execute_trade_child_ids = {
828
+ child_id
829
+ for keyword in execute_trade_keywords
830
+ for child_id in (id(keyword), id(keyword.value))
831
+ }
832
+ for keyword in execute_trade_keywords:
833
+ visit(keyword.value, inside_run=next_inside_run, guarded=True)
834
+ for child in ast.iter_child_nodes(node):
835
+ if id(child) not in execute_trade_child_ids:
836
+ visit(child, inside_run=next_inside_run, guarded=next_guarded)
837
+ return
838
+
839
+ for child in ast.iter_child_nodes(node):
840
+ visit(child, inside_run=next_inside_run, guarded=next_guarded)
841
+
842
+ visit(tree, inside_run=False, guarded=False)
843
+ return errors
844
+
845
+
846
+ def validate_src_tree(pkg_dir: Path, result: ValidationResult) -> None:
847
+ src_root = pkg_dir / "src"
848
+ if not src_root.exists():
849
+ return
850
+
851
+ local_import_roots = _local_import_roots(pkg_dir)
852
+ for path in src_root.rglob("*.py"):
853
+ rel_path = path.relative_to(pkg_dir).as_posix()
854
+ source = path.read_text()
855
+
856
+ try:
857
+ tree = ast.parse(source, filename=rel_path)
858
+ except SyntaxError as e:
859
+ result.error(f"{rel_path}: syntax error at line {e.lineno}: {e.msg}")
860
+ continue
861
+
862
+ for error in _check_position_selection_attributes(tree, source_path=rel_path):
863
+ result.error(error)
864
+ for error in _check_contract_trigger_price_rounding(tree, source_path=rel_path):
865
+ result.error(error)
866
+ for error in _check_contract_tpsl_helper_call(tree, source_path=rel_path):
867
+ result.error(error)
868
+ for error in _check_live_trade_mutation_guards(tree, source_path=rel_path):
869
+ result.error(error)
870
+
871
+ for node in ast.walk(tree):
872
+ if isinstance(node, ast.Import):
873
+ for alias in node.names:
874
+ top = alias.name.split(".")[0]
875
+ if top in BLOCKED_IMPORTS:
876
+ result.error(f"{rel_path}: blocked import '{alias.name}' (line {node.lineno})")
877
+ elif top not in ALLOWED_IMPORTS and top not in local_import_roots:
878
+ result.error(f"{rel_path}: disallowed import '{alias.name}' (line {node.lineno})")
879
+
880
+ elif isinstance(node, ast.ImportFrom):
881
+ if node.level > 0:
882
+ continue
883
+ if node.module:
884
+ top = node.module.split(".")[0]
885
+ if top in BLOCKED_IMPORTS:
886
+ result.error(f"{rel_path}: blocked import from '{node.module}' (line {node.lineno})")
887
+ elif top not in ALLOWED_IMPORTS and top not in local_import_roots:
888
+ result.error(f"{rel_path}: disallowed import from '{node.module}' (line {node.lineno})")
889
+
890
+ elif isinstance(node, ast.Call):
891
+ func = node.func
892
+ name = ""
893
+ if isinstance(func, ast.Name):
894
+ name = func.id
895
+ elif isinstance(func, ast.Attribute):
896
+ name = func.attr
897
+
898
+ if name in ("__import__", "import_module"):
899
+ result.error(f"{rel_path}: dynamic import via {name}() is not allowed (line {node.lineno})")
900
+ if name in ("eval", "exec", "compile"):
901
+ result.error(f"{rel_path}: {name}() is not allowed (line {node.lineno})")
902
+ if (
903
+ name in NAUTILUS_INSTRUMENT_REQUIRED_METHODS
904
+ and isinstance(func, ast.Attribute)
905
+ and isinstance(func.value, ast.Name)
906
+ and func.value.id == "self"
907
+ and not node.args
908
+ and not node.keywords
909
+ ):
910
+ result.error(
911
+ f"{rel_path}: Nautilus Strategy.{name}() requires an instrument_id "
912
+ f"argument in this runner; call self.{name}(instrument_id) (line {node.lineno})"
913
+ )
914
+
915
+ elif isinstance(node, ast.Name) and node.id == "__builtins__":
916
+ result.error(f"{rel_path}: access to __builtins__ is not allowed (line {node.lineno})")
917
+
918
+ elif isinstance(node, ast.Attribute) and node.attr in (
919
+ "__import__", "__builtins__", "__subclasses__",
920
+ "__globals__", "__code__", "__closure__",
921
+ ):
922
+ result.error(f"{rel_path}: access to {node.attr} is not allowed (line {node.lineno})")
923
+
924
+
925
+ def validate_package(pkg_dir: Path) -> ValidationResult:
926
+ result = ValidationResult()
927
+ validate_structure(pkg_dir, result)
928
+ manifest = validate_manifest(pkg_dir, result)
929
+ validate_backtest_yaml(pkg_dir, manifest, result)
930
+ validate_src_tree(pkg_dir, result)
931
+ return result
932
+
933
+
934
+ def main() -> None:
935
+ if len(sys.argv) < 2:
936
+ print(f"Usage: {sys.argv[0]} <package-directory>")
937
+ sys.exit(1)
938
+
939
+ pkg_dir = Path(sys.argv[1]).resolve()
940
+ if not pkg_dir.is_dir():
941
+ print(f"Error: {pkg_dir} is not a directory")
942
+ sys.exit(1)
943
+
944
+ print(f"Validating: {pkg_dir.name}/")
945
+ print()
946
+
947
+ result = validate_package(pkg_dir)
948
+
949
+ if result.warnings:
950
+ for w in result.warnings:
951
+ print(f" WARN {w}")
952
+ print()
953
+
954
+ if result.errors:
955
+ for e in result.errors:
956
+ print(f" FAIL {e}")
957
+ print()
958
+ print(f"Validation FAILED — {len(result.errors)} error(s)")
959
+ sys.exit(1)
960
+
961
+ print("Validation PASSED")
962
+
963
+
964
+ if __name__ == "__main__":
965
+ main()