programgarden-core 2.0.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-2.0.1 → programgarden_core-2.1.0}/PKG-INFO +1 -1
  2. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/code_node.py +17 -9
  3. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/expression/evaluator.py +20 -0
  4. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/en.json +1 -1
  5. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/ko.json +1 -1
  6. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_korea_stock.py +1 -1
  7. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/base.py +16 -2
  8. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/code.py +21 -8
  9. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data.py +1 -1
  10. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_futures.py +3 -5
  11. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_korea_stock.py +3 -4
  12. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_stock.py +3 -23
  13. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/display.py +1 -1
  14. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/order.py +2 -2
  15. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/risk.py +1 -1
  16. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol.py +9 -5
  17. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_futures.py +1 -1
  18. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/trigger.py +33 -28
  19. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/pyproject.toml +1 -1
  20. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/README.md +0 -0
  21. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/__init__.py +0 -0
  22. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/__init__.py +0 -0
  23. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/client.py +0 -0
  24. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/components.py +0 -0
  25. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/listener.py +0 -0
  26. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/mixins.py +0 -0
  27. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/products.py +0 -0
  28. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/sql.py +0 -0
  29. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/storage.py +0 -0
  30. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/exceptions.py +0 -0
  31. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/expression/__init__.py +0 -0
  32. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/i18n/__init__.py +0 -0
  33. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/i18n/translator.py +0 -0
  34. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/korea_alias.py +0 -0
  35. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/__init__.py +0 -0
  36. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/connection_rule.py +0 -0
  37. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/credential.py +0 -0
  38. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/edge.py +0 -0
  39. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/event.py +0 -0
  40. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/exchange.py +0 -0
  41. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/field_binding.py +0 -0
  42. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/job.py +0 -0
  43. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/order_diagnostics.py +0 -0
  44. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/plugin_resource.py +0 -0
  45. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/resilience.py +0 -0
  46. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/resource.py +0 -0
  47. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/validation.py +0 -0
  48. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/workflow.py +0 -0
  49. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/__init__.py +0 -0
  50. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_futures.py +0 -0
  51. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_stock.py +0 -0
  52. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/ai.py +0 -0
  53. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest.py +0 -0
  54. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_futures.py +0 -0
  55. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_korea_stock.py +0 -0
  56. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_stock.py +0 -0
  57. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/broker.py +0 -0
  58. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/calculation.py +0 -0
  59. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/condition.py +0 -0
  60. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/event.py +0 -0
  61. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/fundamental_korea_stock.py +0 -0
  62. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/fundamental_stock.py +0 -0
  63. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/infra.py +0 -0
  64. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_external.py +0 -0
  65. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_status.py +0 -0
  66. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_futures.py +0 -0
  67. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_korea_stock.py +0 -0
  68. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_stock.py +0 -0
  69. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/orderable_quantity_futures.py +0 -0
  70. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/portfolio.py +0 -0
  71. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_futures.py +0 -0
  72. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_korea_stock.py +0 -0
  73. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_stock.py +0 -0
  74. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/session_gate.py +0 -0
  75. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_korea_stock.py +0 -0
  76. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_stock.py +0 -0
  77. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/__init__.py +0 -0
  78. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/news_analyst.json +0 -0
  79. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/risk_manager.json +0 -0
  80. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/strategist.json +0 -0
  81. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/technical_analyst.json +0 -0
  82. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/registry/__init__.py +0 -0
  83. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/registry/credential_registry.py +0 -0
  84. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/registry/node_registry.py +0 -0
  85. {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/registry/plugin_registry.py +0 -0
  86. {programgarden_core-2.0.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: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -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
@@ -941,6 +943,15 @@ class SafeEvaluator:
941
943
  obj = self._eval_node(node.value)
942
944
  if isinstance(obj, dict):
943
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
944
955
  return getattr(obj, node.attr, None)
945
956
 
946
957
  # 인덱싱 (arr[0], dict["key"])
@@ -952,6 +963,15 @@ class SafeEvaluator:
952
963
  # 함수 호출
953
964
  if isinstance(node, ast.Call):
954
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
955
975
  if not callable(func):
956
976
  raise ExpressionError(f"호출 불가능한 객체: {func}")
957
977
  args = [self._eval_node(arg) for arg in node.args]
@@ -291,7 +291,7 @@
291
291
  "fields.CurrencyRateNode.target_currencies": "Target currencies. Select currencies to get exchange rates against the base currency.",
292
292
  "fields.CurrencyRateNode.timeout_seconds": "API request timeout (seconds). Default 30s, adjust based on your network conditions.",
293
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.",
294
- "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.",
295
295
  "fields.CodeNode.params": "Parameters passed to execute() as `params`. Expression bindings are allowed (e.g. a period or threshold).",
296
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.",
297
297
  "fields.FieldMappingNode.data": "Input data to transform. Bind from HTTPRequestNode response or other node outputs.",
@@ -291,7 +291,7 @@
291
291
  "fields.CurrencyRateNode.target_currencies": "대상 통화 목록. 기준 통화 대비 환율을 조회할 통화를 선택하세요.",
292
292
  "fields.CurrencyRateNode.timeout_seconds": "API 요청 타임아웃 (초). 기본 30초, 네트워크 환경에 따라 조정하세요.",
293
293
  "fields.CodeNode.code": "async def execute(data, params, context) 를 정의하는 파이썬 소스. 샌드박스: 순수 계산용 표준 라이브러리(math, statistics, json, datetime, ...) import 만 허용, credential·네트워크·파일시스템·introspection 불가.",
294
- "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를 선언하세요.",
295
295
  "fields.CodeNode.params": "execute() 에 `params` 로 전달되는 파라미터. 표현식 바인딩 허용(예: 기간·임계값).",
296
296
  "fields.CodeNode.data": "execute() 에 `data` 로 전달되는 입력 데이터. 상류 값 전체를 바인딩(예: {{ nodes.hist.values }})하고 함수 안에서 반복하세요.",
297
297
  "fields.FieldMappingNode.data": "변환할 입력 데이터. HTTPRequestNode 응답이나 다른 노드 출력을 바인딩하세요.",
@@ -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",
@@ -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 = "2.0.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"