programgarden 1.29.4__tar.gz → 1.30.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {programgarden-1.29.4 → programgarden-1.30.0}/PKG-INFO +2 -2
  2. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/executor.py +65 -7
  3. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/semantic_rules.py +99 -4
  4. {programgarden-1.29.4 → programgarden-1.30.0}/pyproject.toml +2 -2
  5. {programgarden-1.29.4 → programgarden-1.30.0}/README.md +0 -0
  6. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/__init__.py +0 -0
  7. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/binding_validator.py +0 -0
  8. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/client.py +0 -0
  9. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/code_worker.py +0 -0
  10. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/context.py +0 -0
  11. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/database/__init__.py +0 -0
  12. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/database/checkpoint_manager.py +0 -0
  13. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/database/query_builder.py +0 -0
  14. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/database/workflow_position_tracker.py +0 -0
  15. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  16. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/deep_fixtures.py +0 -0
  17. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/node_runner.py +0 -0
  18. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/plugin/__init__.py +0 -0
  19. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/plugin/sandbox.py +0 -0
  20. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/providers/__init__.py +0 -0
  21. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/providers/llm_errors.py +0 -0
  22. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/providers/llm_provider.py +0 -0
  23. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/reconnect_handler.py +0 -0
  24. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/resolver.py +0 -0
  25. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/resource/__init__.py +0 -0
  26. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/resource/context.py +0 -0
  27. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/resource/limiter.py +0 -0
  28. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/resource/monitor.py +0 -0
  29. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/resource/throttle.py +0 -0
  30. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.29.4 → programgarden-1.30.0}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.29.4
3
+ Version: 1.30.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -16,7 +16,7 @@ Requires-Dist: croniter (>=6.0.0,<7.0.0)
16
16
  Requires-Dist: litellm (>=1.40.0)
17
17
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
18
18
  Requires-Dist: programgarden-community (>=1.13.11,<2.0.0)
19
- Requires-Dist: programgarden-core (>=1.21.0,<2.0.0)
19
+ Requires-Dist: programgarden-core (>=1.22.0,<2.0.0)
20
20
  Requires-Dist: programgarden-finance (>=1.6.14,<2.0.0)
21
21
  Requires-Dist: psutil (>=6.0.0,<7.0.0)
22
22
  Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
@@ -1125,10 +1125,22 @@ def _pick_split_array(upstream: Dict[str, Any], *, source: str) -> List[Any]:
1125
1125
  if len(candidates) == 1:
1126
1126
  return upstream[candidates[0]]
1127
1127
  if not candidates:
1128
+ # ⑰ Diagnostic: a singular alias (value → values, item → items) present
1129
+ # where the plural array port was meant is a common wrong-key mistake;
1130
+ # surface it in the error so the fix is obvious (report-only — resolution
1131
+ # is unchanged, the two ports legitimately co-exist).
1132
+ _alias_hint = ""
1133
+ for _sing, _plur in (("value", "values"), ("item", "items")):
1134
+ if _sing in upstream and _plur not in upstream:
1135
+ _alias_hint = (
1136
+ f" Upstream has '{_sing}' (singular) but no '{_plur}' (array) "
1137
+ f"— did you mean '{_plur}'?"
1138
+ )
1139
+ break
1128
1140
  raise RuntimeError(
1129
1141
  f"SplitNode has no array to split: upstream ({source}) exposes no "
1130
- f"array output among {_SPLIT_ARRAY_KEYS}. Bind split.array explicitly "
1131
- "(e.g. array: '{{ nodes.<id>.held_symbols }}') or wire an "
1142
+ f"array output among {_SPLIT_ARRAY_KEYS}.{_alias_hint} Bind split.array "
1143
+ "explicitly (e.g. array: '{{ nodes.<id>.held_symbols }}') or wire an "
1132
1144
  "array-producing node upstream."
1133
1145
  )
1134
1146
  raise RuntimeError(
@@ -1548,10 +1560,26 @@ class ScheduleNodeExecutor(NodeExecutorBase):
1548
1560
  context.log("debug", f"Scheduler already running for {node_id}, skip", node_id)
1549
1561
  return {"trigger": True}
1550
1562
 
1551
- cron_expr = config.get("cron", "*/5 * * * *")
1563
+ # ⑭ cron is required — NO silent "*/5 * * * *" fallback. A missing cron
1564
+ # used to make the schedule quietly run every 5 minutes ("looks fine,
1565
+ # behaves differently"). Fail loudly instead so the real cause surfaces.
1566
+ cron_expr = config.get("cron")
1567
+ if not isinstance(cron_expr, str) or not cron_expr.strip():
1568
+ context.log(
1569
+ "error",
1570
+ "ScheduleNode requires a 'cron' expression; there is no default schedule fallback",
1571
+ node_id,
1572
+ )
1573
+ raise ValueError(
1574
+ f"ScheduleNode '{node_id}' requires a 'cron' expression; there is no "
1575
+ f"default schedule fallback. Set cron explicitly, e.g. '*/5 * * * *' "
1576
+ f"(every 5 min) or '30 9 * * 1-5' (09:30 weekdays). Keys like "
1577
+ f"'mode'/'schedule' are not read by this node."
1578
+ )
1579
+ cron_expr = cron_expr.strip()
1552
1580
  tz_name = config.get("timezone", "America/New_York")
1553
1581
  enabled = config.get("enabled", True)
1554
- count = config.get("count", 1000) # 최대 반복 횟수 (M-6: 기본값 축소)
1582
+ count = config.get("count", 1000) # 최대 반복 횟수 (M-6: 기본값 축소; 모델 필드로 편입)
1555
1583
  max_duration_hours = config.get("max_duration_hours", 24.0)
1556
1584
 
1557
1585
  if not enabled:
@@ -10594,7 +10622,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
10594
10622
  except (ValueError, _json.JSONDecodeError):
10595
10623
  pass
10596
10624
 
10597
- # 우선순위: config.symbol > input.symbol
10625
+ # 우선순위: config.symbol > input.symbol > config.symbols (수동 목록 폴백)
10598
10626
  if config_symbol:
10599
10627
  symbols_raw = [config_symbol] if isinstance(config_symbol, dict) else []
10600
10628
  context.log("debug", f"Using config.symbol: {config_symbol}", node_id)
@@ -10602,7 +10630,23 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
10602
10630
  symbols_raw = [input_symbol] if isinstance(input_symbol, dict) else []
10603
10631
  context.log("debug", f"Using input port symbol: {input_symbol}", node_id)
10604
10632
  else:
10605
- symbols_raw = []
10633
+ # 마지막 폴백: config.symbols (복수 리터럴 목록) — MarketDataNodeExecutor 의
10634
+ # config_symbols 폴백과 대칭. 상류 배열 입력은 메인 루프 auto-iterate 가
10635
+ # 단수 input.symbol 로 풀어 위 분기에서 소화되므로, 여기 도달하는 건
10636
+ # 리터럴 목록뿐이라 N² 재조회 우려가 없다. deep_validate 픽스처는 이미
10637
+ # _config_symbols() 로 plural 을 읽어 라이브만 조용히 무시하던 비대칭이었다
10638
+ # (실측: code-node 예제 87~93 이 심볼 3개를 적고도 빈 ohlcv 로 사망).
10639
+ config_symbols = config.get("symbols")
10640
+ if isinstance(config_symbols, list):
10641
+ symbols_raw = [e for e in config_symbols if isinstance(e, (dict, str))]
10642
+ if symbols_raw:
10643
+ context.log(
10644
+ "debug",
10645
+ f"Using config.symbols list: {len(symbols_raw)} entries",
10646
+ node_id,
10647
+ )
10648
+ else:
10649
+ symbols_raw = []
10606
10650
 
10607
10651
  # symbols 정규화: [{exchange, symbol}] → 문자열 리스트 + exchange 정보 보존
10608
10652
  symbols = []
@@ -12386,7 +12430,21 @@ class PositionSizingNodeExecutor(NodeExecutorBase):
12386
12430
  price_data = evaluated.get("price_data") or evaluated.get("prices") or evaluated.get("market_data") or {}
12387
12431
 
12388
12432
  # 설정 추출
12389
- method = evaluated.get("method", "fixed_percent")
12433
+ # ⑰ method is required — NO silent "fixed_percent" fallback. The old
12434
+ # default quietly confirmed a hallucinated key (e.g. size_type:"fixed"
12435
+ # left method unset → sized as fixed_percent). Fail loudly instead.
12436
+ method = evaluated.get("method")
12437
+ if not method:
12438
+ context.log(
12439
+ "error",
12440
+ "PositionSizingNode requires 'method'; there is no default fallback",
12441
+ node_id,
12442
+ )
12443
+ raise ValueError(
12444
+ f"PositionSizingNode '{node_id}' requires 'method' (one of "
12445
+ f"fixed_percent, fixed_amount, fixed_quantity, kelly, atr_based); "
12446
+ f"there is no default. A key like 'size_type' is not read by this node."
12447
+ )
12390
12448
  max_percent = float(evaluated.get("max_percent", 10.0))
12391
12449
  fixed_amount = evaluated.get("fixed_amount")
12392
12450
  fixed_quantity = int(evaluated.get("fixed_quantity", 1))
@@ -7,8 +7,8 @@ workflows that are still *semantically* wrong or *unsafe* — they validate
7
7
  *executability*, never *intent* or *safety*.
8
8
 
9
9
  The four ground-truth anti-patterns (verified against the canonical example
10
- workflows shipped in ``programgarden/examples/workflows`` and the live node
11
- registry):
10
+ corpus — maintained internally since the 2026-08 privatization — and the live
11
+ node registry):
12
12
 
13
13
  * **R1 — order quantity bound to an AIAgent response.** An AIAgent
14
14
  sentiment/decision output wired straight into an order quantity risks a
@@ -27,6 +27,15 @@ registry):
27
27
  Always trades the same size regardless of balance/risk. (Advisory.)
28
28
  * **R4 — order node carrying ``paper_trading``.** That field is a *broker*
29
29
  field; on an order node it is silently ignored. (Advisory.)
30
+ * **R5 — unknown node config key.** A key that is not in the node's
31
+ ``get_field_schema()`` nor its declared model fields. ``BaseNode`` sets
32
+ ``extra="allow"`` so Pydantic accepts it, and the executor never reads it —
33
+ the workflow *looks* configured but behaves differently (e.g. ScheduleNode
34
+ ``{mode, schedule}`` instead of ``cron`` → silent 5-min fallback;
35
+ PositionSizingNode ``size_type`` instead of ``method``). (Blocking when
36
+ enabled.) Structural, not keyword-based: the allowed set is derived from the
37
+ registry node class, and unknown / community / dynamic types (no resolvable
38
+ class) are skipped rather than false-flagged.
30
39
 
31
40
  Design contract
32
41
  ---------------
@@ -58,6 +67,7 @@ from programgarden_core import (
58
67
  ErrorLocation,
59
68
  ErrorSeverity,
60
69
  build_error,
70
+ suggest_close_match,
61
71
  )
62
72
 
63
73
  # ---------------------------------------------------------------------------
@@ -68,12 +78,14 @@ RULE_ORDER_QTY_FROM_AI = "order_qty_from_ai_response" # R1
68
78
  RULE_STRUCTURED_OUTPUT_NO_SCHEMA = "structured_output_missing_schema" # R2
69
79
  RULE_HARDCODED_ORDER_QTY = "hardcoded_order_quantity" # R3
70
80
  RULE_ORDER_IGNORED_FIELD = "order_ignored_field" # R4
81
+ RULE_UNKNOWN_NODE_FIELD = "unknown_node_field" # R5
71
82
 
72
83
  ALL_RULES: tuple[str, ...] = (
73
84
  RULE_ORDER_QTY_FROM_AI,
74
85
  RULE_STRUCTURED_OUTPUT_NO_SCHEMA,
75
86
  RULE_HARDCODED_ORDER_QTY,
76
87
  RULE_ORDER_IGNORED_FIELD,
88
+ RULE_UNKNOWN_NODE_FIELD,
77
89
  )
78
90
 
79
91
  _RULE_CODE: Dict[str, ErrorCode] = {
@@ -81,18 +93,23 @@ _RULE_CODE: Dict[str, ErrorCode] = {
81
93
  RULE_STRUCTURED_OUTPUT_NO_SCHEMA: ErrorCode.SEMANTIC_STRUCTURED_OUTPUT_NO_SCHEMA,
82
94
  RULE_HARDCODED_ORDER_QTY: ErrorCode.SEMANTIC_HARDCODED_ORDER_QTY,
83
95
  RULE_ORDER_IGNORED_FIELD: ErrorCode.SEMANTIC_ORDER_IGNORED_FIELD,
96
+ RULE_UNKNOWN_NODE_FIELD: ErrorCode.UNKNOWN_NODE_FIELD,
84
97
  }
85
98
 
86
99
  # Everything off — the default when no config is supplied.
87
100
  DEFAULT_SEMANTIC_SEVERITIES: Dict[str, str] = {r: "off" for r in ALL_RULES}
88
101
 
89
- # Chatbot strict preset — reproduces the legacy ``workflow_semantic_lint``
90
- # behavior exactly (R1/R2 block, R3/R4 advise). Callers opt in with this.
102
+ # Chatbot strict preset — the save-chokepoint anti-pattern gate (R1/R2/R5
103
+ # block, R3/R4 advise). R5 blocks hallucinated / typo'd node config keys
104
+ # (e.g. ScheduleNode {mode,schedule} instead of cron, PositionSizingNode
105
+ # size_type instead of method) that ``extra="allow"`` silently accepts — the
106
+ # workflow looks configured but the extra keys are ignored at runtime.
91
107
  STRICT_SEMANTIC_SEVERITIES: Dict[str, str] = {
92
108
  RULE_ORDER_QTY_FROM_AI: "error",
93
109
  RULE_STRUCTURED_OUTPUT_NO_SCHEMA: "error",
94
110
  RULE_HARDCODED_ORDER_QTY: "warning",
95
111
  RULE_ORDER_IGNORED_FIELD: "warning",
112
+ RULE_UNKNOWN_NODE_FIELD: "error",
96
113
  }
97
114
 
98
115
  _SEVERITY_MAP: Dict[str, ErrorSeverity] = {
@@ -171,6 +188,47 @@ def _is_aiagent_node(node_type: Any) -> bool:
171
188
  return False
172
189
 
173
190
 
191
+ # Structural keys present on every node dict but not always declared as a schema
192
+ # / model field. ``config`` / ``description`` are BaseNode fields; ``plugin`` /
193
+ # ``fields`` are the ConditionNode plugin-binding structure carried at node level.
194
+ # ``credential_id`` is the canonical credential-binding key (``nodes[].credential_id``
195
+ # + top-level ``credentials[]``): the executor's universal node-creation layer reads
196
+ # it (``config.get("credential_id")`` → ``_inject_credentials``) for every node type,
197
+ # so no per-node schema declares it — flagging it would block every credential-bound
198
+ # workflow at the strict save gate.
199
+ _RESERVED_NODE_KEYS: frozenset = frozenset(
200
+ {"id", "type", "category", "position", "config", "description", "plugin",
201
+ "fields", "credential_id"}
202
+ )
203
+
204
+
205
+ def _node_allowed_keys(node_type: Any) -> Optional[frozenset]:
206
+ """Allowed top-level config keys for a registered node type, or None.
207
+
208
+ The allowed set is the union of the node's declared Pydantic model fields,
209
+ its ``get_field_schema()`` keys (the AI/UI-facing schema), and the reserved
210
+ structural keys. Returns None when ``node_type`` does not resolve to a
211
+ registered class (unknown / community / dynamic) — R5 skips those, since
212
+ their schema is unknown and any key would otherwise be false-flagged.
213
+ Never raises.
214
+ """
215
+ cls = _node_class(node_type)
216
+ if cls is None:
217
+ return None
218
+ allowed = set(_RESERVED_NODE_KEYS)
219
+ try:
220
+ allowed |= set(cls.model_fields.keys())
221
+ except Exception: # pragma: no cover - defensive
222
+ pass
223
+ try:
224
+ fs = cls.get_field_schema()
225
+ if isinstance(fs, dict):
226
+ allowed |= set(fs.keys())
227
+ except Exception: # pragma: no cover - defensive
228
+ pass
229
+ return frozenset(allowed)
230
+
231
+
174
232
  # ---------------------------------------------------------------------------
175
233
  # Binding helpers
176
234
  # ---------------------------------------------------------------------------
@@ -261,6 +319,7 @@ def analyze_workflow_semantics(
261
319
  sev_r2 = _enabled(severities, RULE_STRUCTURED_OUTPUT_NO_SCHEMA)
262
320
  sev_r3 = _enabled(severities, RULE_HARDCODED_ORDER_QTY)
263
321
  sev_r4 = _enabled(severities, RULE_ORDER_IGNORED_FIELD)
322
+ sev_r5 = _enabled(severities, RULE_UNKNOWN_NODE_FIELD)
264
323
 
265
324
  # Index node id → node, and whether any PositionSizingNode exists (R3).
266
325
  nodes_by_id: Dict[str, Dict[str, Any]] = {}
@@ -282,6 +341,41 @@ def analyze_workflow_semantics(
282
341
  node_type = node.get("type")
283
342
  node_id = node.get("id") if isinstance(node.get("id"), str) else "?"
284
343
 
344
+ # ── R5: unknown node config key (hallucinated / typo'd field) ──────
345
+ # Runs for every node. Structural: the allowed set comes from the
346
+ # registry node class; unknown / dynamic types (allowed is None) are
347
+ # skipped rather than false-flagged.
348
+ if sev_r5 is not None:
349
+ allowed = _node_allowed_keys(node_type)
350
+ if allowed is not None:
351
+ for key in node.keys():
352
+ if key in allowed:
353
+ continue
354
+ errors.append(build_error(
355
+ ErrorCode.UNKNOWN_NODE_FIELD,
356
+ f"Node '{node_id}' ({node_type}) has field '{key}', which is not "
357
+ f"in this node's schema. Pydantic accepts it (extra='allow') but "
358
+ f"the executor never reads it, so the workflow looks configured "
359
+ f"but behaves differently than it appears.",
360
+ severity=sev_r5,
361
+ location=ErrorLocation(
362
+ node_id=node_id,
363
+ node_type=node_type if isinstance(node_type, str) else None,
364
+ field_path=key if isinstance(key, str) else None,
365
+ ),
366
+ available_values=(
367
+ suggest_close_match(key, sorted(allowed))
368
+ if isinstance(key, str) else None
369
+ ),
370
+ suggestion=(
371
+ f"'{key}' 는 {node_type} 노드에 없는 필드예요. 오탈자이거나 "
372
+ f"다른 노드의 필드일 수 있습니다. 이 노드의 config_schema 에 "
373
+ f"정의된 필드만 사용하세요 (예: ScheduleNode 는 cron, "
374
+ f"PositionSizingNode 는 method)."
375
+ ),
376
+ details={"rule": RULE_UNKNOWN_NODE_FIELD, "field": key},
377
+ ))
378
+
285
379
  # ── R2: AIAgent structured output with no schema (and no preset) ──
286
380
  if sev_r2 is not None and _is_aiagent_node(node_type):
287
381
  if node.get("output_format") == "structured":
@@ -396,6 +490,7 @@ __all__ = [
396
490
  "RULE_STRUCTURED_OUTPUT_NO_SCHEMA",
397
491
  "RULE_HARDCODED_ORDER_QTY",
398
492
  "RULE_ORDER_IGNORED_FIELD",
493
+ "RULE_UNKNOWN_NODE_FIELD",
399
494
  "ALL_RULES",
400
495
  "DEFAULT_SEMANTIC_SEVERITIES",
401
496
  "STRICT_SEMANTIC_SEVERITIES",
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.29.4"
8
+ version = "1.30.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -29,7 +29,7 @@ lxml = "^6.0.2"
29
29
  pytickersymbols = {version = ">=1.17.5", python = ">=3.12,<4.0"}
30
30
  aiosqlite = "^0.20.0"
31
31
  litellm = ">=1.40.0"
32
- programgarden-core = "^1.21.0"
32
+ programgarden-core = "^1.22.0"
33
33
  programgarden-finance = "^1.6.14"
34
34
  programgarden-community = "^1.13.11"
35
35
 
File without changes