programgarden 2.1.0__tar.gz → 2.2.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 (66) hide show
  1. {programgarden-2.1.0 → programgarden-2.2.0}/PKG-INFO +2 -2
  2. programgarden-2.2.0/programgarden/replay_semantics.py +642 -0
  3. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/tools/registry_tools.py +7 -2
  4. {programgarden-2.1.0 → programgarden-2.2.0}/pyproject.toml +2 -2
  5. {programgarden-2.1.0 → programgarden-2.2.0}/README.md +0 -0
  6. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/__init__.py +0 -0
  7. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/binding_validator.py +0 -0
  8. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/client.py +0 -0
  9. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/code_worker.py +0 -0
  10. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/context.py +0 -0
  11. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/__init__.py +0 -0
  12. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/adjustment_delivery.py +0 -0
  13. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/broker_evidence.py +0 -0
  14. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/broker_order_totals.py +0 -0
  15. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/broker_snapshot.py +0 -0
  16. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/checkpoint_manager.py +0 -0
  17. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/db_naming.py +0 -0
  18. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/execution_storage.py +0 -0
  19. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/fill_reconciler.py +0 -0
  20. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/order_cancellation.py +0 -0
  21. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/order_recovery.py +0 -0
  22. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/owned_order_cancellation.py +0 -0
  23. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/position_reconciliation.py +0 -0
  24. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/query_builder.py +0 -0
  25. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/workflow_position_tracker.py +0 -0
  26. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  27. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/deep_fixtures.py +0 -0
  28. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/executor.py +0 -0
  29. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/futures_orderable.py +0 -0
  30. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/futures_pnl.py +0 -0
  31. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/futures_read_evidence.py +0 -0
  32. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/incremental_build.py +0 -0
  33. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/managed_order_control.py +0 -0
  34. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/node_runner.py +0 -0
  35. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/order_lifecycle.py +0 -0
  36. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/plugin/__init__.py +0 -0
  37. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/plugin/sandbox.py +0 -0
  38. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/providers/__init__.py +0 -0
  39. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/providers/llm_errors.py +0 -0
  40. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/providers/llm_provider.py +0 -0
  41. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/reconnect_handler.py +0 -0
  42. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/replay_contracts.py +0 -0
  43. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/replay_events.py +0 -0
  44. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/replay_external.py +0 -0
  45. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/replay_order_adapter.py +0 -0
  46. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/replay_orders.py +0 -0
  47. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/replay_scenarios.py +0 -0
  48. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/replay_sources.py +0 -0
  49. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/replay_sqlite.py +0 -0
  50. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/replay_triggers.py +0 -0
  51. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/resolver.py +0 -0
  52. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/resource/__init__.py +0 -0
  53. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/resource/context.py +0 -0
  54. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/resource/limiter.py +0 -0
  55. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/resource/monitor.py +0 -0
  56. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/resource/throttle.py +0 -0
  57. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/semantic_rules.py +0 -0
  58. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/tools/__init__.py +0 -0
  59. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/tools/credential_tools.py +0 -0
  60. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/tools/definition_tools.py +0 -0
  61. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/tools/event_tools.py +0 -0
  62. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/tools/job_tools.py +0 -0
  63. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/tools/sqlite_tools.py +0 -0
  64. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/validation_recommender.py +0 -0
  65. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/validation_replay.py +0 -0
  66. {programgarden-2.1.0 → programgarden-2.2.0}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.1.0
3
+ Version: 2.2.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 (>=2.1.0,<3.0.0)
19
- Requires-Dist: programgarden-core (>=2.1.0,<3.0.0)
19
+ Requires-Dist: programgarden-core (>=2.2.0,<3.0.0)
20
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)
@@ -0,0 +1,642 @@
1
+ """Machine-readable per-node execution/replay semantics, derived from contract code.
2
+
3
+ Metadata only. This module adds NO behaviour to any node or replay path; it reads
4
+ the engine's own single-source-of-truth contract sets and executor tables and
5
+ projects them into a compact block the AI service (designer / Jev judge /
6
+ compiler) and the node catalog can consume without re-deriving engine semantics
7
+ from prose. Because every value is derived from the same sets the runtime uses,
8
+ the block cannot drift (a unit test cross-checks the derivation against those
9
+ sets).
10
+
11
+ Closed value sets follow the design vocabulary exactly (so consumers can switch
12
+ on them):
13
+
14
+ role : external | source | broker | order | trigger | gate |
15
+ clock_gate | computation | sink
16
+ replay.recording : required | forbidden | orders_response_only
17
+ output-port shape: signal | any | array<row> | object<row> |
18
+ symbol_keyed<bar[]> | row<time_series> | array<order> |
19
+ object<connection>
20
+
21
+ Grounding (paths relative to src/programgarden/programgarden unless noted):
22
+ - FIXTURE_NODES / COMPUTATION_NODES / _RESERVED_OUTPUT_KEYS : validation_replay.py:26-56
23
+ - SOURCE_NODES : replay_sources.py:13
24
+ - ORDER_NODES + market-buy->limit conversion : replay_order_adapter.py:15-17,151-165
25
+ - subsequent_event_types (emits_events) : replay_events.py:43-62
26
+ - schedule tick == next cron instant / limits / disabled : replay_events.py:110-133
27
+ - schedule_startup (executes-once {trigger:true}, tz/limit): replay_triggers.py:43-60
28
+ - SessionGate evaluate_at / TradingHours wait-blocks-replay: validation_replay.py:276-289; replay_triggers.py:62-71
29
+ - request_identity (own fields, connection allowlist) : replay_external.py:94-146
30
+ - per-item recording key EXCHANGE:SYMBOL (KRX default) : replay_external.py:178-190
31
+ - connection auto-injection (product-scoped nodes) : executor.py:23582-23641 (WorkflowJob)
32
+ - auto-iteration eligibility (NO_AUTO_ITERATE_NODE_TYPES) : executor.py:22319-22343 (WorkflowJob)
33
+ - SIM-<node> / SIM-REPLACE-<node> order ids : replay_orders.py:134,236,338-343
34
+ - order rejection reasons / futures-only held refusal : replay_orders.py:95-133
35
+ - ThrottleNode interval_sec upper bound (<=300) : core .../nodes/infra.py:196-199,348
36
+ - TradingHoursFilter default window (US regular) : core .../nodes/trigger.py:268-271
37
+ - ScheduleNode field defaults (cron/tz/enabled/limits) : core .../nodes/trigger.py:45-66; replay_events.py:119-127
38
+ """
39
+ from __future__ import annotations
40
+
41
+ from functools import lru_cache
42
+ from typing import Any, Dict, List, Optional
43
+
44
+
45
+ # --- non-secret broker identity allowlist (replay_external.py:119) -----------
46
+ # The engine allows exactly these keys in request.connection. `credential_id` is
47
+ # present only when the account is linked; it is marked "?" here as optional.
48
+ _CONNECTION_KEYS: List[str] = [
49
+ "provider", "product", "paper_trading", "broker_node_id", "credential_id?",
50
+ ]
51
+ # Fields a recorded request may NOT carry: `product` is not a broker request
52
+ # field (it lives inside connection), `credential_ref` is not in the allowlist,
53
+ # and a null-valued identity key is dropped before matching (replay_external.py:
54
+ # 114-142). Names are descriptive labels, not literal engine tokens.
55
+ _REQUEST_FORBIDDEN: List[str] = ["product", "credential_ref", "null_identity"]
56
+
57
+ _REAL_TRIGGER_SUFFIXES = ("RealMarketDataNode", "RealOrderEventNode", "RealAccountNode")
58
+
59
+
60
+ @lru_cache(maxsize=1)
61
+ def _contract():
62
+ """Load the engine's single-source-of-truth contract sets and tables (lazy).
63
+
64
+ Imported inside the function so importing this module (e.g. from the tool
65
+ registry) does not eagerly pull the executor for callers that never derive a
66
+ block. Cached so per-type derivation pays the import cost once.
67
+ """
68
+ from programgarden_core import NodeTypeRegistry
69
+ from programgarden_core.nodes.base import BaseNode
70
+ from programgarden.validation_replay import (
71
+ FIXTURE_NODES, COMPUTATION_NODES, _RESERVED_OUTPUT_KEYS,
72
+ )
73
+ from programgarden.replay_sources import SOURCE_NODES
74
+ from programgarden.replay_order_adapter import ORDER_NODES
75
+ from programgarden.replay_events import subsequent_event_types
76
+ from programgarden.executor import WorkflowJob
77
+
78
+ return {
79
+ "registry": NodeTypeRegistry(),
80
+ "base_fields": frozenset(BaseNode.model_fields),
81
+ "FIXTURE": FIXTURE_NODES,
82
+ "SOURCE": SOURCE_NODES,
83
+ "COMPUTATION": COMPUTATION_NODES,
84
+ "ORDER": ORDER_NODES,
85
+ "reserved_keys": tuple(_RESERVED_OUTPUT_KEYS),
86
+ "subsequent_event_types": subsequent_event_types,
87
+ "no_auto_iterate": frozenset(WorkflowJob.NO_AUTO_ITERATE_NODE_TYPES),
88
+ }
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Product-level execution facts (venue / currency / market hours). Kept as a
93
+ # sibling map so ~10 product-scoped nodes per product reference it instead of
94
+ # repeating it. Market hours: only the US regular window is defined in engine
95
+ # CODE (TradingHoursFilterNode defaults, core .../nodes/trigger.py:268-271); KRX
96
+ # regular hours are stated as a public fact (not in engine code); the Korean
97
+ # daytime (Blue Ocean) session for overseas stocks and overseas-futures hours
98
+ # are NOT defined in code (owner facts / contract-specific) and are left null.
99
+ # ---------------------------------------------------------------------------
100
+ def product_execution() -> Dict[str, Dict[str, Any]]:
101
+ """Per-product execution facts keyed by product_scope.
102
+
103
+ Each entry: {tz, currency, sessions (names), venue, session_hours}. A
104
+ session_hours entry with open/close null is a session that trades but whose
105
+ window is not defined in engine code (noted in `note`).
106
+ """
107
+ return {
108
+ "overseas_stock": {
109
+ "tz": "America/New_York",
110
+ "currency": "USD",
111
+ # Owner 2026-09-25: US stocks trade from the KR account in BOTH the
112
+ # Korean daytime session and the US regular session.
113
+ "sessions": ["kr_daytime", "us_regular"],
114
+ "venue": None, # US venues (NASDAQ/NYSE/AMEX); no single venue token
115
+ "session_hours": {
116
+ # engine: TradingHoursFilterNode defaults 09:30-16:00 ET; public
117
+ "us_regular": {"open": "09:30", "close": "16:00",
118
+ "tz": "America/New_York",
119
+ "source": "engine:TradingHoursFilterNode defaults + public"},
120
+ # tradable (owner) but the window is not defined in engine code
121
+ "kr_daytime": {"open": None, "close": None, "tz": "Asia/Seoul",
122
+ "note": "Korean daytime (Blue Ocean) session for US "
123
+ "stocks; tradable per owner 2026-09-25, window "
124
+ "not defined in engine code"},
125
+ },
126
+ },
127
+ "korea_stock": {
128
+ "tz": "Asia/Seoul",
129
+ "currency": "KRW",
130
+ "sessions": ["krx_regular"],
131
+ "venue": "KRX",
132
+ "session_hours": {
133
+ # public fact (KRX); not defined as a constant in engine code
134
+ "krx_regular": {"open": "09:00", "close": "15:30", "tz": "Asia/Seoul",
135
+ "source": "public (KRX); not in engine code"},
136
+ },
137
+ },
138
+ "overseas_futures": {
139
+ # Not provable from code: futures hours/venue/tz vary per contract.
140
+ "tz": None,
141
+ "currency": None,
142
+ "sessions": ["contract_specific"],
143
+ "venue": None,
144
+ "session_hours": {
145
+ "contract_specific": {"open": None, "close": None,
146
+ "note": "overseas futures hours vary per "
147
+ "contract; not defined in engine code"},
148
+ },
149
+ },
150
+ }
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Derivation helpers
155
+ # ---------------------------------------------------------------------------
156
+ def _role(node_type: str, category: Optional[str], c) -> str:
157
+ # ScheduleNode/StartNode/*Real* are triggers even though the two *Real* kinds
158
+ # are also FIXTURE recording nodes (design vocab: trigger wins). See
159
+ # subsequent_event_types (replay_events.py:43-62) and executor scheduler.
160
+ if node_type in ("ScheduleNode", "StartNode"):
161
+ return "trigger"
162
+ if node_type.endswith(_REAL_TRIGGER_SUFFIXES):
163
+ return "trigger"
164
+ # Immediate time gates evaluated at the fixture instant (validation_replay.py
165
+ # :276-289) — distinct from a boolean data gate.
166
+ if node_type in ("SessionGateNode", "TradingHoursFilterNode"):
167
+ return "clock_gate"
168
+ if node_type == "IfNode":
169
+ return "gate"
170
+ if node_type.endswith("BrokerNode") and node_type in c["FIXTURE"]:
171
+ return "broker"
172
+ if node_type in c["ORDER"]:
173
+ return "order"
174
+ if node_type in c["FIXTURE"]:
175
+ return "external"
176
+ if node_type in c["SOURCE"]:
177
+ return "source"
178
+ if category == "display":
179
+ return "sink"
180
+ # COMPUTATION_NODES and the execute-without-recording remainder (SQLiteNode).
181
+ return "computation"
182
+
183
+
184
+ def _recording(node_type: str, c) -> str:
185
+ """Closed set: required | forbidden | orders_response_only.
186
+
187
+ Consumer contract (2026-09-25): the compiler's per-frame recording rule reads
188
+ recording == "required" to decide which nodes need a recording in every tick
189
+ frame. Every FIXTURE_NODES member — brokers included — is re-recorded per
190
+ tick, so brokers are "required", not a weaker "metadata_only". Sources are
191
+ also required (in raw_source form). Only the execute-without-recording nodes
192
+ (COMPUTATION + StartNode/ScheduleNode/gates/SQLite/display) are "forbidden".
193
+ Grounding: validation_replay.py:238 (FIXTURE->external_record required),
194
+ :265 (SOURCE->source_record), :293 (COMPUTATION execute); order nodes go
195
+ through the order adapter (replay_order_adapter.py:167).
196
+ """
197
+ if node_type in c["ORDER"]:
198
+ return "orders_response_only"
199
+ if node_type in c["FIXTURE"] or node_type in c["SOURCE"]:
200
+ return "required"
201
+ return "forbidden"
202
+
203
+
204
+ def _own_fields(node_class, c) -> List[str]:
205
+ """Config fields the node adds on top of BaseNode, minus the injected
206
+ `connection` (tracked under request.injected). request_identity allows
207
+ exactly model_fields minus presentation/scheduler minus connection
208
+ (replay_external.py:114); presentation/scheduler fields live on BaseNode and
209
+ so are already excluded by the set difference."""
210
+ return sorted(set(node_class.model_fields) - c["base_fields"] - {"connection"})
211
+
212
+
213
+ def _port_shape(node_type: str, role: str, port_name: str, port_type: str) -> Optional[str]:
214
+ """Map an engine output-port type to the closed shape vocabulary, or None to
215
+ omit a scalar port that has no structural shape. Node/port context resolves
216
+ the two ohlcv_data envelopes (realtime symbol-keyed vs historical row) and the
217
+ NewOrder result array."""
218
+ if port_type == "signal":
219
+ return "signal"
220
+ # A broker/LLM connection handle injected downstream (broker.py:44-55;
221
+ # LLMModelNode `connection: ai_model`).
222
+ if port_type in ("broker_connection", "ai_model"):
223
+ return "object<connection>"
224
+ if port_type == "ohlcv_data":
225
+ # *RealMarketDataNode records ohlcv_data (and its data alias) as a
226
+ # symbol-keyed dict of bar lists; a *HistoricalDataNode `value` is one row
227
+ # {symbol, exchange, time_series:[bars]}; historical `values` is the array
228
+ # of such rows.
229
+ if node_type.endswith("RealMarketDataNode"):
230
+ return "symbol_keyed<bar[]>"
231
+ if node_type.endswith("HistoricalDataNode") and port_name == "value":
232
+ return "row<time_series>"
233
+ return "array<row>"
234
+ # A NewOrder `result` port is an ARRAY of one order object (replay_order_
235
+ # adapter.py:191); open_orders / rebalance_orders (order_list) are arrays too.
236
+ if role == "order" and port_name == "result":
237
+ return "array<order>"
238
+ if port_type == "order_list":
239
+ return "array<order>"
240
+ if port_type in ("array", "symbol_list", "trade_list"):
241
+ return "array<row>"
242
+ if port_type in ("object", "dict", "balance_data", "position_data",
243
+ "fundamental_data", "portfolio_result", "performance_summary",
244
+ "order_result", "order", "order_event"):
245
+ return "object<row>"
246
+ if port_type == "any":
247
+ return "any"
248
+ # string / number / integer / bool / boolean — scalar, no structural shape.
249
+ return None
250
+
251
+
252
+ def _output_ports(node_type: str, role: str, outputs) -> tuple[Dict[str, str], List[str]]:
253
+ ports: Dict[str, str] = {}
254
+ internal: List[str] = []
255
+ for port in outputs:
256
+ name, ptype = port.get("name"), port.get("type")
257
+ if not name:
258
+ continue
259
+ # `_`-prefixed ports never trigger downstream (ThrottleNode _throttle_stats;
260
+ # executor throttle branch) — surfaced separately, not as data ports.
261
+ if name.startswith("_"):
262
+ internal.append(name)
263
+ continue
264
+ shape = _port_shape(node_type, role, name, ptype)
265
+ if shape is not None:
266
+ ports[name] = shape
267
+ return ports, sorted(internal)
268
+
269
+
270
+ def _iterates(node_type: str, role: str, c, own_fields: List[str]) -> Optional[Dict[str, Any]]:
271
+ """Per-item auto-iteration block for recording nodes that fan out over an
272
+ upstream symbol array. Eligibility: a FIXTURE/SOURCE node that is not in
273
+ NO_AUTO_ITERATE_NODE_TYPES (executor.py:22319), is not a broker (broker
274
+ output is connection metadata, item=null), and consumes a `symbols` batch
275
+ field. The per-item recording key is EXCHANGE:SYMBOL, and KoreaStock* defaults
276
+ the exchange to KRX (replay_external.py:178-190)."""
277
+ if node_type not in c["FIXTURE"] and node_type not in c["SOURCE"]:
278
+ return None
279
+ if node_type in c["no_auto_iterate"] or role == "broker":
280
+ return None
281
+ if "symbols" not in own_fields:
282
+ return None
283
+ block: Dict[str, Any] = {
284
+ "mode": "per_item",
285
+ "item": "{exchange,symbol}",
286
+ "when": "upstream_array_or_symbols_list",
287
+ "record_key": "EXCHANGE:SYMBOL",
288
+ "single": "item=null",
289
+ }
290
+ if node_type.startswith("KoreaStock"):
291
+ block["default_exchange"] = "KRX"
292
+ return block
293
+
294
+
295
+ def _on_event(node_type: str) -> Optional[Dict[str, Any]]:
296
+ """Trigger-only. ScheduleNode re-runs the whole main flow on a tick
297
+ (executor _event_loop schedule_tick -> _execute_main_flow), so it re-runs
298
+ itself and every downstream node; a streaming *Real* trigger re-runs only its
299
+ downstream chain (_handle_realtime_update -> _find_downstream_nodes), so the
300
+ startup account/open-order snapshots are retained. Both re-record every
301
+ external node in the frame per replay_events.event_fixture (:64-79)."""
302
+ if node_type == "ScheduleNode":
303
+ return {"reruns": "self_and_every_downstream",
304
+ "external_downstream": "recording_per_frame",
305
+ "upstream_outputs": "retained", "book": "cumulative"}
306
+ if node_type.endswith(_REAL_TRIGGER_SUFFIXES):
307
+ return {"reruns": "listed_only",
308
+ "external_downstream": "recording_per_frame",
309
+ "upstream_outputs": "retained", "book": "cumulative"}
310
+ return None
311
+
312
+
313
+ def _order_block(node_type: str, product_scope: str, own_fields: List[str]) -> Dict[str, Any]:
314
+ """ORDER_NODES only. Grounding in replay_orders.py / replay_order_adapter.py."""
315
+ # config_keys = the node's own order parameters (connection is injected,
316
+ # resilience is a retry policy, neither is an order intent field).
317
+ config_keys = [f for f in own_fields if f not in ("connection", "resilience")]
318
+ block: Dict[str, Any] = {"config_keys": config_keys}
319
+ if node_type.endswith("NewOrderNode"):
320
+ # replay_orders.py:134 SIM-<node_id> (#2.. on repeats).
321
+ block["sim_id"] = "SIM-<node_id>"
322
+ block["result_shape"] = "array<order>" # replay_order_adapter.py:191
323
+ block["one_active_order_per_symbol"] = True # pending_or_unknown_order (:117)
324
+ # Stocks have NO held-symbol refusal; only futures adding to an open
325
+ # same-direction position is refused (replay_orders.py:129).
326
+ block["held_symbol_refusal"] = (product_scope == "overseas_futures")
327
+ if product_scope == "overseas_stock":
328
+ # overseas-stock market BUY submits as a LIMIT at the quote
329
+ # (replay_order_adapter.py:162-165; executor.py:16377-16399).
330
+ block["market_buy"] = "limit_at_quote"
331
+ elif node_type.endswith("ModifyOrderNode"):
332
+ block["sim_id"] = "SIM-REPLACE-<node_id>" # replay_orders.py:236
333
+ # A modify/cancel is only acknowledged in-tick; completion is expressed by
334
+ # a downstream *OpenOrdersNode recording's top-level order_events.
335
+ block["acknowledged_only"] = True
336
+ elif node_type.endswith("CancelOrderNode"):
337
+ block["acknowledged_only"] = True
338
+ return block
339
+
340
+
341
+ def _time_rules(node_type: str, role: str) -> List[Dict[str, Any]]:
342
+ """Closed rule ids; each entry is an object with an `id`."""
343
+ if node_type == "ScheduleNode":
344
+ # replay_events.py:110-133 + replay_triggers.py:43-60 + core trigger.py:45-66.
345
+ return [
346
+ {"id": "cron_required", "field": "cron", "format": "5-field", "default": None},
347
+ {"id": "tick_next_cron_instant", "granularity": "minute",
348
+ "after": "prior_frame", "min_gap_s": 60},
349
+ {"id": "timezone_iana", "field": "timezone",
350
+ "default": "America/New_York", "invalid": "replay_rejects"},
351
+ {"id": "disabled_emits_nothing", "field": "enabled"},
352
+ {"id": "limits", "count": {"default": 1000, "min": 1},
353
+ "max_duration_hours": {"default": 24.0, "gt": 0}, "pin": "range_not_const"},
354
+ ]
355
+ if node_type == "SessionGateNode":
356
+ return [{"id": "evaluate_at_fixture_instant"}] # validation_replay.py:276-283
357
+ if node_type == "TradingHoursFilterNode":
358
+ return [{"id": "wait_blocks_replay"}] # replay_triggers.py:62-71
359
+ if node_type == "ThrottleNode":
360
+ return [{"id": "interval_max_s", "value": 300}] # core infra.py:196-199,348
361
+ if role in ("external", "source"):
362
+ # An external recording is a snapshot bound to the fixture instant
363
+ # (replay_external.py:203-208 as_of match).
364
+ return [{"id": "snapshot_at_call_time"}]
365
+ return []
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Public API
370
+ # ---------------------------------------------------------------------------
371
+ def execution_for(node_type: str) -> Optional[Dict[str, Any]]:
372
+ """Derive the execution-semantics block for a registered node type.
373
+
374
+ Returns None for an unregistered type. Every key is traceable to the contract
375
+ code cited in this module's docstring; a key is omitted rather than guessed.
376
+ """
377
+ c = _contract()
378
+ node_class = c["registry"].get(node_type)
379
+ if node_class is None:
380
+ return None
381
+ schema = c["registry"].get_schema(node_type) # raw (no locale); ports carry type
382
+ if schema is None:
383
+ return None
384
+
385
+ category = schema.category
386
+ product_scope = schema.product_scope or "all"
387
+ role = _role(node_type, category, c)
388
+ recording = _recording(node_type, c)
389
+ own_fields = _own_fields(node_class, c)
390
+ ports, internal_ports = _output_ports(node_type, role, schema.outputs or [])
391
+ emits = sorted(c["subsequent_event_types"](node_type))
392
+
393
+ block: Dict[str, Any] = {"role": role}
394
+
395
+ # --- replay --------------------------------------------------------------
396
+ replay: Dict[str, Any] = {"recording": recording}
397
+ if recording == "required":
398
+ replay["form"] = "raw_source" if node_type in c["SOURCE"] else "output"
399
+ if ports:
400
+ replay["envelope"] = dict(ports)
401
+ if node_type == "ScheduleNode":
402
+ # schedule_startup returns the live executor's {trigger: True} once
403
+ # (replay_triggers.py:43-60).
404
+ replay["startup"] = "executes_once:{trigger:true}"
405
+ block["replay"] = replay
406
+
407
+ # --- request -------------------------------------------------------------
408
+ if recording == "required":
409
+ request: Dict[str, Any] = {"own_fields": own_fields}
410
+ if role == "broker":
411
+ # A broker's own recorded request never carries a connection; it
412
+ # CREATES the connection metadata (replay_external.py:116-146 excludes
413
+ # connection from the broker's identity on the first run).
414
+ request["never_connection"] = True
415
+ elif product_scope != "all":
416
+ # The engine resolves this node's request WITH the upstream broker's
417
+ # identity (executor.py:23582-23641; replay_external.py:117-146).
418
+ request["injected"] = {"connection": "upstream_broker_identity"}
419
+ request["connection_keys"] = list(_CONNECTION_KEYS)
420
+ request["forbidden"] = list(_REQUEST_FORBIDDEN)
421
+ else:
422
+ request["injected"] = {}
423
+ block["request"] = request
424
+ else:
425
+ block["request"] = None
426
+
427
+ # --- iteration -----------------------------------------------------------
428
+ block["iteration"] = _iterates(node_type, role, c, own_fields)
429
+
430
+ # --- rerun / events ------------------------------------------------------
431
+ # A trigger is the source of re-runs; a non-trigger node re-runs when placed
432
+ # downstream of a schedule tick (executor _event_loop re-runs the main flow)
433
+ # and when its trigger input fires.
434
+ block["reruns_on"] = [] if role == "trigger" else [
435
+ "downstream_of:schedule_tick", "input:trigger"]
436
+ block["emits_events"] = emits
437
+ on_event = _on_event(node_type)
438
+ if on_event is not None:
439
+ block["on_event"] = on_event
440
+
441
+ # --- ports ---------------------------------------------------------------
442
+ block["output_ports"] = ports
443
+ # No declared port is provably never-emitted in this engine version, so the
444
+ # dead-port list is empty (kept for shape stability; never guessed).
445
+ block["dead_ports"] = []
446
+ if internal_ports:
447
+ block["internal_ports"] = internal_ports
448
+ # error/reason are reserved for COMPUTATION nodes (validation_replay.py:309-315
449
+ # fails on a top-level `error` key or an invalid-input `reason` there).
450
+ block["reserved_output_ports"] = (
451
+ list(c["reserved_keys"]) if node_type in c["COMPUTATION"] else [])
452
+
453
+ # --- gating --------------------------------------------------------------
454
+ if node_type == "IfNode":
455
+ # A native IfNode evaluates exactly one left/operator/right comparison;
456
+ # the untaken `false` port skips its branch (executor _compute_if_skip).
457
+ block["gating"] = {"false_path": "false", "single_comparison": True}
458
+ elif node_type == "CodeNode":
459
+ # A CodeNode returning a boolean false skips nothing — route through IfNode.
460
+ block["gating"] = {"boolean_completion_is_not_a_gate": True}
461
+
462
+ # --- order ---------------------------------------------------------------
463
+ if node_type in c["ORDER"]:
464
+ block["order"] = _order_block(node_type, product_scope, own_fields)
465
+
466
+ # --- time rules ----------------------------------------------------------
467
+ time_rules = _time_rules(node_type, role)
468
+ if time_rules:
469
+ block["time_rules"] = time_rules
470
+
471
+ # --- venue (product map) -------------------------------------------------
472
+ # Every product-scoped node carries the product's execution facts (tz /
473
+ # currency / session hours) under `venue`; `all`-scoped nodes have no venue.
474
+ if product_scope != "all":
475
+ block["venue"] = product_execution().get(product_scope)
476
+
477
+ return block
478
+
479
+
480
+ def _fmt_hours(session: str, hours: Dict[str, Any]) -> str:
481
+ open_, close = hours.get("open"), hours.get("close")
482
+ tz = hours.get("tz")
483
+ if open_ and close:
484
+ return f"- {session}: {open_}-{close} {tz}."
485
+ note = hours.get("note") or "window not defined in engine code"
486
+ return f"- {session}: {note}."
487
+
488
+
489
+ def execution_semantics_text(node_type: str) -> List[str]:
490
+ """Render the execution block into short English bullet sentences (one fact
491
+ per line), suitable for appending to a node's `features` for the chatbot
492
+ catalog. Deterministic, no adjectives."""
493
+ block = execution_for(node_type)
494
+ if not block:
495
+ return []
496
+ lines: List[str] = []
497
+ role = block["role"]
498
+ lines.append(f"Role: {role}.")
499
+
500
+ recording = block["replay"]["recording"]
501
+ if recording == "required":
502
+ form = block["replay"].get("form", "output")
503
+ lines.append(f"Replay recording: required (form {form}); it is re-recorded "
504
+ "in every tick frame it executes in.")
505
+ elif recording == "orders_response_only":
506
+ lines.append("Replay recording: an explicit order-response scenario is "
507
+ "required; the node has no output recording.")
508
+ else:
509
+ lines.append("Replay recording: forbidden; the node executes in replay.")
510
+
511
+ req = block.get("request")
512
+ if isinstance(req, dict):
513
+ if req.get("own_fields"):
514
+ lines.append("Recording request carries only these native fields: "
515
+ + ", ".join(req["own_fields"]) + ".")
516
+ if req.get("never_connection"):
517
+ lines.append("Its own request never carries a connection; it creates "
518
+ "the connection metadata for downstream nodes.")
519
+ if req.get("injected", {}).get("connection"):
520
+ lines.append("The upstream broker connection is injected into the "
521
+ "request (identity keys: " + ", ".join(_CONNECTION_KEYS) + ").")
522
+
523
+ it = block.get("iteration")
524
+ if isinstance(it, dict):
525
+ line = ("Auto-iterates per item over an upstream symbol array; each per-item "
526
+ "recording is keyed EXCHANGE:SYMBOL and a single non-iterated "
527
+ "recording sets item=null.")
528
+ if it.get("default_exchange"):
529
+ line = line[:-1] + " (exchange defaults to KRX when omitted)."
530
+ lines.append(line)
531
+
532
+ emits = block.get("emits_events") or []
533
+ if emits:
534
+ lines.append("Emits subsequent events: " + ", ".join(emits) + ".")
535
+ else:
536
+ lines.append("Emits no subsequent event.")
537
+
538
+ oe = block.get("on_event")
539
+ if isinstance(oe, dict):
540
+ if oe.get("reruns") == "self_and_every_downstream":
541
+ lines.append("On each tick it re-runs itself and every downstream node; "
542
+ "each external downstream node needs a fresh per-frame "
543
+ "recording; startup snapshots are retained; the order book "
544
+ "is cumulative.")
545
+ else:
546
+ lines.append("On each event it re-runs only its downstream chain; "
547
+ "startup account/open-order snapshots are retained; the "
548
+ "order book is cumulative.")
549
+
550
+ if block.get("output_ports"):
551
+ lines.append("Output ports: " + ", ".join(
552
+ f"{name} ({shape})" for name, shape in block["output_ports"].items()) + ".")
553
+ if block.get("internal_ports"):
554
+ lines.append("Internal ports never trigger downstream: "
555
+ + ", ".join(block["internal_ports"]) + ".")
556
+ if block.get("reserved_output_ports"):
557
+ lines.append("Reserved output keys (never declare or expect): "
558
+ + ", ".join(block["reserved_output_ports"]) + ".")
559
+
560
+ gating = block.get("gating")
561
+ if isinstance(gating, dict):
562
+ if node_type == "IfNode":
563
+ lines.append("Evaluates exactly one left/operator/right comparison; the "
564
+ "false port skips its branch.")
565
+ elif node_type == "CodeNode":
566
+ lines.append("A boolean completion is not a gate; route a branch "
567
+ "decision through an IfNode.")
568
+
569
+ order = block.get("order")
570
+ if isinstance(order, dict):
571
+ if order.get("result_shape"):
572
+ lines.append("Booked as " + order["sim_id"] + "; the result port is an "
573
+ "array of one order object.")
574
+ if order.get("market_buy") == "limit_at_quote":
575
+ lines.append("An overseas-stock market BUY is submitted as a LIMIT order "
576
+ "at the current quote.")
577
+ if order.get("acknowledged_only"):
578
+ lines.append("A modify/cancel is only acknowledged in-tick; its "
579
+ "completion is a downstream OpenOrders order_events entry.")
580
+ if order.get("one_active_order_per_symbol"):
581
+ lines.append("At most one active order per symbol at a time.")
582
+
583
+ tr_ids = {r.get("id") for r in block.get("time_rules", [])}
584
+ if "snapshot_at_call_time" in tr_ids:
585
+ lines.append("Its recorded output is a snapshot at the fixture instant.")
586
+ if "evaluate_at_fixture_instant" in tr_ids:
587
+ lines.append("The decision is a pure function of the fixture instant and "
588
+ "config; two frames at the same instant get the same result.")
589
+ if "wait_blocks_replay" in tr_ids:
590
+ lines.append("Live it waits outside the window; in replay an out-of-window "
591
+ "instant is refused, so only in-window instants replay.")
592
+ if "interval_max_s" in tr_ids:
593
+ lines.append("Cooldown state persists across re-triggers; interval_sec is at "
594
+ "most 300 and must be pinned as a range, never a const.")
595
+
596
+ if "cron_required" in tr_ids:
597
+ _schedule_time_lines(lines)
598
+
599
+ return lines
600
+
601
+
602
+ def _schedule_time_lines(lines: List[str]) -> None:
603
+ """ScheduleNode-specific bullets: cron/tick rules + market operating hours +
604
+ the market-relative time rule (owner 2026-09-25)."""
605
+ lines.append("cron is required (5-field); the timezone defaults to "
606
+ "America/New_York; a disabled schedule emits no tick.")
607
+ lines.append("A recorded tick must equal the cron's next firing instant after "
608
+ "the prior frame in this timezone: a whole minute, at least one "
609
+ "minute later.")
610
+ lines.append("count and max_duration_hours are safety limits (defaults 1000 and "
611
+ "24.0); a contract bounds them with a range, never a const.")
612
+ lines.append("Set the cron time in the target market's timezone. Market "
613
+ "operating hours by product:")
614
+ pe = product_execution()
615
+ for scope in ("korea_stock", "overseas_stock", "overseas_futures"):
616
+ entry = pe.get(scope, {})
617
+ for session in entry.get("sessions", []):
618
+ hours = entry.get("session_hours", {}).get(session, {})
619
+ lines.append(_fmt_hours(f"{scope} / {session}", hours))
620
+ lines.append("A time stated relative to a market ('장 열리고 30분 뒤' = 30 minutes "
621
+ "after the open) is that many minutes AFTER that market's open in "
622
+ "its timezone: US open 09:30 + 30 = 10:00 America/New_York; cron "
623
+ "'0 10 * * 1-5'.")
624
+
625
+
626
+ def attach_execution(schema):
627
+ """Return a COPY of a NodeTypeSchema carrying the derived `execution` block
628
+ and its rendered lines merged into `features` (without duplicating existing
629
+ lines). Never mutates the registry's cached schema instance.
630
+
631
+ Used by the tool registry (list_node_types / get_node_schema) so served
632
+ schemas carry execution semantics; the core registry itself leaves the field
633
+ None (core must not import programgarden).
634
+ """
635
+ block = execution_for(schema.node_type)
636
+ if block is None:
637
+ return schema
638
+ features = list(schema.features or [])
639
+ for line in execution_semantics_text(schema.node_type):
640
+ if line not in features:
641
+ features.append(line)
642
+ return schema.model_copy(update={"execution": block, "features": features})
@@ -29,10 +29,14 @@ def list_node_types(
29
29
  [{"node_type": "ConditionNode", "display_name": "조건 노드", ...}]
30
30
  """
31
31
  from programgarden_core import NodeTypeRegistry
32
+ from programgarden.replay_semantics import attach_execution
32
33
 
33
34
  registry = NodeTypeRegistry()
34
35
  schemas = registry.list_schemas(category=category, locale=locale)
35
- return [schema.model_dump() for schema in schemas]
36
+ # Attach the machine-readable execution-semantics block and merge its rendered
37
+ # lines into `features` (attach_execution returns a copy — the cached schema is
38
+ # untouched).
39
+ return [attach_execution(schema).model_dump() for schema in schemas]
36
40
 
37
41
 
38
42
  def get_node_schema(node_type: str, locale: Optional[str] = None) -> Optional[Dict[str, Any]]:
@@ -51,11 +55,12 @@ def get_node_schema(node_type: str, locale: Optional[str] = None) -> Optional[Di
51
55
  {"node_type": "ConditionNode", "display_name": "i18n:nodes.ConditionNode.name", ...}
52
56
  """
53
57
  from programgarden_core import NodeTypeRegistry
58
+ from programgarden.replay_semantics import attach_execution
54
59
 
55
60
  registry = NodeTypeRegistry()
56
61
  schema = registry.get_schema(node_type, locale=locale)
57
62
  if schema:
58
- return schema.model_dump()
63
+ return attach_execution(schema).model_dump()
59
64
 
60
65
  return None
61
66
 
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "2.1.0"
8
+ version = "2.2.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -36,7 +36,7 @@ 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 = "^2.1.0"
39
+ programgarden-core = "^2.2.0"
40
40
  programgarden-finance = "^2.0.1"
41
41
  programgarden-community = "^2.1.0"
42
42
 
File without changes