programgarden-core 1.31.1__tar.gz → 2.1.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 (86) hide show
  1. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/PKG-INFO +3 -3
  2. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/README.md +1 -1
  3. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/code_node.py +17 -9
  4. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/expression/evaluator.py +43 -8
  5. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/en.json +1 -19
  6. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/ko.json +1 -19
  7. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/credential.py +0 -10
  8. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_korea_stock.py +1 -1
  9. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/base.py +16 -2
  10. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/code.py +21 -8
  11. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data.py +1 -1
  12. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_futures.py +3 -5
  13. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_korea_stock.py +3 -4
  14. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_stock.py +3 -23
  15. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/display.py +1 -1
  16. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/fundamental_stock.py +8 -5
  17. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/order.py +2 -2
  18. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/risk.py +1 -1
  19. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol.py +9 -5
  20. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_futures.py +1 -1
  21. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/trigger.py +33 -28
  22. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/pyproject.toml +3 -3
  23. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/__init__.py +0 -0
  24. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/__init__.py +0 -0
  25. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/client.py +0 -0
  26. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/components.py +0 -0
  27. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/listener.py +0 -0
  28. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/mixins.py +0 -0
  29. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/products.py +0 -0
  30. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/sql.py +0 -0
  31. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/storage.py +0 -0
  32. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/exceptions.py +0 -0
  33. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/expression/__init__.py +0 -0
  34. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/i18n/__init__.py +0 -0
  35. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/i18n/translator.py +0 -0
  36. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/korea_alias.py +0 -0
  37. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/__init__.py +0 -0
  38. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/connection_rule.py +0 -0
  39. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/edge.py +0 -0
  40. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/event.py +0 -0
  41. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/exchange.py +0 -0
  42. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/field_binding.py +0 -0
  43. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/job.py +0 -0
  44. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/order_diagnostics.py +0 -0
  45. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/plugin_resource.py +0 -0
  46. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/resilience.py +0 -0
  47. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/resource.py +0 -0
  48. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/validation.py +0 -0
  49. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/workflow.py +0 -0
  50. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/__init__.py +0 -0
  51. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_futures.py +0 -0
  52. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_stock.py +0 -0
  53. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/ai.py +0 -0
  54. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest.py +0 -0
  55. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_futures.py +0 -0
  56. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_korea_stock.py +0 -0
  57. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_stock.py +0 -0
  58. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/broker.py +0 -0
  59. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/calculation.py +0 -0
  60. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/condition.py +0 -0
  61. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/event.py +0 -0
  62. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/fundamental_korea_stock.py +0 -0
  63. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/infra.py +0 -0
  64. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_external.py +0 -0
  65. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_status.py +0 -0
  66. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_futures.py +0 -0
  67. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_korea_stock.py +0 -0
  68. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_stock.py +0 -0
  69. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/orderable_quantity_futures.py +0 -0
  70. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/portfolio.py +0 -0
  71. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_futures.py +0 -0
  72. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_korea_stock.py +0 -0
  73. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_stock.py +0 -0
  74. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/session_gate.py +0 -0
  75. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_korea_stock.py +0 -0
  76. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_stock.py +0 -0
  77. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/__init__.py +0 -0
  78. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/news_analyst.json +0 -0
  79. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/risk_manager.json +0 -0
  80. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/strategist.json +0 -0
  81. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/technical_analyst.json +0 -0
  82. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/registry/__init__.py +0 -0
  83. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/registry/credential_registry.py +0 -0
  84. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/registry/node_registry.py +0 -0
  85. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/registry/plugin_registry.py +0 -0
  86. {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/retry_executor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden-core
3
- Version: 1.31.1
3
+ Version: 2.1.0
4
4
  Summary: ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Programming Language :: Python :: 3.12
11
11
  Classifier: Programming Language :: Python :: 3.13
12
12
  Classifier: Programming Language :: Python :: 3.14
13
- Requires-Dist: pydantic (>=2.0.0,<3.0.0)
13
+ Requires-Dist: pydantic (>=2.0.1,<3.0.0)
14
14
  Requires-Dist: tzdata (>=2025.2,<2026.0)
15
15
  Description-Content-Type: text/markdown
16
16
 
@@ -59,7 +59,7 @@ poetry add programgarden-core
59
59
  | analysis | 2 | BacktestEngineNode, BenchmarkCompareNode |
60
60
  | ai | 2 | LLMModelNode, AIAgentNode |
61
61
 
62
- > Community adds five nodes: TelegramNode, FearGreedIndexNode, FundamentalDataNode, FileReaderNode and PerformanceReportNode, for 78 total.
62
+ > Community adds four nodes: TelegramNode, FearGreedIndexNode, FileReaderNode and PerformanceReportNode, for 77 total.
63
63
 
64
64
  ## 사용 예시
65
65
 
@@ -43,7 +43,7 @@ poetry add programgarden-core
43
43
  | analysis | 2 | BacktestEngineNode, BenchmarkCompareNode |
44
44
  | ai | 2 | LLMModelNode, AIAgentNode |
45
45
 
46
- > Community adds five nodes: TelegramNode, FearGreedIndexNode, FundamentalDataNode, FileReaderNode and PerformanceReportNode, for 78 total.
46
+ > Community adds four nodes: TelegramNode, FearGreedIndexNode, FileReaderNode and PerformanceReportNode, for 77 total.
47
47
 
48
48
  ## 사용 예시
49
49
 
@@ -104,9 +104,11 @@ FORBIDDEN_ATTR_NAMES: frozenset = frozenset({
104
104
  })
105
105
 
106
106
  # ── Denylist: introspection-escape / exfiltration dunders & frame attrs ──────
107
- # NOTE: enforcement is the BLANKET underscore rule in _scan_ast (any attribute
108
- # or string beginning with '_' is rejected), which subsumes every dunder. This
109
- # explicit set is retained for documentation / message clarity.
107
+ # NOTE: enforcement is the underscore rule in _scan_ast — any ATTRIBUTE beginning
108
+ # with '_' is rejected (subsumes every dunder and private module re-export like
109
+ # random._os); STRING literals are rejected when they contain a dunder or name a
110
+ # blocked attribute re-export (bare single-underscore data-key strings such as
111
+ # "_partial_failure" are allowed). This explicit set is retained for message clarity.
110
112
  FORBIDDEN_DUNDERS: frozenset = frozenset({
111
113
  "__class__", "__bases__", "__base__", "__subclasses__", "__mro__",
112
114
  "__globals__", "__code__", "__closure__", "__func__", "__self__",
@@ -224,12 +226,18 @@ def _scan_ast(tree: ast.AST, allowed_imports: Set[str]) -> List[Dict[str, Any]]:
224
226
  elif isinstance(node, ast.Constant) and isinstance(node.value, str):
225
227
  v = node.value
226
228
  bad = None
227
- if v.startswith("_"):
228
- bad = f"string literal '{v}' begins with '_' (introspection escape)"
229
- elif "__" in v:
230
- bad = f"string literal contains a dunder (introspection escape)"
231
- elif v in FORBIDDEN_ATTR_NAMES:
232
- bad = f"string literal '{v}' names a blocked attribute"
229
+ # A single leading underscore names a documented native data marker
230
+ # read as a dict key (e.g. balance["_partial_failure"], "_source"),
231
+ # so it is allowed. Still rejected: dunder-style names, and blocked
232
+ # public/underscored attribute re-exports ("os"/"sys"/"system"/"_os"/
233
+ # "_sys"... -> in FORBIDDEN_ATTR_NAMES via lstrip("_")), and attribute-
234
+ # walking format fields. getattr/attrgetter/operator/format are
235
+ # independently blocked, so a bare underscore data-key string cannot
236
+ # be turned into attribute access.
237
+ if "__" in v:
238
+ bad = "string literal contains a dunder (introspection escape)"
239
+ elif v.lstrip("_") in FORBIDDEN_ATTR_NAMES:
240
+ bad = f"string literal '{v}' names a blocked attribute (introspection escape)"
233
241
  elif _FORMAT_ATTR_RE.search(v):
234
242
  bad = "format string performs attribute access (introspection escape)"
235
243
  if bad:
@@ -56,6 +56,8 @@ def _get_nested_value(obj: Any, path: str) -> Any:
56
56
  return None
57
57
  if isinstance(current, dict):
58
58
  current = current.get(key)
59
+ elif isinstance(current, NodeOutputProxy) and isinstance(current._data, dict) and key in current._data:
60
+ current = current._data[key] # port before helper method (see _eval_node)
59
61
  else:
60
62
  current = getattr(current, key, None)
61
63
  return current
@@ -149,6 +151,21 @@ class DateNamespace:
149
151
  'iso': '%Y-%m-%d',
150
152
  }
151
153
 
154
+ def __init__(self, *, as_of: Optional[str] = None):
155
+ """Optionally pin replay helpers to an explicit timezone-aware instant.
156
+
157
+ Live callers omit as_of and retain their existing local-clock behavior.
158
+ The execution host, never a model-supplied node setting, owns this clock.
159
+ """
160
+ self._as_of = None
161
+ if as_of is not None:
162
+ self._as_of = datetime.fromisoformat(as_of.replace("Z", "+00:00"))
163
+ if self._as_of.tzinfo is None or self._as_of.utcoffset() is None:
164
+ raise ValueError("Replay as_of must include its timezone")
165
+
166
+ def _today(self) -> date:
167
+ return self._as_of.date() if self._as_of is not None else date.today()
168
+
152
169
  def _format_date(self, d: date, fmt: Optional[str] = None) -> str:
153
170
  """날짜를 지정된 포맷으로 변환"""
154
171
  if fmt is None:
@@ -159,40 +176,40 @@ class DateNamespace:
159
176
 
160
177
  def today(self, format: Optional[str] = None) -> str:
161
178
  """오늘 날짜"""
162
- return self._format_date(date.today(), format)
179
+ return self._format_date(self._today(), format)
163
180
 
164
181
  def now(self) -> str:
165
182
  """현재 시간 (ISO 형식)"""
166
- return datetime.now().isoformat()[:19]
183
+ return self._as_of.isoformat(timespec="seconds") if self._as_of is not None else datetime.now().isoformat()[:19]
167
184
 
168
185
  def ago(self, days: int, format: Optional[str] = None) -> str:
169
186
  """n일 전"""
170
- d = date.today() - timedelta(days=int(days))
187
+ d = self._today() - timedelta(days=int(days))
171
188
  return self._format_date(d, format)
172
189
 
173
190
  def later(self, days: int, format: Optional[str] = None) -> str:
174
191
  """n일 후"""
175
- d = date.today() + timedelta(days=int(days))
192
+ d = self._today() + timedelta(days=int(days))
176
193
  return self._format_date(d, format)
177
194
 
178
195
  def months_ago(self, months: int, format: Optional[str] = None) -> str:
179
196
  """n개월 전 (30일 기준)"""
180
- d = date.today() - timedelta(days=int(months) * 30)
197
+ d = self._today() - timedelta(days=int(months) * 30)
181
198
  return self._format_date(d, format)
182
199
 
183
200
  def year_start(self, format: Optional[str] = None) -> str:
184
201
  """연초 (1월 1일)"""
185
- d = date(date.today().year, 1, 1)
202
+ d = date(self._today().year, 1, 1)
186
203
  return self._format_date(d, format)
187
204
 
188
205
  def year_end(self, format: Optional[str] = None) -> str:
189
206
  """연말 (12월 31일)"""
190
- d = date(date.today().year, 12, 31)
207
+ d = date(self._today().year, 12, 31)
191
208
  return self._format_date(d, format)
192
209
 
193
210
  def month_start(self, format: Optional[str] = None) -> str:
194
211
  """월초"""
195
- d = date.today().replace(day=1)
212
+ d = self._today().replace(day=1)
196
213
  return self._format_date(d, format)
197
214
 
198
215
 
@@ -926,6 +943,15 @@ class SafeEvaluator:
926
943
  obj = self._eval_node(node.value)
927
944
  if isinstance(obj, dict):
928
945
  return obj.get(node.attr)
946
+ # An output PORT wins over a proxy helper of the same name: a node
947
+ # whose port is called `count` (OpenOrdersNode), `sum`, `first`, `last`,
948
+ # `map` or `filter` must resolve to the port value, not to the bound
949
+ # helper method (observed 2026-09-24: `{{ nodes.open_orders.count }}`
950
+ # returned `NodeOutputProxy.count` and the CodeNode input could not be
951
+ # serialized — "Object of type method is not JSON serializable").
952
+ if isinstance(obj, NodeOutputProxy) and isinstance(obj._data, dict) and node.attr in obj._data:
953
+ value = obj._data[node.attr]
954
+ return NodeOutputProxy(value) if isinstance(value, list) else value
929
955
  return getattr(obj, node.attr, None)
930
956
 
931
957
  # 인덱싱 (arr[0], dict["key"])
@@ -937,6 +963,15 @@ class SafeEvaluator:
937
963
  # 함수 호출
938
964
  if isinstance(node, ast.Call):
939
965
  func = self._eval_node(node.func)
966
+ if not callable(func) and isinstance(node.func, ast.Attribute):
967
+ # `{{ nodes.open_orders.count() }}`: the bare attribute resolves to
968
+ # the `count` PORT (see the Attribute branch), but a CALL of that
969
+ # name still means the proxy helper, so workflows written against
970
+ # the helper keep working on nodes that also declare the port.
971
+ owner = self._eval_node(node.func.value)
972
+ helper = getattr(owner, node.func.attr, None) if isinstance(owner, NodeOutputProxy) else None
973
+ if callable(helper):
974
+ func = helper
940
975
  if not callable(func):
941
976
  raise ExpressionError(f"호출 불가능한 객체: {func}")
942
977
  args = [self._eval_node(arg) for arg in node.args]
@@ -64,8 +64,6 @@
64
64
  "nodes.EventHandlerNode.name": "Event Handler",
65
65
  "nodes.FearGreedIndexNode.description": "Fetches the CNN Fear & Greed Index, a market sentiment indicator ranging from 0 (Extreme Fear) to 100 (Extreme Greed). No broker connection or API key required. Outputs the current index value (0-100), label (Extreme Fear / Fear / Neutral / Greed / Extreme Greed), and previous day's close value. Built-in resilience with retry on API failures. Typical connection: FearGreedIndexNode → ConditionNode (for sentiment-based trading signals) or → SummaryDisplayNode.",
66
66
  "nodes.FearGreedIndexNode.name": "Fear & Greed Index",
67
- "nodes.FundamentalDataNode.description": "Fetches company financial data via Financial Modeling Prep (FMP) API. Supports four data types: company profile, income statement, balance sheet, and key metrics. Configure period (annual/quarter) and limit (number of periods to fetch). Requires FMP API credentials. Outputs financial data array and query summary. Typical connection: WatchlistNode → FundamentalDataNode → ConditionNode (for fundamental screening) or → TableDisplayNode.",
68
- "nodes.FundamentalDataNode.name": "Fundamental Data (FMP)",
69
67
  "nodes.PerformanceReportNode.name": "Performance Report (quantstats)",
70
68
  "nodes.PerformanceReportNode.description": "Computes risk/return metrics (Sharpe, Sortino, max drawdown, CAGR, annualized volatility, Calmar) from an equity curve, price series, or return series using quantstats. Optionally computes beta and alpha against a benchmark series. Emits a drawdown time series and a formatted text report, and runs headless via the matplotlib Agg backend. Requires the optional 'perf' extra (pip install 'programgarden-community[perf]'); a missing or partially-installed extra raises a structured MissingDependencyError. Typical connection: BacktestEngineNode → PerformanceReportNode → TableDisplayNode.",
71
69
  "nodes.FieldMappingNode.description": "Renames field names in data objects to a standard format. Use to normalize external API responses so downstream nodes can access data with consistent field names (e.g., rename 'adj_close' to 'close', 'vol' to 'volume'). Configure mappings as from/to pairs. Outputs mapped_data with renamed fields. Set preserve_unmapped=true to keep original fields alongside renamed ones. Note: this node only renames fields — it does not generate or transform data values. Typical connection: HTTPRequestNode → FieldMappingNode → ConditionNode.",
@@ -293,7 +291,7 @@
293
291
  "fields.CurrencyRateNode.target_currencies": "Target currencies. Select currencies to get exchange rates against the base currency.",
294
292
  "fields.CurrencyRateNode.timeout_seconds": "API request timeout (seconds). Default 30s, adjust based on your network conditions.",
295
293
  "fields.CodeNode.code": "Python source that defines async def execute(data, params, context). Sandboxed: only pure-computation stdlib imports (math, statistics, json, datetime, ...) are allowed; no credentials, network, filesystem, or introspection.",
296
- "fields.CodeNode.outputs": "Output port declarations [{name, type}]. Leave empty for a single 'result' port carrying the whole return value. Declared ports are consumed downstream by {{ nodes.<id>.<port> }} expressions written into a consumer's generic input field (TableDisplayNode.data, IfNode.left/right, ...), not by typed-port matching; declaring them enables static typo-guarding.",
294
+ "fields.CodeNode.outputs": "Output port declarations [{name, type}]. Leave empty for a single 'result' port carrying the whole return value: return 3 yields result=3; return {'result':3} yields result={'result':3}. Declare [{name:'result',type:'number'}] to map a returned dict's result key to the number port. Declared ports are consumed downstream by {{ nodes.<id>.<port> }} expressions written into a consumer's generic input field (TableDisplayNode.data, IfNode.left/right, ...), not by typed-port matching; declaring them enables static typo-guarding.",
297
295
  "fields.CodeNode.params": "Parameters passed to execute() as `params`. Expression bindings are allowed (e.g. a period or threshold).",
298
296
  "fields.CodeNode.data": "Input data passed to execute() as `data`. Bind the whole upstream value (e.g. {{ nodes.hist.values }}) and loop over it inside the function.",
299
297
  "fields.FieldMappingNode.data": "Input data to transform. Bind from HTTPRequestNode response or other node outputs.",
@@ -318,13 +316,6 @@
318
316
  "fields.FileReaderNode.max_file_size_mb": "Maximum file size per file (MB). Range 1-50, default 10MB.",
319
317
  "fields.FearGreedIndexNode.resilience": "Retry and fallback settings for API call failures",
320
318
  "fields.FearGreedIndexNode.timeout_seconds": "API request timeout (seconds). Default 30s, adjust based on your network conditions.",
321
- "fields.FundamentalDataNode.credential_id": "FMP API key issued by financialmodelingprep.com. Select, replace or unlink its credential in node details. You can save an unlinked draft and run offline fixture checks; live data remains unverified until you connect a valid key with access to the selected endpoint.",
322
- "fields.FundamentalDataNode.symbols": "Symbols to query. [{symbol, exchange}] format.",
323
- "fields.FundamentalDataNode.data_type": "Data type: profile, income_statement, balance_sheet, key_metrics",
324
- "fields.FundamentalDataNode.period": "Period: annual or quarter. Used for financial statements and key metrics.",
325
- "fields.FundamentalDataNode.limit": "Number of periods to fetch (most recent N). Default 1.",
326
- "fields.FundamentalDataNode.timeout_seconds": "API request timeout (seconds). Default 30s.",
327
- "fields.FundamentalDataNode.resilience": "Retry and fallback settings for API call failures",
328
319
  "fields.PerformanceReportNode.data": "Input series: an array of numbers or of {date, <value_field>} objects (equity curve, price levels, or periodic returns).",
329
320
  "fields.PerformanceReportNode.data_kind": "Kind of the input series: 'equity', 'prices', or 'returns'. Level series ('equity'/'prices') are converted to returns internally.",
330
321
  "fields.PerformanceReportNode.value_field": "Key holding the numeric value when rows are objects (default 'close'; falls back to value/equity/return).",
@@ -655,13 +646,6 @@
655
646
  "fieldNames.FileReaderNode.max_file_size_mb": "Max Size (MB)",
656
647
  "fieldNames.FearGreedIndexNode.resilience": "Resilience",
657
648
  "fieldNames.FearGreedIndexNode.timeout_seconds": "Timeout (sec)",
658
- "fieldNames.FundamentalDataNode.credential_id": "FMP Credential",
659
- "fieldNames.FundamentalDataNode.symbols": "Symbols",
660
- "fieldNames.FundamentalDataNode.data_type": "Data Type",
661
- "fieldNames.FundamentalDataNode.period": "Period",
662
- "fieldNames.FundamentalDataNode.limit": "Limit",
663
- "fieldNames.FundamentalDataNode.timeout_seconds": "Timeout (sec)",
664
- "fieldNames.FundamentalDataNode.resilience": "Resilience",
665
649
  "fieldNames.PerformanceReportNode.data": "Data Series",
666
650
  "fieldNames.PerformanceReportNode.data_kind": "Data Kind",
667
651
  "fieldNames.PerformanceReportNode.value_field": "Value Field",
@@ -886,8 +870,6 @@
886
870
  "outputs.FearGreedIndexNode.label": "Index label (Extreme Fear / Fear / Neutral / Greed / Extreme Greed)",
887
871
  "outputs.FearGreedIndexNode.previous_close": "Previous day's Fear & Greed Index value",
888
872
  "outputs.FearGreedIndexNode.value": "Fear & Greed Index (0~100)",
889
- "outputs.FundamentalDataNode.data": "Financial data array [{symbol, exchange, ...}]",
890
- "outputs.FundamentalDataNode.summary": "Query summary {data_type, symbol_count, record_count}",
891
873
  "outputs.PerformanceReportNode.metrics": "Metrics object {sharpe, sortino, max_drawdown, cagr, volatility, calmar[, beta, alpha]} (null on insufficient data).",
892
874
  "outputs.PerformanceReportNode.report": "Formatted multi-line performance report text.",
893
875
  "outputs.PerformanceReportNode.drawdown_series": "Drawdown time series as an array of {date, drawdown}.",
@@ -68,8 +68,6 @@
68
68
  "nodes.FileReaderNode.name": "파일 리더",
69
69
  "nodes.FearGreedIndexNode.description": "CNN 공포/탐욕 지수를 조회합니다. 0(극단적 공포)부터 100(극단적 탐욕)까지의 시장 심리 지표입니다. 브로커 연결이나 API 키가 필요 없습니다. 출력: 현재 지수값(0-100), 라벨(Extreme Fear / Fear / Neutral / Greed / Extreme Greed), 전일 종가. API 오류 시 자동 재시도 기능 내장. 일반적인 연결: FearGreedIndexNode → ConditionNode (심리 기반 매매 시그널) 또는 → SummaryDisplayNode.",
70
70
  "nodes.FearGreedIndexNode.name": "공포/탐욕 지수",
71
- "nodes.FundamentalDataNode.description": "Financial Modeling Prep (FMP) API로 기업 재무 데이터를 조회합니다. 4가지 데이터 유형: 기업 프로필, 손익계산서, 대차대조표, 핵심 지표. 기간(연간/분기)과 조회 수(최근 N기)를 설정합니다. FMP API 인증 정보 필요. 출력: 재무 데이터 배열, 조회 요약. 일반적인 연결: WatchlistNode → FundamentalDataNode → ConditionNode (펀더멘털 스크리닝) 또는 → TableDisplayNode.",
72
- "nodes.FundamentalDataNode.name": "재무 데이터 (FMP)",
73
71
  "nodes.PerformanceReportNode.name": "성과 리포트 (quantstats)",
74
72
  "nodes.PerformanceReportNode.description": "자산곡선(equity)·가격·수익률 시계열로부터 quantstats 로 리스크·수익 지표(Sharpe, Sortino, 최대낙폭, CAGR, 연율 변동성, Calmar)를 산출합니다. 벤치마크 시계열을 지정하면 beta/alpha 를 추가로 계산합니다. 낙폭 시계열과 서식 리포트 텍스트를 출력하며, matplotlib Agg 백엔드로 헤드리스 동작합니다. 선택 'perf' extra 필요(pip install 'programgarden-community[perf]') — 미설치/부분설치 시 구조화된 MissingDependencyError 를 발생시킵니다. 대표 연결: BacktestEngineNode → PerformanceReportNode → TableDisplayNode.",
75
73
  "nodes.HTTPRequestNode.description": "GET, POST, PUT, PATCH, DELETE를 지원하는 외부 REST API 호출 노드입니다. URL({{ }} 표현식 지원), 헤더, 쿼리 파라미터, JSON 요청 본문을 설정합니다. 인증 방식: Bearer 토큰, 헤더 키, 기본 인증, 쿼리 파라미터. 서버 에러(5xx), 속도 제한(429), 네트워크 장애 시 자동 재시도 기능 내장. 출력: 응답 데이터, HTTP 상태 코드, 응답 헤더, 응답 시간(ms). 실시간 데이터 소스 연결 시 ThrottleNode 필수. 일반적인 연결: ThrottleNode → HTTPRequestNode → FieldMappingNode. AI Agent 도구로 사용 가능.",
@@ -293,7 +291,7 @@
293
291
  "fields.CurrencyRateNode.target_currencies": "대상 통화 목록. 기준 통화 대비 환율을 조회할 통화를 선택하세요.",
294
292
  "fields.CurrencyRateNode.timeout_seconds": "API 요청 타임아웃 (초). 기본 30초, 네트워크 환경에 따라 조정하세요.",
295
293
  "fields.CodeNode.code": "async def execute(data, params, context) 를 정의하는 파이썬 소스. 샌드박스: 순수 계산용 표준 라이브러리(math, statistics, json, datetime, ...) import 만 허용, credential·네트워크·파일시스템·introspection 불가.",
296
- "fields.CodeNode.outputs": "출력 포트 선언 [{name, type}]. 비워두면 반환값 전체를 담는 단일 'result' 포트가 됩니다. 선언한 포트는 소비자 노드의 범용 입력 필드(TableDisplayNode.data, IfNode.left/right 등)에 {{ nodes.<id>.<port> }} 표현식으로 소비됩니다 — 타입드 포트 매칭이 아니라 이름으로 dict 키를 꺼내는 방식. 선언하면 정적 오타 검증이 적용됩니다.",
294
+ "fields.CodeNode.outputs": "출력 포트 선언 [{name, type}]. 비워두면 반환값 전체를 담는 단일 'result' 포트가 됩니다. 선언한 포트는 소비자 노드의 범용 입력 필드(TableDisplayNode.data, IfNode.left/right 등)에 {{ nodes.<id>.<port> }} 표현식으로 소비됩니다 — 타입드 포트 매칭이 아니라 이름으로 dict 키를 꺼내는 방식. 선언하면 정적 오타 검증이 적용됩니다. outputs를 비우면 반환값 전체가 result입니다. return 3은 result=3, return {'result':3}은 result={'result':3}이 됩니다. 반환 딕셔너리의 result 키를 숫자 포트로 사용하려면 outputs에 name:result, type:number를 선언하세요.",
297
295
  "fields.CodeNode.params": "execute() 에 `params` 로 전달되는 파라미터. 표현식 바인딩 허용(예: 기간·임계값).",
298
296
  "fields.CodeNode.data": "execute() 에 `data` 로 전달되는 입력 데이터. 상류 값 전체를 바인딩(예: {{ nodes.hist.values }})하고 함수 안에서 반복하세요.",
299
297
  "fields.FieldMappingNode.data": "변환할 입력 데이터. HTTPRequestNode 응답이나 다른 노드 출력을 바인딩하세요.",
@@ -318,13 +316,6 @@
318
316
  "fields.FileReaderNode.max_file_size_mb": "파일당 최대 크기 (MB). 1~50, 기본 10MB.",
319
317
  "fields.FearGreedIndexNode.resilience": "API 호출 실패 시 재시도 및 폴백 설정",
320
318
  "fields.FearGreedIndexNode.timeout_seconds": "API 요청 타임아웃 (초). 기본 30초, 네트워크 환경에 따라 조정하세요.",
321
- "fields.FundamentalDataNode.credential_id": "financialmodelingprep.com에서 발급받은 FMP API 키를 등록하고 선택하세요. 노드 상세정보에서 교체·연결 해제가 가능합니다. 키 없이 초안 저장과 샘플 데이터 검증은 가능하지만, 실제 데이터 조회는 해당 자료의 이용권한이 있는 키를 연결한 뒤 확인해야 합니다.",
322
- "fields.FundamentalDataNode.symbols": "조회할 종목 목록. [{symbol, exchange}] 형식.",
323
- "fields.FundamentalDataNode.data_type": "데이터 유형: profile(기업정보), income_statement(손익계산서), balance_sheet(대차대조표), key_metrics(핵심지표)",
324
- "fields.FundamentalDataNode.period": "기간: annual(연간), quarter(분기). 재무제표/핵심지표 조회 시 사용.",
325
- "fields.FundamentalDataNode.limit": "가져올 기간 수 (최근 N개). 기본 1.",
326
- "fields.FundamentalDataNode.timeout_seconds": "API 요청 타임아웃 (초). 기본 30초.",
327
- "fields.FundamentalDataNode.resilience": "API 호출 실패 시 재시도 및 폴백 설정",
328
319
  "fields.PerformanceReportNode.data": "입력 시계열: 숫자 배열 또는 {date, <value_field>} 객체 배열(자산곡선/가격/수익률).",
329
320
  "fields.PerformanceReportNode.data_kind": "입력 종류: 'equity'/'prices'/'returns'. 레벨 시계열('equity'/'prices')은 내부에서 수익률로 변환됩니다.",
330
321
  "fields.PerformanceReportNode.value_field": "행이 객체일 때 숫자 값을 담은 키(기본 'close'; value/equity/return 폴백).",
@@ -655,13 +646,6 @@
655
646
  "fieldNames.FileReaderNode.max_file_size_mb": "최대크기(MB)",
656
647
  "fieldNames.FearGreedIndexNode.resilience": "재시도설정",
657
648
  "fieldNames.FearGreedIndexNode.timeout_seconds": "타임아웃(초)",
658
- "fieldNames.FundamentalDataNode.credential_id": "FMP 인증",
659
- "fieldNames.FundamentalDataNode.symbols": "종목",
660
- "fieldNames.FundamentalDataNode.data_type": "데이터유형",
661
- "fieldNames.FundamentalDataNode.period": "기간",
662
- "fieldNames.FundamentalDataNode.limit": "조회수",
663
- "fieldNames.FundamentalDataNode.timeout_seconds": "타임아웃(초)",
664
- "fieldNames.FundamentalDataNode.resilience": "재시도설정",
665
649
  "fieldNames.PerformanceReportNode.data": "데이터 시계열",
666
650
  "fieldNames.PerformanceReportNode.data_kind": "데이터 종류",
667
651
  "fieldNames.PerformanceReportNode.value_field": "값 필드",
@@ -886,8 +870,6 @@
886
870
  "outputs.FearGreedIndexNode.label": "지수 라벨 (Extreme Fear / Fear / Neutral / Greed / Extreme Greed)",
887
871
  "outputs.FearGreedIndexNode.previous_close": "전일 공포/탐욕 지수",
888
872
  "outputs.FearGreedIndexNode.value": "공포/탐욕 지수 (0~100)",
889
- "outputs.FundamentalDataNode.data": "재무 데이터 배열 [{symbol, exchange, ...}]",
890
- "outputs.FundamentalDataNode.summary": "조회 요약 {data_type, symbol_count, record_count}",
891
873
  "outputs.PerformanceReportNode.metrics": "지표 객체 {sharpe, sortino, max_drawdown, cagr, volatility, calmar[, beta, alpha]} (데이터 부족 시 null).",
892
874
  "outputs.PerformanceReportNode.report": "서식 성과 리포트 텍스트.",
893
875
  "outputs.PerformanceReportNode.drawdown_series": "낙폭 시계열 [{date, drawdown}] 배열.",
@@ -91,16 +91,6 @@ class Credential(BaseModel):
91
91
  # Built-in credential type schemas
92
92
  # 디자인(레이아웃, 간격, 스타일) 요소는 포함하지 않음 - 클라이언트 개발자가 직접 구현
93
93
  BUILTIN_CREDENTIAL_SCHEMAS: Dict[str, CredentialTypeSchema] = {
94
- "fmp_api": CredentialTypeSchema(
95
- type_id="fmp_api",
96
- name="FMP 재무 데이터 API",
97
- description="증권사 계좌와 별도로 등록하는 Financial Modeling Prep 재무 데이터 API 키",
98
- widget_schema={"fields": [
99
- {"key": "name", "type": "text", "label": "인증정보 이름", "required": True},
100
- {"key": "api_key", "type": "password", "label": "FMP API 키", "required": True,
101
- "description": "https://site.financialmodelingprep.com/developer/docs/dashboard 에서 발급"},
102
- ]},
103
- ),
104
94
  # ============================================================
105
95
  # LS증권 해외주식 (overseas_stock) - 모의투자 미지원
106
96
  # ============================================================
@@ -111,7 +111,7 @@ class KoreaStockAccountNode(BaseNode):
111
111
  }
112
112
  ],
113
113
  },
114
- "expected_output": "positions port: list of {symbol, quantity, avg_price, pnl_rate} in KRW; balance port: {cash_krw, total_eval_krw, orderable_amount}; held_symbols: [{symbol}].",
114
+ "expected_output": "positions: observed holdings with symbol, quantity, avg_price, pnl_rate and cost/P&L availability; missing money stays null. balance: {deposit, total_eval, orderable_amount} in KRW; held_symbols: [{exchange, symbol}].",
115
115
  },
116
116
  {
117
117
  "title": "Cash check before Korea stock order",
@@ -328,7 +328,7 @@ OVERSEAS_STOCK_POSITION_FIELDS: List[Dict[str, str]] = [
328
328
  {"name": "market", "type": "string", "description": "시장 구분명"},
329
329
  ]
330
330
 
331
- # 국내주식 REST 계좌 (t0424)
331
+ # Domestic REST account: observed CSPAQ12300 evidence plus executor aliases.
332
332
  KOREA_STOCK_POSITION_FIELDS: List[Dict[str, str]] = [
333
333
  {"name": "exchange", "type": "string", "description": "거래소 코드 (항상 KRX)"},
334
334
  {"name": "symbol", "type": "string", "description": "종목코드"},
@@ -342,6 +342,20 @@ KOREA_STOCK_POSITION_FIELDS: List[Dict[str, str]] = [
342
342
  {"name": "sellable_qty", "type": "number", "description": "매도 가능 수량"},
343
343
  {"name": "eval_amount", "type": "number", "description": "평가 금액"},
344
344
  {"name": "product", "type": "string", "description": "상품 구분 (항상 korea_stock)"},
345
+ {"name": "symbol_name", "type": "string", "description": "Observed instrument name; name alias uses the same value"},
346
+ {"name": "currency", "type": "string", "description": "Valuation currency: KRW"},
347
+ {"name": "buy_price", "type": "number", "description": "Observed average acquisition price; null when unavailable"},
348
+ {"name": "average_price", "type": "number", "description": "Observed average acquisition price; null when unavailable"},
349
+ {"name": "acquisition_amount", "type": "number", "description": "Observed acquisition amount; null when unavailable"},
350
+ {"name": "purchase_amount", "type": "number", "description": "Acquisition amount alias; null when unavailable"},
351
+ {"name": "pnl_rate_basis", "type": "string", "description": "Broker average cost basis, commission excluded"},
352
+ {"name": "pnl_rate_status", "type": "string", "description": "available or unavailable; missing evidence is not zero return"},
353
+ {"name": "pnl_status", "type": "string", "description": "available or unavailable"},
354
+ {"name": "pnl_unavailable_reason", "type": "string", "description": "Missing P&L evidence reason; null when available"},
355
+ {"name": "cost_basis", "type": "string", "description": "Broker average cost basis, commission excluded"},
356
+ {"name": "cost_status", "type": "string", "description": "available or unavailable"},
357
+ {"name": "cost_unavailable_reason", "type": "string", "description": "Missing acquisition evidence reason; null when available"},
358
+ {"name": "observed_at", "type": "string", "description": "UTC ISO observation timestamp"},
345
359
  ]
346
360
 
347
361
  # 해외선물 REST 계좌 (CIDBQ01500 block2)
@@ -1030,4 +1044,4 @@ class BaseMessagingNode(BaseNode):
1030
1044
  category=FieldCategory.SETTINGS,
1031
1045
  depends_on={"resilience.fallback.mode": "default_value"},
1032
1046
  ),
1033
- }
1047
+ }
@@ -69,8 +69,10 @@ class CodeNode(BaseNode):
69
69
  - `context` = a READ-ONLY scrubbed context: safe helper namespaces
70
70
  (`context.date/finance/stats/format/lst`), a risk-tracker read snapshot,
71
71
  and workflow meta. It exposes NO credentials, broker, or executor.
72
- - Return a dict; its keys map to the declared `outputs` ports. If `outputs`
73
- is omitted, the entire return value is exposed on a single `result` port.
72
+ - With declared `outputs`, return a dict keyed by those ports. A single
73
+ declared port also accepts a scalar. With no declaration, the WHOLE return
74
+ value becomes `result`: return 3 gives result=3; return {'result': 3}
75
+ gives result={'result': 3}, not result=3.
74
76
 
75
77
  Example DSL:
76
78
  {
@@ -108,9 +110,15 @@ class CodeNode(BaseNode):
108
110
  outputs: List[Dict[str, str]] = Field(
109
111
  default_factory=list,
110
112
  description=(
111
- "Output port declarations [{name, type}]. Empty → single 'result' port. "
113
+ "Output port declarations [{name, type}]. Empty exposes the WHOLE return on result: "
114
+ "return 3 gives result=3; return {'result': 3} gives result={'result': 3}. "
115
+ "With outputs=[{name:'result',type:'number'}], return {'result':3} gives result=3. "
112
116
  "Declared ports are consumed downstream by {{ nodes.<id>.<port> }} expressions, "
113
- "not by typed-port matching; declaring them enables static typo-guarding."
117
+ "not by typed-port matching; declaring them enables static typo-guarding. "
118
+ "Do NOT name a port 'error' or 'reason': the engine treats a top-level 'error' "
119
+ "value (and a top-level 'reason' of no_symbol/no_price/invalid_input) as a node "
120
+ "failure, so such a port makes a successful return read as an engine error. Put "
121
+ "any status/diagnostic under a differently named port or nested object instead."
114
122
  ),
115
123
  )
116
124
 
@@ -174,9 +182,14 @@ class CodeNode(BaseNode):
174
182
  },
175
183
  {
176
184
  "pattern": "Declaring 'outputs' ports whose names the return dict never sets",
177
- "reason": "A declared port missing from the return maps to None with a warning — a silent-looking gap downstream.",
185
+ "reason": "Deep/replay validation rejects missing declared ports and values that violate their declared types. Legacy live mapping to None is not evidence of a valid output.",
178
186
  "alternative": "Return a dict whose keys exactly match every declared output port name.",
179
187
  },
188
+ {
189
+ "pattern": "Naming a declared output port 'error' or 'reason'",
190
+ "reason": "The engine (live runtime and replay) treats a top-level 'error' value, and a top-level 'reason' of no_symbol/no_price/invalid_input, as a node failure. A port with either name makes the node's own successful output read as an engine execution error, and no calculation defect exists to repair.",
191
+ "alternative": "Rename the port (e.g. 'status', 'note', 'detail') or nest status inside another object such as {'result': {...}}; keep 'error'/'reason' out of the top-level return.",
192
+ },
180
193
  {
181
194
  "pattern": "Feeding a CodeNode return of non-standard shape into a typed node (order node, ConditionNode)",
182
195
  "reason": "The binding layer checks port existence but never coerces shape; a wrong shape does not fail at the CodeNode — it fails downstream where the typed node reads it (e.g. order nodes expect the Symbol Data Format array of {symbol, exchange, ...}).",
@@ -347,7 +360,7 @@ class CodeNode(BaseNode):
347
360
  ]
348
361
  _node_guide: ClassVar[Dict[str, Any]] = {
349
362
  "input_handling": "SINGLE upstream: bind the whole upstream value to 'data' (e.g. \"{{ nodes.hist.values }}\") and loop over it inside execute() — CodeNode is not per-item auto-iterated. MULTIPLE upstreams (2+ producers feeding this node — fully supported): 'data' holds ONE whole value, so do NOT try to merge producers into it. Keep an edge from EACH producer and bind EACH upstream value to its own key under 'params' — params values are expression-bindable: \"params\": {\"fgi_value\": \"{{ nodes.fgi.value }}\", \"vix_response\": \"{{ nodes.vix_http.response }}\"} — then read them with params.get('fgi_value', 50) inside execute(). params.get(...) is the normal, always-allowed way to read bound inputs. 'params' also carries fixed knobs (literals). Declare 'outputs' when you want named ports; omit it to use the single 'result' port.",
350
- "output_consumption": "Downstream nodes do NOT need a matching typed port. They consume a CodeNode output by writing a {{ nodes.<id>.<port> }} expression into their own generic input field (e.g. TableDisplayNode.data, IfNode.left/right, FieldMappingNode.data) — the binding layer resolves the dict key by name, it does not type-match ports. With no outputs declared, read {{ nodes.<id>.result }} (the whole return value). A declared output port that no node references is fine; a declared port absent from the return dict resolves to None with a warning. Because ports are declared, validate() typo-guards these references.",
363
+ "output_consumption": "Downstream nodes do NOT need a matching typed port. They consume a CodeNode output by writing a {{ nodes.<id>.<port> }} expression into their own generic input field (e.g. TableDisplayNode.data, IfNode.left/right, FieldMappingNode.data) — the binding layer resolves the dict key by name, it does not type-match ports. With no outputs declared, read {{ nodes.<id>.result }} (the whole return value). A declared output port that no node references is fine; a declared port absent from the return dict fails deep/replay validation. Do not rely on legacy live None substitution. Because ports are declared, validate() typo-guards these references.",
351
364
  "common_combinations": [
352
365
  "CodeNode → TableDisplayNode / LineChartNode / TelegramNode (display or sink — any return shape works)",
353
366
  "CodeNode → OverseasStockNewOrderNode / ConditionNode (typed node — return the standard [{symbol, exchange, ...}] shape)",
@@ -362,7 +375,7 @@ class CodeNode(BaseNode):
362
375
  "sandbox, and I/O/system stdlib (os, sys, socket, subprocess, urllib, http, requests, open) is "
363
376
  "blocked. Re-implement such functionality by hand in pure Python using the allowed stdlib.",
364
377
  "No credential/broker/network access — CodeNode cannot place orders or fetch data itself; feed it data from typed nodes.",
365
- "Declared output ports must all appear as keys in the returned dict, or they resolve to None.",
378
+ "Every declared output must be present and match its type in deep/replay validation. A single declared port also supports a scalar return.",
366
379
  "There is NO one-upstream limit. Only the `data` field carries a single whole value — "
367
380
  "the node accepts any number of upstream producers. With 2+ producers, keep an edge "
368
381
  "from each and bind each value to its own `params` key "
@@ -440,7 +453,7 @@ class CodeNode(BaseNode):
440
453
  expression_mode=ExpressionMode.FIXED_ONLY,
441
454
  category=FieldCategory.PARAMETERS,
442
455
  ui_component=UIComponent.CUSTOM_CODE_EDITOR,
443
- example="async def execute(data, params, context):\n return {'result': data}",
456
+ example="async def execute(data, params, context):\n return data",
444
457
  expected_type="str",
445
458
  help_text=(
446
459
  "Define async def execute(data, params, context). Sandboxed: no "
@@ -212,7 +212,7 @@ class SQLiteNode(BaseNode):
212
212
  "operation": "simple",
213
213
  "table": "hwm",
214
214
  "action": "upsert",
215
- "values": {"symbol": "{{ nodes.market.value.symbol }}", "peak": '{{ nodes.market.value.price }}'},
215
+ "values": {"symbol": "{{ nodes.market.values[0].symbol }}", "peak": '{{ nodes.market.values[0].price }}'},
216
216
  "on_conflict": "symbol",
217
217
  },
218
218
  {
@@ -196,8 +196,6 @@ class OverseasFuturesMarketDataNode(BaseNode):
196
196
  InputPort(name="trigger", type="signal", description="i18n:ports.trigger", required=False),
197
197
  ]
198
198
  _outputs: List[OutputPort] = [
199
- OutputPort(name="value", type="market_data", description="i18n:ports.market_data_value",
200
- fields=FUTURES_REST_PRICE_FIELDS),
201
199
  OutputPort(
202
200
  name="values",
203
201
  type="array",
@@ -206,9 +204,9 @@ class OverseasFuturesMarketDataNode(BaseNode):
206
204
  ),
207
205
  ]
208
206
 
209
- _version: ClassVar[str] = "1.0.0"
210
- _updated_at: ClassVar[str] = "2026-05-19"
211
- _change_note: ClassVar[Optional[str]] = None
207
+ _version: ClassVar[str] = "1.0.1"
208
+ _updated_at: ClassVar[str] = "2026-09-23"
209
+ _change_note: ClassVar[Optional[str]] = "Remove nonexistent singular quote output; bind the values array."
212
210
 
213
211
  @classmethod
214
212
  def get_field_schema(cls) -> Dict[str, "FieldSchema"]:
@@ -195,7 +195,6 @@ class KoreaStockMarketDataNode(BaseNode):
195
195
  InputPort(name="trigger", type="signal", description="i18n:ports.trigger", required=False),
196
196
  ]
197
197
  _outputs: List[OutputPort] = [
198
- OutputPort(name="value", type="market_data", description="i18n:ports.market_data_value", fields=KOREA_STOCK_PRICE_DATA_FIELDS),
199
198
  OutputPort(
200
199
  name="values",
201
200
  type="array",
@@ -204,9 +203,9 @@ class KoreaStockMarketDataNode(BaseNode):
204
203
  ),
205
204
  ]
206
205
 
207
- _version: ClassVar[str] = "1.0.0"
208
- _updated_at: ClassVar[str] = "2026-05-19"
209
- _change_note: ClassVar[Optional[str]] = None
206
+ _version: ClassVar[str] = "1.0.1"
207
+ _updated_at: ClassVar[str] = "2026-09-23"
208
+ _change_note: ClassVar[Optional[str]] = "Remove nonexistent singular quote output; bind the values array."
210
209
 
211
210
  @classmethod
212
211
  def get_field_schema(cls) -> Dict[str, "FieldSchema"]:
@@ -196,26 +196,6 @@ class OverseasStockMarketDataNode(BaseNode):
196
196
  InputPort(name="trigger", type="signal", description="i18n:ports.trigger", required=False),
197
197
  ]
198
198
  _outputs: List[OutputPort] = [
199
- OutputPort(
200
- name="value",
201
- type="market_data",
202
- description="i18n:ports.market_data_value",
203
- fields=PRICE_DATA_FIELDS,
204
- example={
205
- "symbol": "AAPL",
206
- "exchange": "NASDAQ",
207
- "price": 187.45,
208
- "change": -2.34,
209
- "change_pct": -1.23,
210
- "volume": 12_345_678,
211
- "open": 189.10,
212
- "high": 190.02,
213
- "low": 186.90,
214
- "close": 187.45,
215
- "per": 28.5,
216
- "eps": 6.57,
217
- },
218
- ),
219
199
  OutputPort(
220
200
  name="values",
221
201
  type="array",
@@ -224,9 +204,9 @@ class OverseasStockMarketDataNode(BaseNode):
224
204
  ),
225
205
  ]
226
206
 
227
- _version: ClassVar[str] = "1.0.0"
228
- _updated_at: ClassVar[str] = "2026-05-19"
229
- _change_note: ClassVar[Optional[str]] = None
207
+ _version: ClassVar[str] = "1.0.1"
208
+ _updated_at: ClassVar[str] = "2026-09-23"
209
+ _change_note: ClassVar[Optional[str]] = "Remove nonexistent singular quote output; bind the values array."
230
210
 
231
211
  @classmethod
232
212
  def get_field_schema(cls) -> Dict[str, "FieldSchema"]:
@@ -190,7 +190,7 @@ class TableDisplayNode(BaseDisplayNode):
190
190
  {
191
191
  "pattern": "Binding a single dict (not a list) to TableDisplayNode data",
192
192
  "reason": "TableDisplayNode expects list[dict]. A plain dict renders as a single-column table with keys, which is rarely useful.",
193
- "alternative": "Wrap single records in a list ([{{ nodes.market.value }}]) or use SummaryDisplayNode for single-object display.",
193
+ "alternative": "Wrap single records in a list ([{{ nodes.market.values[0] }}]) or use SummaryDisplayNode for single-object display.",
194
194
  },
195
195
  {
196
196
  "pattern": "Setting limit=100 for a live dashboard that updates every second",
@@ -64,7 +64,7 @@ class OverseasStockFundamentalNode(BaseNode):
64
64
 
65
65
  _usage: ClassVar[Dict[str, Any]] = {
66
66
  "when_to_use": [
67
- "Retrieve fundamental valuation data (PER, EPS, PBR, market cap, 52w high/low, sector) for a US-listed stock",
67
+ "Retrieve LS security-detail data (PER, EPS, market cap, 52w high/low, industry) for a US-listed stock using the existing broker connection",
68
68
  "Screen or rank stocks by fundamental criteria — pair with ConditionNode or FieldMappingNode for filtering",
69
69
  "Give an AI Agent context about a stock's valuation alongside price data for investment thesis generation",
70
70
  ],
@@ -72,6 +72,7 @@ class OverseasStockFundamentalNode(BaseNode):
72
72
  "For Korean domestic stock fundamentals — use KoreaStockFundamentalNode",
73
73
  "When you only need price/volume data — use OverseasStockMarketDataNode",
74
74
  "For real-time earnings or news events — fundamental data is updated daily, not tick-by-tick",
75
+ "For financial-statement history, free cash flow, ROE or PBR — these are not returned by this node; use a separately verified data source",
75
76
  ],
76
77
  "typical_scenarios": [
77
78
  "SplitNode.item → OverseasStockFundamentalNode → ConditionNode (filter by PER < 20)",
@@ -80,7 +81,8 @@ class OverseasStockFundamentalNode(BaseNode):
80
81
  ],
81
82
  }
82
83
  _features: ClassVar[List[str]] = [
83
- "Returns per, pbr, eps, market_cap, shares_outstanding, 52w_high, 52w_low, sector, industry fields",
84
+ "Returns per, eps, market_cap, shares_outstanding, high_52w, low_52w and industry fields",
85
+ "Uses the LS overseas-stock broker credential; no separate financial-data subscription key is required",
84
86
  "Item-based execution: pair with SplitNode to fetch fundamentals for each symbol in a universe",
85
87
  "is_tool_enabled=True — AI Agent can call this node to analyze stock valuation autonomously",
86
88
  "Broker connection is auto-injected via DAG traversal from OverseasStockBrokerNode",
@@ -130,7 +132,7 @@ class OverseasStockFundamentalNode(BaseNode):
130
132
  }
131
133
  ],
132
134
  },
133
- "expected_output": "value port: {symbol, exchange, per, pbr, eps, market_cap, shares_outstanding, 52w_high, 52w_low, sector, industry}.",
135
+ "expected_output": "value port: {symbol, exchange, per, eps, market_cap, shares_outstanding, high_52w, low_52w, industry}.",
134
136
  },
135
137
  {
136
138
  "title": "Fundamental screening — filter low-PER stocks",
@@ -172,7 +174,7 @@ class OverseasStockFundamentalNode(BaseNode):
172
174
  "Supported exchanges: NYSE, NASDAQ, AMEX. Broker connection is auto-injected."
173
175
  ),
174
176
  "output_consumption": (
175
- "The `value` port emits: {symbol, exchange, per, pbr, eps, market_cap, shares_outstanding, 52w_high, 52w_low, sector, industry}. "
177
+ "The `value` port emits: {symbol, exchange, per, eps, market_cap, shares_outstanding, high_52w, low_52w, industry}. "
176
178
  "Access individual fields via `{{ nodes.fundamental.value.per }}`."
177
179
  ),
178
180
  "common_combinations": [
@@ -182,7 +184,8 @@ class OverseasStockFundamentalNode(BaseNode):
182
184
  ],
183
185
  "pitfalls": [
184
186
  "Fundamental data is updated daily — do not use it as a real-time signal",
185
- "PER may be null for pre-earnings or non-profitable companies — add null-check in ConditionNode logic",
187
+ "Missing broker fields can be empty or zero. For a positive-PER screen require a finite numeric per > 0 before comparing the upper bound; missing/zero/negative data must not create a buy signal",
188
+ "LS does not document the PER computation basis or the market-cap currency unit here. Do not claim TTM equivalence or compare cross-provider values without confirming the contract",
186
189
  "The symbol field expects a single dict; use SplitNode for multi-symbol fundamental queries",
187
190
  ],
188
191
  }
@@ -389,7 +389,7 @@ class OverseasStockNewOrderNode(BaseOrderNode):
389
389
  "Supports extended price_type options: LOO, LOC, MOO, MOC for open/close auction orders",
390
390
  "is_tool_enabled=True — AI Agent can call this node as a tool to place orders autonomously",
391
391
  "retry is disabled by default (resilience.retry.enabled=False); only pure network-connection failures may be retried, never a submitted order",
392
- "US daytime (Blue Ocean) session accepts LIMIT orders only — a market order is refused at intake with rsp_cd=00891 and no order number. A workflow scheduled across both the daytime and the regular overnight session must therefore use order_type='limit' for it to work in both.",
392
+ "US daytime (Blue Ocean) session accepts LIMIT orders only — a market order is refused at intake with rsp_cd=00891 and no order number (00891 is a generic order-price rejection: it was also observed on 2026-09-24 for a price of 1 USD or more sent with more than two decimals — '주문단가 오류. $1이상은 소수점 2째 자리까지 입력가능합니다'). A workflow scheduled across both the daytime and the regular overnight session must therefore use order_type='limit' for it to work in both.",
393
393
  "A limit order needs a price. Buy orders fall back to a current-price lookup, and so do sell orders since engine 1.37.3, but that lookup (g3101) only accepts exchange codes 81/82 — it fails for a holding listed on any other market. Prefer feeding the price the account balance already carries: price='{{ item.current_price }}'.",
394
394
  ]
395
395
  _anti_patterns: ClassVar[List[Dict[str, str]]] = [
@@ -438,7 +438,7 @@ class OverseasStockNewOrderNode(BaseOrderNode):
438
438
  {"id": "market", "type": "OverseasStockMarketDataNode", "symbols": [{"symbol": "AAPL", "exchange": "NASDAQ"}], "fields": ["price"]},
439
439
  {"id": "historical", "type": "OverseasStockHistoricalDataNode", "symbols": [{"symbol": "AAPL", "exchange": "NASDAQ"}], "period": "1d", "count": 20},
440
440
  {"id": "condition", "type": "ConditionNode", "plugin": "RSI", "items": {"from": "{{ item.time_series }}", "extract": {"symbol": "{{ item.symbol }}", "exchange": "{{ item.exchange }}", "date": "{{ row.date }}", "close": "{{ row.close }}"}}, "fields": {"period": 14, "oversold_threshold": 30}},
441
- {"id": "sizing", "type": "PositionSizingNode", "method": "fixed_percent", "max_percent": 5, "balance": "{{ nodes.account.balance }}", "price": '{{ nodes.market.value.price }}', "symbol": {"symbol": "AAPL", "exchange": "NASDAQ"}},
441
+ {"id": "sizing", "type": "PositionSizingNode", "method": "fixed_percent", "max_percent": 5, "balance": "{{ nodes.account.balance }}", "price": '{{ nodes.market.values[0].price }}', "symbol": {"symbol": "AAPL", "exchange": "NASDAQ"}},
442
442
  {"id": "order", "type": "OverseasStockNewOrderNode", "side": "buy", "order_type": "limit", "order": "{{ nodes.sizing.order }}"},
443
443
  ],
444
444
  "edges": [
@@ -257,7 +257,7 @@ class PositionSizingNode(BaseNode):
257
257
  "max_percent": 10.0,
258
258
  "symbols": [{"symbol": "AAPL", "exchange": "NASDAQ"}],
259
259
  "balance": "{{ nodes.account.balance }}",
260
- "market_data": "{{ nodes.market.value }}",
260
+ "market_data": "{{ nodes.market.values }}",
261
261
  },
262
262
  {"id": "display", "type": "SummaryDisplayNode", "title": "Sized orders", "data": {"orders": "{{ nodes.size.orders }}"}},
263
263
  ],
@@ -113,7 +113,7 @@ class WatchlistNode(BaseNode):
113
113
  {"id": "watchlist", "type": "WatchlistNode", "symbols": [{"symbol": "AAPL", "exchange": "NASDAQ"}, {"symbol": "MSFT", "exchange": "NASDAQ"}, {"symbol": "NVDA", "exchange": "NASDAQ"}]},
114
114
  {'id': 'split', 'type': 'SplitNode', 'array': '{{ nodes.watchlist.symbols }}'},
115
115
  {"id": "market", "type": "OverseasStockMarketDataNode", "symbol": "{{ nodes.split.item }}"},
116
- {"id": "display", "type": "TableDisplayNode", "data": "{{ nodes.market.value }}"},
116
+ {"id": "display", "type": "TableDisplayNode", "data": "{{ nodes.market.values }}"},
117
117
  {'id': 'split_results', 'type': 'AggregateNode', 'mode': 'collect'}],
118
118
  "edges": [
119
119
  {"from": "start", "to": "broker"},
@@ -392,7 +392,7 @@ class ScreenerNode(BaseNode):
392
392
  description: str = "i18n:nodes.ScreenerNode.description"
393
393
 
394
394
  # 입력 종목 리스트 (선택사항) - 바인딩 또는 직접 입력
395
- symbols: Optional[Union[List[Dict[str, str]], str]] = Field(
395
+ symbols: Optional[Union[List[Dict[str, Any]], str]] = Field(
396
396
  default=None,
397
397
  description="필터링할 종목 리스트. 없으면 전체 시장에서 검색",
398
398
  )
@@ -592,6 +592,10 @@ class ScreenerNode(BaseNode):
592
592
  "KoreaStockBrokerNode → KoreaStockSymbolQueryNode → ScreenerNode(market='korea_stock') → SplitNode",
593
593
  ],
594
594
  "pitfalls": [
595
+ "LS screening requires an explicit exchange for each symbol and a connected broker credential. It does not guess NASDAQ for unknown exchanges.",
596
+ "LS sector filtering is unsupported: choose data_source='yfinance' for sector names. A sector condition is never silently removed in LS mode.",
597
+ "LS market-cap filters require numeric market_cap from a g3190 master upstream. A watchlist plus g3101 cannot supply market cap; missing data raises an error.",
598
+ "Valid observations with no matching symbols produce count=0. Missing quotes, wrong response identity or unavailable filter data are errors, not a no-signal result.",
595
599
  "ScreenerNode relies on Yahoo Finance data (or LS g3190/g3101 for overseas_stock) which may have delays or inconsistencies with realtime broker data.",
596
600
  "Setting no filters returns all symbols up to max_results — always set at least one filter for production strategies.",
597
601
  "data_source='ls' is only effective for overseas_stock. Other markets log a warning and fall back to yfinance — set data_source='yfinance' explicitly to silence the warning.",
@@ -600,9 +604,9 @@ class ScreenerNode(BaseNode):
600
604
  ],
601
605
  }
602
606
 
603
- _version: ClassVar[str] = "1.0.0"
604
- _updated_at: ClassVar[str] = "2026-05-19"
605
- _change_note: ClassVar[Optional[str]] = None
607
+ _version: ClassVar[str] = "1.1.0"
608
+ _updated_at: ClassVar[str] = "2026-09-23"
609
+ _change_note: ClassVar[Optional[str]] = "Preserve numeric master fields; bind LS quotes and fail unavailable filters without treating zero matches as errors."
606
610
 
607
611
  @classmethod
608
612
  def get_field_schema(cls) -> Dict[str, "FieldSchema"]:
@@ -133,7 +133,7 @@ class OverseasFuturesSymbolQueryNode(BaseNode):
133
133
  {"id": "symbols", "type": "OverseasFuturesSymbolQueryNode", "futures_exchange": "6", "futures_contract_month": "front", "max_results": 50},
134
134
  {'id': 'split', 'type': 'SplitNode', 'array': '{{ nodes.symbols.symbols }}'},
135
135
  {"id": "market", "type": "OverseasFuturesMarketDataNode", "symbol": "{{ nodes.split.item }}"},
136
- {"id": "display", "type": "TableDisplayNode", "data": "{{ nodes.market.value }}"},
136
+ {"id": "display", "type": "TableDisplayNode", "data": "{{ nodes.market.values }}"},
137
137
  {'id': 'split_results', 'type': 'AggregateNode', 'mode': 'collect'}],
138
138
  "edges": [
139
139
  {"from": "start", "to": "broker"},
@@ -115,7 +115,7 @@ class ScheduleNode(BaseNode):
115
115
  {
116
116
  "pattern": "No TradingHoursFilterNode downstream for time-sensitive trading",
117
117
  "reason": "Cron fires exactly at the cron cadence, including weekends and holidays; orders may slip onto a closed market.",
118
- "alternative": "Chain `ScheduleNode → TradingHoursFilterNode (from_port='passed') → trading body` so cron-after-hours is silently blocked.",
118
+ "alternative": "Use SessionGateNode → IfNode for immediate off-hours refusal, or TradingHoursFilterNode to wait until the window opens. Neither supplies an exchange holiday calendar.",
119
119
  },
120
120
  ]
121
121
  _examples: ClassVar[List[Dict[str, Any]]] = [
@@ -310,14 +310,14 @@ class TradingHoursFilterNode(BaseNode):
310
310
  ],
311
311
  "typical_scenarios": [
312
312
  "ScheduleNode → TradingHoursFilterNode → trading body (passed branch)",
313
- "TradingHoursFilterNode → IfNode(reason='...') for per-reason branching",
313
+ "TradingHoursFilterNode blocked port → IfNode for timeout handling",
314
314
  "Start → TradingHoursFilterNode → long-running realtime subscription (cleanup at close)",
315
315
  ],
316
316
  }
317
317
  _features: ClassVar[List[str]] = [
318
318
  "Configurable start / end in HH:MM form and IANA timezone",
319
319
  "`days` whitelist supports weekend-only or weekday-only flows",
320
- "Dual outputs: `passed` (within hours) and `blocked` (outside) for explicit branching",
320
+ "Outside the window this node waits; `passed` activates on entry and `blocked` activates only on timeout/shutdown",
321
321
  "max_wait_hours safeguards long waits — the node timeouts instead of stalling forever",
322
322
  ]
323
323
  _anti_patterns: ClassVar[List[Dict[str, str]]] = [
@@ -356,11 +356,11 @@ class TradingHoursFilterNode(BaseNode):
356
356
  {"credential_id": "broker_cred", "type": "broker_ls_overseas_stock", "data": [{"key": "appkey", "value": "", "type": "password", "label": "App Key"}, {"key": "appsecret", "value": "", "type": "password", "label": "App Secret"}]},
357
357
  ],
358
358
  },
359
- "expected_output": "Cron fires every 5 min; account query runs only during US trading hours on weekdays. Off-hours cycles hit the blocked branch and skip downstream.",
359
+ "expected_output": "Account query runs after the trading window opens. Outside the window the node waits up to max_wait_hours; timeout skips the trading branch.",
360
360
  },
361
361
  {
362
362
  "title": "Branch on blocked path for after-hours notification",
363
- "description": "TradingHoursFilterNode forks: passed branch runs trading body, blocked branch sends an after-hours notice.",
363
+ "description": "TradingHoursFilterNode waits for the window: passed runs the trading body; blocked handles timeout. Use SessionGateNode plus IfNode for an immediate closed-window decision.",
364
364
  "workflow_snippet": {
365
365
  "id": "hours-filter-notify",
366
366
  "name": "Trading hours fork",
@@ -383,12 +383,12 @@ class TradingHoursFilterNode(BaseNode):
383
383
  {"credential_id": "broker_cred", "type": "broker_ls_overseas_stock", "data": [{"key": "appkey", "value": "", "type": "password", "label": "App Key"}, {"key": "appsecret", "value": "", "type": "password", "label": "App Secret"}]},
384
384
  ],
385
385
  },
386
- "expected_output": "Hourly cron; within hours → account query; outside hours → SummaryDisplay renders the closed notice.",
386
+ "expected_output": "Within hours → account query; outside hours → wait; on timeout → SummaryDisplay renders the blocked notice.",
387
387
  },
388
388
  ]
389
389
  _node_guide: ClassVar[Dict[str, Any]] = {
390
390
  "input_handling": "Trigger edge from upstream (ScheduleNode or StartNode). Window is configured via start / end / timezone / days.",
391
- "output_consumption": "`passed` port runs during-hours branches; `blocked` runs after-hours branches. Use edge `from_port` to pick which downstream fires.",
391
+ "output_consumption": "`passed` and default edges run after entering the window; explicit `blocked` edges run on timeout. Outside the window the node waits first.",
392
392
  "common_combinations": [
393
393
  "ScheduleNode → TradingHoursFilterNode → trading body",
394
394
  "TradingHoursFilterNode → OverseasStockRealMarketDataNode (start realtime only in-hours)",
@@ -413,7 +413,7 @@ class TradingHoursFilterNode(BaseNode):
413
413
  "start": FieldSchema(
414
414
  name="start",
415
415
  type=FieldType.STRING,
416
- description="Start time in HH:MM format (24-hour). Signals before this time are blocked.",
416
+ description="Start time in HH:MM format (24-hour). Wait until the window opens, bounded by max_wait_hours.",
417
417
  default="09:30",
418
418
  required=True,
419
419
  category=FieldCategory.PARAMETERS,
@@ -424,7 +424,7 @@ class TradingHoursFilterNode(BaseNode):
424
424
  "end": FieldSchema(
425
425
  name="end",
426
426
  type=FieldType.STRING,
427
- description="End time in HH:MM format (24-hour). Signals after this time are blocked.",
427
+ description="Inclusive end minute in HH:MM format (24-hour). Afterward wait for the next configured window, bounded by max_wait_hours.",
428
428
  default="16:00",
429
429
  required=True,
430
430
  category=FieldCategory.PARAMETERS,
@@ -468,32 +468,37 @@ class TradingHoursFilterNode(BaseNode):
468
468
  ),
469
469
  }
470
470
 
471
- def _is_trading_hours(self) -> bool:
472
- """Check if current time is within trading hours"""
471
+ def _is_trading_hours(self, *, as_of: Optional[datetime] = None) -> bool:
472
+ """Use the live window predicate with an optional aware replay instant."""
473
+ if as_of is not None and (as_of.tzinfo is None or as_of.utcoffset() is None):
474
+ raise ValueError("Trading-hours instant must include a timezone")
473
475
  try:
474
476
  import pytz
475
477
  except ImportError:
476
- # pytz 없으면 UTC 기준으로 체크
477
- now = datetime.utcnow()
478
- tz = None
478
+ from zoneinfo import ZoneInfo
479
+ tz = ZoneInfo(self.timezone)
479
480
  else:
480
481
  tz = pytz.timezone(self.timezone)
481
- now = datetime.now(tz)
482
+ now = as_of.astimezone(tz) if as_of is not None else datetime.now(tz)
482
483
 
483
484
  # 요일 체크
484
485
  day_map = {"mon": 0, "tue": 1, "wed": 2, "thu": 3, "fri": 4, "sat": 5, "sun": 6}
485
- active_weekdays = [day_map[d.lower()] for d in self.days if d.lower() in day_map]
486
+ if not self.days or any(d.lower() not in day_map for d in self.days):
487
+ raise ValueError("Trading days must use explicit weekday names")
488
+ active_weekdays = [day_map[d.lower()] for d in self.days]
489
+ # Reject malformed windows even on an inactive weekday.
490
+ import re
491
+ # H:MM and HH:MM are both unambiguous ("9:30" was accepted before 2.1.0).
492
+ if any(not isinstance(value, str) or not re.fullmatch(r"(?:[01]?\d|2[0-3]):[0-5]\d", value)
493
+ for value in (self.start, self.end)):
494
+ raise ValueError("Trading-hours window requires HH:MM times")
495
+ start_h, start_m = map(int, self.start.split(":"))
496
+ end_h, end_m = map(int, self.end.split(":"))
497
+ if (end_h, end_m) < (start_h, start_m):
498
+ raise ValueError("Use SessionGateNode for an overnight window")
486
499
  if now.weekday() not in active_weekdays:
487
500
  return False
488
501
 
489
- # 시간 체크
490
- try:
491
- start_h, start_m = map(int, self.start.split(":"))
492
- end_h, end_m = map(int, self.end.split(":"))
493
- except ValueError:
494
- # 파싱 실패 시 통과
495
- return True
496
-
497
502
  current_minutes = now.hour * 60 + now.minute
498
503
  start_minutes = start_h * 60 + start_m
499
504
  end_minutes = end_h * 60 + end_m
@@ -513,7 +518,7 @@ class TradingHoursFilterNode(BaseNode):
513
518
  # dry_run: 거래시간 대기 없이 즉시 통과
514
519
  if getattr(context, "is_dry_run", False):
515
520
  context.log("info", "[dry_run] TradingHoursFilter bypassed", self.id)
516
- return {"passed": True, "reason": "dry_run_bypass"}
521
+ return {"passed": True, "blocked": False, "reason": "dry_run_bypass"}
517
522
 
518
523
  check_interval = 60 # 1분마다 체크
519
524
  wait_start = _time.monotonic()
@@ -523,7 +528,7 @@ class TradingHoursFilterNode(BaseNode):
523
528
  # graceful shutdown 체크
524
529
  if hasattr(context, 'is_running') and not context.is_running:
525
530
  context.log("info", "Shutdown requested, exiting trading hours wait", self.id)
526
- return {"passed": False, "reason": "shutdown"}
531
+ return {"passed": False, "blocked": True, "reason": "shutdown"}
527
532
 
528
533
  # M-7: max_wait_hours 초과 체크
529
534
  if (_time.monotonic() - wait_start) >= max_wait_sec:
@@ -532,10 +537,10 @@ class TradingHoursFilterNode(BaseNode):
532
537
  f"거래시간 대기 timeout: max_wait_hours={self.max_wait_hours}h 초과",
533
538
  self.id,
534
539
  )
535
- return {"passed": False, "reason": "timeout"}
540
+ return {"passed": False, "blocked": True, "reason": "timeout"}
536
541
 
537
542
  context.log("debug", f"Outside trading hours, waiting... (next check in {check_interval}s)", self.id)
538
543
  await asyncio.sleep(check_interval)
539
544
 
540
545
  context.log("info", "Trading hours active, passing through", self.id)
541
- return {"passed": True}
546
+ return {"passed": True, "blocked": False}
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden-core"
8
- version = "1.31.1"
8
+ version = "2.1.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의"
11
11
  readme = "README.md"
@@ -16,7 +16,7 @@ packages = [
16
16
  ]
17
17
 
18
18
  [tool.poetry.dependencies]
19
- pydantic = ">=2.0.0,<3.0.0"
19
+ pydantic = ">=2.0.1,<3.0.0"
20
20
  tzdata = "^2025.2"
21
21
 
22
22
  [tool.poetry.group.dev.dependencies]
@@ -26,5 +26,5 @@ mypy = "^1.0.0"
26
26
  ruff = "^0.1.0"
27
27
 
28
28
  [build-system]
29
- requires = ["poetry-core>=2.0.0,<3.0.0"]
29
+ requires = ["poetry-core>=2.0.1,<3.0.0"]
30
30
  build-backend = "poetry.core.masonry.api"