programgarden 1.41.2__tar.gz → 2.0.1__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 (55) hide show
  1. {programgarden-1.41.2 → programgarden-2.0.1}/PKG-INFO +5 -5
  2. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/code_worker.py +1 -1
  3. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/executor.py +137 -4
  4. programgarden-2.0.1/programgarden/replay_contracts.py +218 -0
  5. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/resolver.py +56 -1
  6. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/validation_recommender.py +0 -1
  7. {programgarden-1.41.2 → programgarden-2.0.1}/pyproject.toml +6 -6
  8. {programgarden-1.41.2 → programgarden-2.0.1}/README.md +0 -0
  9. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/__init__.py +0 -0
  10. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/binding_validator.py +0 -0
  11. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/client.py +0 -0
  12. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/context.py +0 -0
  13. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/__init__.py +0 -0
  14. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/adjustment_delivery.py +0 -0
  15. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/broker_evidence.py +0 -0
  16. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/broker_order_totals.py +0 -0
  17. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/broker_snapshot.py +0 -0
  18. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/checkpoint_manager.py +0 -0
  19. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/db_naming.py +0 -0
  20. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/execution_storage.py +0 -0
  21. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/fill_reconciler.py +0 -0
  22. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/order_cancellation.py +0 -0
  23. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/order_recovery.py +0 -0
  24. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/owned_order_cancellation.py +0 -0
  25. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/position_reconciliation.py +0 -0
  26. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/query_builder.py +0 -0
  27. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/workflow_position_tracker.py +0 -0
  28. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/database/workflow_risk_tracker.py +0 -0
  29. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/deep_fixtures.py +0 -0
  30. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/futures_orderable.py +0 -0
  31. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/futures_pnl.py +0 -0
  32. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/futures_read_evidence.py +0 -0
  33. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/managed_order_control.py +0 -0
  34. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/node_runner.py +0 -0
  35. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/order_lifecycle.py +0 -0
  36. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/plugin/__init__.py +0 -0
  37. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/plugin/sandbox.py +0 -0
  38. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/providers/__init__.py +0 -0
  39. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/providers/llm_errors.py +0 -0
  40. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/providers/llm_provider.py +0 -0
  41. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/reconnect_handler.py +0 -0
  42. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/resource/__init__.py +0 -0
  43. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/resource/context.py +0 -0
  44. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/resource/limiter.py +0 -0
  45. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/resource/monitor.py +0 -0
  46. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/resource/throttle.py +0 -0
  47. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/semantic_rules.py +0 -0
  48. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/tools/__init__.py +0 -0
  49. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/tools/credential_tools.py +0 -0
  50. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/tools/definition_tools.py +0 -0
  51. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/tools/event_tools.py +0 -0
  52. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/tools/job_tools.py +0 -0
  53. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/tools/registry_tools.py +0 -0
  54. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/tools/sqlite_tools.py +0 -0
  55. {programgarden-1.41.2 → programgarden-2.0.1}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.41.2
3
+ Version: 2.0.1
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -15,12 +15,12 @@ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
15
15
  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
- Requires-Dist: programgarden-community (>=1.15.3,<2.0.0)
19
- Requires-Dist: programgarden-core (>=1.31.0,<2.0.0)
20
- Requires-Dist: programgarden-finance (>=1.10.5,<2.0.0)
18
+ Requires-Dist: programgarden-community (>=2.0.1,<3.0.0)
19
+ Requires-Dist: programgarden-core (>=2.0.1,<3.0.0)
20
+ Requires-Dist: programgarden-finance (>=2.0.1,<3.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)
23
- Requires-Dist: pydantic (>=2.0.0,<3.0.0)
23
+ Requires-Dist: pydantic (>=2.0.1,<3.0.0)
24
24
  Requires-Dist: python-dotenv (>=1.1.0,<2.0.0)
25
25
  Requires-Dist: pytickersymbols (>=1.17.5) ; python_version >= "3.12" and python_version < "4.0"
26
26
  Requires-Dist: tzdata (>=2025.2,<2026.0)
@@ -73,7 +73,7 @@ class _SandboxedContext:
73
73
  DateNamespace, FinanceNamespace, StatsNamespace,
74
74
  FormatNamespace, ListNamespace,
75
75
  )
76
- self.date = DateNamespace()
76
+ self.date = DateNamespace(as_of=snapshot.get("validation_as_of"))
77
77
  self.finance = FinanceNamespace()
78
78
  self.stats = StatsNamespace()
79
79
  self.format = FormatNamespace()
@@ -10946,7 +10946,7 @@ class ConditionNodeExecutor(NodeExecutorBase):
10946
10946
  # the risk → order → notify branch is exercised.
10947
10947
  if getattr(context, "is_deep_validate", False) and positions and not _passed:
10948
10948
  _passed = [
10949
- {"symbol": p.get("symbol"), "exchange": p.get("exchange", "UNKNOWN")}
10949
+ dict(p)
10950
10950
  for p in positions if isinstance(p, dict) and p.get("symbol")
10951
10951
  ]
10952
10952
  context.log(
@@ -11018,6 +11018,7 @@ class ConditionNodeExecutor(NodeExecutorBase):
11018
11018
  )
11019
11019
 
11020
11020
  # === 플러그인 required_fields 검증 ===
11021
+ required_fields = []
11021
11022
  if plugin_schema and hasattr(plugin_schema, 'required_fields'):
11022
11023
  required_fields = plugin_schema.required_fields or []
11023
11024
  extract = items_config.get("extract", {})
@@ -11057,6 +11058,20 @@ class ConditionNodeExecutor(NodeExecutorBase):
11057
11058
  "values": [],
11058
11059
  }
11059
11060
 
11061
+ if context.is_deep_validate:
11062
+ # Wrapper rows can have the right keys but no OHLCV values.
11063
+ # A fixture's missing fields are wiring failures, not no-signal.
11064
+ missing_values = [
11065
+ key for key in required_fields
11066
+ if all(row.get(key) in (None, "") for row in data)
11067
+ ]
11068
+ if missing_values:
11069
+ raise ValueError(
11070
+ f"ConditionNode '{node_id}' extracted no values for "
11071
+ f"{missing_values}. Bind items.from to the bar array "
11072
+ "(for example item.time_series), not per-symbol wrappers."
11073
+ )
11074
+
11060
11075
  # symbols 자동 추출 (data에서)
11061
11076
  symbols = []
11062
11077
  seen = set()
@@ -11210,9 +11225,13 @@ class ConditionNodeExecutor(NodeExecutorBase):
11210
11225
  values = result.get("values", [])
11211
11226
 
11212
11227
  except PluginTimeoutError as e:
11228
+ if context.is_deep_validate:
11229
+ raise
11213
11230
  context.log("error", f"Plugin timeout: {e}", node_id)
11214
11231
  failed_symbols = normalized_symbols
11215
11232
  except Exception as e:
11233
+ if context.is_deep_validate:
11234
+ raise
11216
11235
  context.log("error", f"Plugin error: {e}", node_id)
11217
11236
  import traceback
11218
11237
  context.log("debug", f"Plugin traceback: {traceback.format_exc()}", node_id)
@@ -11242,6 +11261,18 @@ class ConditionNodeExecutor(NodeExecutorBase):
11242
11261
  # "no signal this pass"). Deep validation's job is flow/field/type
11243
11262
  # integrity, not reproducing a market signal, so guarantee at least the
11244
11263
  # input symbols flow through. Runtime / dry_run are untouched.
11264
+ if (
11265
+ context.is_deep_validate
11266
+ and symbol_results
11267
+ and all(row.get("error") for row in symbol_results)
11268
+ ):
11269
+ raise ValueError(
11270
+ f"ConditionNode '{node_id}' could not evaluate any symbol: "
11271
+ f"{sorted({str(row['error']) for row in symbol_results})}. "
11272
+ "Check extracted bar fields and indicator lookback. If the default "
11273
+ "fixture is too short, provide a longer deep-validation fixture."
11274
+ )
11275
+
11245
11276
  if getattr(context, "is_deep_validate", False) and normalized_symbols and not passed_symbols:
11246
11277
  passed_symbols = list(normalized_symbols)
11247
11278
  failed_symbols = []
@@ -14509,6 +14540,14 @@ class PositionSizingNodeExecutor(NodeExecutorBase):
14509
14540
  )
14510
14541
 
14511
14542
  # 방법별 수량 계산
14543
+ if context.is_deep_validate and any(
14544
+ not self._get_price(row.get("symbol", ""), row, price_data, context, node_id)
14545
+ for row in symbols
14546
+ ):
14547
+ raise ValueError(
14548
+ "Position sizing has candidates without usable prices. Bind market_data "
14549
+ "to matching quote rows or retain price on each candidate."
14550
+ )
14512
14551
  if method == "fixed_percent":
14513
14552
  result = self._calc_fixed_percent(
14514
14553
  symbols, available_balance, max_percent, price_data, context, node_id
@@ -15555,6 +15594,25 @@ class NewOrderNodeExecutor(NodeExecutorBase):
15555
15594
 
15556
15595
  # dry_run: LS API 미호출, 모의 응답 반환
15557
15596
  if context.is_dry_run:
15597
+ if context.is_deep_validate:
15598
+ # Exercise the actual payload contract before simulating success.
15599
+ # Do not enter connection, balance, quote or broker request paths.
15600
+ config = evaluate_all_bindings(config, context, node_id)
15601
+ if not self._normalize_order(config.get("order"), config, context, node_id):
15602
+ upstream = _input_namespace(context, node_id)
15603
+ if (
15604
+ config.get("order") is None
15605
+ and isinstance(upstream, dict)
15606
+ and upstream.get("reason") == EmptyOrderReason.NO_SIGNAL.value
15607
+ and self._extract_upstream_error(upstream) is None
15608
+ ):
15609
+ return self._empty_result(EmptyOrderReason.NO_SIGNAL)
15610
+ raise ValueError(
15611
+ f"{node_type} requires order to resolve to an object with "
15612
+ "a symbol and a positive whole-share/contract quantity. "
15613
+ "Use the current sizing order item or a position retaining "
15614
+ "quantity; a symbol string alone is not an order."
15615
+ )
15558
15616
  import uuid
15559
15617
  order_id = f"DRYRUN-{uuid.uuid4()}"
15560
15618
  context.log(
@@ -20394,6 +20452,9 @@ class CodeNodeExecutor(NodeExecutorBase):
20394
20452
  "iteration_total": int(getattr(context, "_iteration_total", 0) or 0),
20395
20453
  "risk": {},
20396
20454
  }
20455
+ validation_as_of = getattr(context, "validation_as_of", None)
20456
+ if validation_as_of is not None:
20457
+ snap["validation_as_of"] = validation_as_of
20397
20458
  try:
20398
20459
  rt = context.risk_tracker
20399
20460
  if rt is not None and hasattr(rt, "get_all_hwm"):
@@ -20471,6 +20532,30 @@ class CodeNodeExecutor(NodeExecutorBase):
20471
20532
 
20472
20533
  value = env.get("value")
20473
20534
 
20535
+ if context.is_deep_validate or getattr(context, "is_replay_validation", False):
20536
+ from programgarden.replay_contracts import ContractViolation, check_contract, check_codenode_ports
20537
+ ports = config.get("outputs", [])
20538
+ try:
20539
+ check_codenode_ports(ports)
20540
+ for index, port in enumerate(ports):
20541
+ name = port["name"]
20542
+ if isinstance(value, dict):
20543
+ if name not in value:
20544
+ raise ContractViolation(f"{node_id}.{name}", "Declared output is absent")
20545
+ item = value[name]
20546
+ elif len(ports) == 1:
20547
+ # The existing single-port scalar mapping is explicit
20548
+ # public behavior; validate its value without coercion.
20549
+ item = value
20550
+ else:
20551
+ raise ContractViolation(node_id, "Multiple declared ports require a result object")
20552
+ if port.get("type", "any") != "any":
20553
+ check_contract(item, {"type":port["type"]}, f"{node_id}.{name}")
20554
+ except ContractViolation as exc:
20555
+ raise CodeNodeError(ErrorCode.CODE_NODE_EXEC_ERROR, str(exc),
20556
+ suggestion="Return every declared output with its declared JSON type; do not substitute missing values with zero.",
20557
+ details=exc.as_dict()) from exc
20558
+
20474
20559
  # Map return → declared ports. No declared ports → whole value on result.
20475
20560
  if not declared:
20476
20561
  return {"result": value}
@@ -22078,6 +22163,20 @@ class WorkflowJob:
22078
22163
  },
22079
22164
  )
22080
22165
 
22166
+ if (
22167
+ self.context.is_deep_validate
22168
+ and node.node_type == "PositionSizingNode"
22169
+ and outputs.get("reason") in {"no_symbol", "fetch_failed"}
22170
+ ):
22171
+ from programgarden_core import ErrorCode, ErrorLocation, build_error
22172
+ self._node_error_infos[node_id] = build_error(
22173
+ ErrorCode.DEEP_VALIDATION_NODE_ERROR,
22174
+ f"Position sizing inputs are invalid: {outputs.get('detail', '')}",
22175
+ location=ErrorLocation(node_id=node_id, node_type=node.node_type),
22176
+ suggestion="Bind symbols, balance and price data required by the sizing method.",
22177
+ details={"stage": "sizing_inputs", "reason": outputs["reason"]},
22178
+ )
22179
+
22081
22180
  # An order node that failed did NOT raise — it returned
22082
22181
  # order_result.success=False. Reporting that as COMPLETED leaves
22083
22182
  # errors_count=0 / last_error=None, i.e. every observable surface
@@ -22687,6 +22786,16 @@ class WorkflowJob:
22687
22786
  all_results.append(outputs)
22688
22787
  except Exception as e:
22689
22788
  self.context.log("warning", f"Auto-iterate [{idx+1}/{total}] failed: {e}", node_id)
22789
+ if getattr(self.context, "is_deep_validate", False):
22790
+ from programgarden_core import ErrorCode, ErrorLocation, build_error
22791
+ # A later successful item must not erase an earlier failure.
22792
+ self._node_error_infos.setdefault(node_id, build_error(
22793
+ ErrorCode.DEEP_VALIDATION_NODE_ERROR,
22794
+ f"Node '{node_id}' failed at iteration {idx + 1}/{total}: {e}",
22795
+ location=ErrorLocation(node_id=node_id, node_type=node.node_type),
22796
+ suggestion="Check this item's input fields and bindings before executing the workflow.",
22797
+ details={"stage": "auto_iteration", "iteration_index": idx},
22798
+ ))
22690
22799
  # continue_on_error: 기본적으로 계속 진행
22691
22800
  all_results.append({"error": str(e), "item": current_item})
22692
22801
  finally:
@@ -22735,12 +22844,32 @@ class WorkflowJob:
22735
22844
  """
22736
22845
  if total <= 1 or not isinstance(current_item, dict):
22737
22846
  return item_config
22847
+ # These executors already select one symbol from the iteration context.
22848
+ # Keep the generic warning for other consumers and conflicting overrides.
22849
+ if (
22850
+ not self.context.is_deep_validate
22851
+ and node_type in {
22852
+ "OverseasStockMarketDataNode", "OverseasFuturesMarketDataNode",
22853
+ "KoreaStockMarketDataNode", "OverseasStockHistoricalDataNode",
22854
+ "OverseasFuturesHistoricalDataNode", "KoreaStockHistoricalDataNode",
22855
+ }
22856
+ and current_item.get("symbol")
22857
+ and all(
22858
+ not override or self._same_symbol_entry(override, current_item)
22859
+ for override in (
22860
+ item_config.get("symbol"),
22861
+ self.context.get_output(f"_input_{node_id}", "symbol"),
22862
+ )
22863
+ )
22864
+ ):
22865
+ return item_config
22738
22866
  narrowed = None
22739
22867
  for port in self._WHOLE_ARRAY_REEVAL_PORTS:
22740
22868
  value = item_config.get(port)
22741
22869
  if not isinstance(value, list) or len(value) != total:
22742
22870
  continue
22743
- if not any(self._same_symbol_entry(entry, current_item) for entry in value):
22871
+ matching = [entry for entry in value if self._same_symbol_entry(entry, current_item)]
22872
+ if not matching:
22744
22873
  continue
22745
22874
  simulated = bool(getattr(self.context, "is_dry_run", False))
22746
22875
  self.context.log(
@@ -22754,7 +22883,11 @@ class WorkflowJob:
22754
22883
  if simulated:
22755
22884
  if narrowed is None:
22756
22885
  narrowed = dict(item_config)
22757
- narrowed[port] = [current_item]
22886
+ # Preserve the configured projection and quantity. The upstream
22887
+ # item can contain quotes or other numeric fields that are not
22888
+ # inputs to this node (e.g. a symbol-only market-data contract).
22889
+ # Substituting that raw item changed valid bindings in validation.
22890
+ narrowed[port] = [matching[0]]
22758
22891
  return narrowed if narrowed is not None else item_config
22759
22892
 
22760
22893
  def _merge_iterate_results(self, results: list) -> Dict[str, Any]:
@@ -23545,7 +23678,7 @@ class WorkflowJob:
23545
23678
  if self.context._iteration_item is None:
23546
23679
  for k in list(config_copy.keys()):
23547
23680
  v = config_copy[k]
23548
- if isinstance(v, str) and "{{ item" in v:
23681
+ if self._references_iteration_item(v):
23549
23682
  deferred[k] = config_copy.pop(k)
23550
23683
 
23551
23684
  deep_mode = bool(getattr(self.context, "is_deep_validate", False))
@@ -0,0 +1,218 @@
1
+ """Deterministic contracts at replay input, mapping and output boundaries.
2
+
3
+ Contracts use an intentionally bounded JSON-schema subset. Unknown constraints
4
+ fail closed; values are never coerced, defaulted, renamed or replaced with zero.
5
+ Semantic identity is explicit contract data, not inferred from field names.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+ from decimal import Decimal, InvalidOperation
11
+ import math
12
+ import re
13
+ from typing import Any
14
+
15
+ CONTRACT_VERSION = "replay-contract-1"
16
+ _KEYS = frozenset({"type", "nullable", "required", "properties", "items", "enum",
17
+ "minimum", "maximum", "minItems", "maxItems", "minLength",
18
+ "maxLength", "const", "additionalProperties", "format",
19
+ "decimalPlaces", "description"})
20
+ _TYPES = {"object": dict, "array": list, "string": str, "number": (int, float),
21
+ "integer": int, "boolean": bool, "null": type(None)}
22
+
23
+
24
+ class ContractViolation(ValueError):
25
+ def __init__(self, path: str, reason: str, code: str = "REPLAY_CONTRACT_FAILED"):
26
+ self.path, self.reason, self.code = path, reason, code
27
+ super().__init__(f"{path}: {reason}")
28
+
29
+ def as_dict(self) -> dict[str, str]:
30
+ return {"code": self.code, "path": self.path, "message": str(self)}
31
+
32
+
33
+ def finite_json(value: Any, path: str = "$", depth: int = 0) -> None:
34
+ if depth > 40:
35
+ raise ContractViolation(path, "JSON nesting exceeds the validation limit")
36
+ if value is None or type(value) in (str, int, bool):
37
+ return
38
+ if type(value) is float:
39
+ if not math.isfinite(value):
40
+ raise ContractViolation(path, "NaN and Infinity are not valid evidence")
41
+ return
42
+ if type(value) is dict:
43
+ for key, item in value.items():
44
+ if not isinstance(key, str):
45
+ raise ContractViolation(path, "Object keys must be strings")
46
+ finite_json(item, f"{path}.{key}", depth + 1)
47
+ return
48
+ if type(value) is list:
49
+ for index, item in enumerate(value):
50
+ finite_json(item, f"{path}[{index}]", depth + 1)
51
+ return
52
+ raise ContractViolation(path, "Expected finite JSON data")
53
+
54
+
55
+ def check_contract(value: Any, schema: dict[str, Any], path: str = "$") -> None:
56
+ """Validate a finite JSON value without coercion; raise at the first defect."""
57
+ finite_json(value, path)
58
+ finite_json(schema, "contract")
59
+ _validate_schema(schema, "contract", 0)
60
+ _check(value, schema, path, 0)
61
+
62
+
63
+ def _literal_equal(left, right):
64
+ """JSON identities do not coerce nested True/1 or False/0 values."""
65
+ if type(left) in (int,float) and type(right) in (int,float):
66
+ return Decimal(str(left))==Decimal(str(right))
67
+ if type(left) is not type(right):
68
+ return False
69
+ if isinstance(left, dict):
70
+ return left.keys()==right.keys() and all(_literal_equal(left[key],right[key]) for key in left)
71
+ if isinstance(left, list):
72
+ return len(left)==len(right) and all(_literal_equal(a,b) for a,b in zip(left,right))
73
+ return left==right
74
+
75
+
76
+ def _validate_schema(schema: Any, path: str, depth: int) -> None:
77
+ def invalid(reason):
78
+ raise ContractViolation(path, reason, "REPLAY_CONTRACT_UNSUPPORTED")
79
+ if depth > 40 or type(schema) is not dict or set(schema) - _KEYS:
80
+ invalid("Unsupported contract")
81
+ kinds = schema.get("type")
82
+ kinds = kinds if type(kinds) is list else [kinds]
83
+ if not kinds or any(type(k) is not str or k not in _TYPES for k in kinds):
84
+ invalid("Explicit supported type required")
85
+ if len(kinds) != len(set(kinds)):
86
+ invalid("Duplicate types are not supported")
87
+ for key in ("nullable", "additionalProperties"):
88
+ if key in schema and type(schema[key]) is not bool:
89
+ invalid(f"{key} must be boolean")
90
+ if "enum" in schema and (type(schema["enum"]) is not list or not schema["enum"]):
91
+ invalid("enum must be a nonempty array")
92
+ for key in ("minItems", "maxItems", "minLength", "maxLength", "decimalPlaces"):
93
+ if key in schema and (type(schema[key]) is not int or schema[key] < 0):
94
+ invalid(f"{key} must be a nonnegative integer")
95
+ if schema.get("decimalPlaces", 0) > 18:
96
+ invalid("decimalPlaces must not exceed 18")
97
+ for key in ("minimum", "maximum"):
98
+ if key in schema and type(schema[key]) not in (int, float):
99
+ invalid(f"{key} must be a finite number")
100
+ for lower, upper in (("minimum", "maximum"), ("minItems", "maxItems"), ("minLength", "maxLength")):
101
+ if lower in schema and upper in schema and schema[lower] > schema[upper]:
102
+ invalid(f"{lower} exceeds {upper}")
103
+ applicable = {"object": {"properties", "required", "additionalProperties"},
104
+ "array": {"items", "minItems", "maxItems"},
105
+ "string": {"minLength", "maxLength", "format"},
106
+ "number": {"minimum", "maximum", "decimalPlaces"},
107
+ "integer": {"minimum", "maximum", "decimalPlaces"}}
108
+ allowed = {"type", "nullable", "const", "enum", "description"}
109
+ for kind in kinds:
110
+ allowed |= applicable.get(kind, set())
111
+ if set(schema) - allowed:
112
+ invalid("Constraint does not apply to the declared type")
113
+ if "description" in schema and type(schema["description"]) is not str:
114
+ invalid("description must be a string")
115
+ if "format" in schema and schema["format"] != "date-time":
116
+ invalid("Unsupported format")
117
+ if "properties" in schema:
118
+ if type(schema["properties"]) is not dict:
119
+ invalid("properties must be an object")
120
+ for name, child in schema["properties"].items():
121
+ _validate_schema(child, f"{path}.properties.{name}", depth + 1)
122
+ if "required" in schema:
123
+ required = schema["required"]
124
+ if type(required) is not list or any(type(k) is not str for k in required):
125
+ invalid("required must contain field names")
126
+ if len(required) != len(set(required)):
127
+ invalid("required contains duplicates")
128
+ if "items" in schema:
129
+ _validate_schema(schema["items"], f"{path}.items", depth + 1)
130
+
131
+
132
+ def _check(value: Any, schema: dict[str, Any], path: str, depth: int) -> None:
133
+ if depth > 40 or not isinstance(schema, dict) or set(schema) - _KEYS:
134
+ raise ContractViolation(path, "Unsupported contract", "REPLAY_CONTRACT_UNSUPPORTED")
135
+ kind = schema.get("type")
136
+ kinds = kind if isinstance(kind, list) else [kind]
137
+ if not kinds or any(not isinstance(k, str) or k not in _TYPES for k in kinds):
138
+ raise ContractViolation(path, "Explicit supported type required", "REPLAY_CONTRACT_UNSUPPORTED")
139
+ matches = (value is None and schema.get("nullable") is True) or any(isinstance(value, _TYPES[k]) and
140
+ not (k in ("number", "integer") and isinstance(value, bool)) for k in kinds)
141
+ if not matches:
142
+ raise ContractViolation(path, f"Expected {kind}, received {type(value).__name__}")
143
+ if "const" in schema and not _literal_equal(value,schema["const"]):
144
+ raise ContractViolation(path, "Value does not match the required identity")
145
+ if "enum" in schema and not any(_literal_equal(value,v) for v in schema["enum"]):
146
+ raise ContractViolation(path, "Value is outside the allowed enumeration")
147
+ if isinstance(value, dict):
148
+ properties, required = schema.get("properties", {}), schema.get("required", [])
149
+ if not isinstance(properties, dict) or not isinstance(required, list) or any(not isinstance(k, str) for k in required):
150
+ raise ContractViolation(path, "Invalid object contract", "REPLAY_CONTRACT_UNSUPPORTED")
151
+ for key in required:
152
+ if key not in value:
153
+ raise ContractViolation(f"{path}.{key}", "Required field is absent")
154
+ for key, item in value.items():
155
+ if key in properties:
156
+ _check(item, properties[key], f"{path}.{key}", depth + 1)
157
+ elif schema.get("additionalProperties") is False:
158
+ raise ContractViolation(f"{path}.{key}", "Undeclared field")
159
+ elif isinstance(value, list):
160
+ for key, valid in (("minItems", len(value) >= schema.get("minItems", 0)),
161
+ ("maxItems", len(value) <= schema.get("maxItems", len(value)))):
162
+ if not valid:
163
+ raise ContractViolation(path, f"Array violates {key}")
164
+ if "items" in schema:
165
+ for index, item in enumerate(value):
166
+ _check(item, schema["items"], f"{path}[{index}]", depth + 1)
167
+ elif type(value) in (float, int):
168
+ if value < schema.get("minimum", value) or value > schema.get("maximum", value):
169
+ raise ContractViolation(path, "Number is outside the allowed range")
170
+ if "decimalPlaces" in schema:
171
+ places = schema["decimalPlaces"]
172
+ if type(places) is not int or not 0 <= places <= 18:
173
+ raise ContractViolation(path, "Invalid precision contract", "REPLAY_CONTRACT_UNSUPPORTED")
174
+ try:
175
+ precision = max(0, -Decimal(str(value)).normalize().as_tuple().exponent)
176
+ except InvalidOperation as exc:
177
+ raise ContractViolation(path, "Invalid decimal") from exc
178
+ if precision > places:
179
+ raise ContractViolation(path, "Number exceeds the allowed decimal precision")
180
+ elif isinstance(value, str):
181
+ if not schema.get("minLength", 0) <= len(value) <= schema.get("maxLength", len(value)):
182
+ raise ContractViolation(path, "String length violates the contract")
183
+ if "format" in schema:
184
+ if schema["format"] != "date-time":
185
+ raise ContractViolation(path, "Unsupported format", "REPLAY_CONTRACT_UNSUPPORTED")
186
+ try:
187
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
188
+ except ValueError as exc:
189
+ raise ContractViolation(path, "Expected an ISO timestamp") from exc
190
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
191
+ raise ContractViolation(path, "Timestamp must include its timezone")
192
+
193
+
194
+ def check_observation_identity(actual: dict[str, Any], expected: dict[str, Any]) -> None:
195
+ """Check explicit units and time identity at a data boundary.
196
+
197
+ The caller supplies only the semantics consumed by this boundary. A currency
198
+ conversion is a separate transformation with its own tested output contract.
199
+ Missing metadata is unverified, not inferred as USD/UTC from node names.
200
+ """
201
+ allowed = {"symbol", "market", "currency", "timeframe", "as_of"}
202
+ if not expected or set(expected) - allowed:
203
+ raise ContractViolation("metadata", "Unsupported semantic identity", "REPLAY_CONTRACT_UNSUPPORTED")
204
+ for field, value in expected.items():
205
+ check_contract(actual.get(field), {"type": "string", "const": value,
206
+ **({"format": "date-time"} if field == "as_of" else {})}, f"metadata.{field}")
207
+
208
+
209
+ def check_codenode_ports(ports: Any) -> None:
210
+ if not isinstance(ports, list):
211
+ raise ContractViolation("outputs", "Declared CodeNode ports must be a list of objects")
212
+ names: set[str] = set()
213
+ for index, port in enumerate(ports):
214
+ if not isinstance(port, dict) or not isinstance(port.get("name"), str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", port["name"]):
215
+ raise ContractViolation(f"outputs[{index}]", "Invalid declared output port")
216
+ if port["name"] in names or port.get("type", "any") not in {*_TYPES, "any"}:
217
+ raise ContractViolation(f"outputs[{index}]", "Duplicate name or unsupported output type")
218
+ names.add(port["name"])
@@ -193,7 +193,14 @@ class WorkflowResolver:
193
193
  f"Unknown node type '{node_type}'",
194
194
  location=ErrorLocation(node_id=node_id, node_type=node_type),
195
195
  available_values=suggest_close_match(node_type or "", known_types),
196
- suggestion="Pick a node type from the registered list.",
196
+ suggestion=(
197
+ "FundamentalDataNode (FMP) was removed. For LS overseas-stock PER/EPS, "
198
+ "use OverseasStockFundamentalNode and rebind its value/values outputs. "
199
+ "It does not supply financial statements or cash-flow history; "
200
+ "do not silently substitute missing data."
201
+ if node_type == "FundamentalDataNode" else
202
+ "Pick a node type from the registered list."
203
+ ),
197
204
  )
198
205
  )
199
206
 
@@ -328,6 +335,9 @@ class WorkflowResolver:
328
335
  # 10.8 Display 노드 columns 키가 상류 출력 스키마에 실재하는지 (없는 키는 표에 '-' 만 찍힌다)
329
336
  self._validate_display_columns(workflow, registry, result)
330
337
 
338
+ # Order routing choices are literals, never prices or item bindings.
339
+ self._validate_order_enums(workflow, registry, result)
340
+
331
341
  # 11. Static recommendations (topology analysis)
332
342
  for rec in run_static_recommendation_rules(
333
343
  workflow,
@@ -344,6 +354,39 @@ class WorkflowResolver:
344
354
 
345
355
  return result
346
356
 
357
+ def _validate_order_enums(self, workflow, registry, result) -> None:
358
+ """Enforce fixed price/order types without changing dynamic close sides."""
359
+ from programgarden_core.models.field_binding import ExpressionMode
360
+
361
+ for node in workflow.nodes:
362
+ node_type = node.get("type", "")
363
+ if not node_type.endswith(("NewOrderNode", "ModifyOrderNode", "CancelOrderNode")):
364
+ continue
365
+ node_class = registry.get(node_type)
366
+ if node_class is None:
367
+ continue
368
+ for name, field in node_class.get_field_schema().items():
369
+ if (
370
+ name not in {"price_type", "order_type"}
371
+ or name not in node
372
+ or field.expression_mode != ExpressionMode.FIXED_ONLY
373
+ or not field.enum_values
374
+ or node[name] in field.enum_values
375
+ ):
376
+ continue
377
+ result.add(build_error(
378
+ ErrorCode.INVALID_FIELD_ENUM,
379
+ f"Order field '{name}' requires a fixed value from its enum.",
380
+ location=ErrorLocation(
381
+ node_id=node.get("id"), node_type=node_type, field_path=name,
382
+ ),
383
+ available_values=field.enum_values,
384
+ suggestion=(
385
+ "Use a listed literal value. Put an order's numeric price in "
386
+ "order.price, not price_type or order_type."
387
+ ),
388
+ ))
389
+
347
390
  def _attach_inline_recommendations(self, result: ValidationResult, workflow) -> None:
348
391
  """Augment specific ErrorInfo entries with related Recommendation hints.
349
392
 
@@ -1040,6 +1083,18 @@ class WorkflowResolver:
1040
1083
  continue
1041
1084
  node_id = node.get("id")
1042
1085
 
1086
+ # A mapping-shaped declaration was previously silently ignored by
1087
+ # the executor, wrapping a named result twice and disabling signals.
1088
+ from programgarden.replay_contracts import ContractViolation, check_codenode_ports
1089
+ try:
1090
+ check_codenode_ports(node.get("outputs", []))
1091
+ except ContractViolation as exc:
1092
+ result.add(build_error(
1093
+ ErrorCode.INVALID_FIELD_TYPE, str(exc),
1094
+ location=ErrorLocation(node_id=node_id, node_type="CodeNode", field_path="outputs"),
1095
+ suggestion="Use a list such as [{\"name\":\"result\",\"type\":\"object\"}], or omit outputs for one whole-result port.",
1096
+ ))
1097
+
1043
1098
  # 1. credential_id ban
1044
1099
  if node.get("credential_id"):
1045
1100
  result.add(
@@ -72,7 +72,6 @@ ACCOUNT_NODES: Set[str] = {
72
72
  EXTERNAL_API_NODES: Set[str] = {
73
73
  "HTTPRequestNode",
74
74
  "TelegramNode",
75
- "FundamentalDataNode",
76
75
  "FearGreedIndexNode",
77
76
  }
78
77
 
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.41.2"
8
+ version = "2.0.1"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -17,7 +17,7 @@ packages = [
17
17
  exclude = ["examples", "tests", "**/__pycache__"]
18
18
 
19
19
  [tool.poetry.dependencies]
20
- pydantic = ">=2.0.0,<3.0.0"
20
+ pydantic = ">=2.0.1,<3.0.0"
21
21
  croniter = "^6.0.0"
22
22
  python-dotenv = "^1.1.0"
23
23
  tzdata = "^2025.2"
@@ -36,9 +36,9 @@ litellm = ">=1.40.0"
36
36
  # 로 통째 실패한다(1.35.1 의 조용한 TypeError 보다 강한 모듈 로드 실패).
37
37
  # (1.26.0 이후로도 context.py 는 WorkflowPnLEvent(**event_data) 에 personal_metrics
38
38
  # 를 무조건 실으므로 그 하한 근거도 이 상한에 포함된다.)
39
- programgarden-core = "^1.31.0"
40
- programgarden-finance = "^1.10.5"
41
- programgarden-community = "^1.15.3"
39
+ programgarden-core = "^2.0.1"
40
+ programgarden-finance = "^2.0.1"
41
+ programgarden-community = "^2.0.1"
42
42
 
43
43
  [tool.poetry.group.dev.dependencies]
44
44
  pytest = "^8.0.0"
@@ -50,5 +50,5 @@ uvicorn = "^0.40.0"
50
50
 
51
51
 
52
52
  [build-system]
53
- requires = ["poetry-core>=2.0.0,<3.0.0"]
53
+ requires = ["poetry-core>=2.0.1,<3.0.0"]
54
54
  build-backend = "poetry.core.masonry.api"
File without changes