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.
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/PKG-INFO +1 -1
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/code_node.py +17 -9
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/expression/evaluator.py +20 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/en.json +1 -1
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/ko.json +1 -1
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_korea_stock.py +1 -1
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/base.py +16 -2
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/code.py +21 -8
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data.py +1 -1
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_futures.py +3 -5
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_korea_stock.py +3 -4
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_stock.py +3 -23
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/display.py +1 -1
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/order.py +2 -2
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/risk.py +1 -1
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol.py +9 -5
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_futures.py +1 -1
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/trigger.py +33 -28
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/pyproject.toml +1 -1
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/README.md +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/__init__.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/__init__.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/client.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/components.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/listener.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/mixins.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/products.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/sql.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/storage.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/exceptions.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/expression/__init__.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/i18n/__init__.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/i18n/translator.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/korea_alias.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/__init__.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/connection_rule.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/credential.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/edge.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/event.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/exchange.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/field_binding.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/job.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/order_diagnostics.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/plugin_resource.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/resilience.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/resource.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/validation.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/workflow.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/__init__.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_futures.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_stock.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/ai.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_futures.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_korea_stock.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_stock.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/broker.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/calculation.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/condition.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/event.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/fundamental_korea_stock.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/fundamental_stock.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/infra.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_external.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_status.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_futures.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_korea_stock.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_stock.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/orderable_quantity_futures.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/portfolio.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_futures.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_korea_stock.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_stock.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/session_gate.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_korea_stock.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_stock.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/__init__.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/news_analyst.json +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/risk_manager.json +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/strategist.json +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/technical_analyst.json +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/registry/__init__.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/registry/credential_registry.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/registry/node_registry.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/registry/plugin_registry.py +0 -0
- {programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/retry_executor.py +0 -0
|
@@ -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
|
|
108
|
-
#
|
|
109
|
-
#
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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:
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/expression/evaluator.py
RENAMED
|
@@ -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]
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/en.json
RENAMED
|
@@ -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.",
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/ko.json
RENAMED
|
@@ -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
|
|
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
|
-
#
|
|
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
|
-
-
|
|
73
|
-
|
|
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
|
|
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": "
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
215
|
+
"values": {"symbol": "{{ nodes.market.values[0].symbol }}", "peak": '{{ nodes.market.values[0].price }}'},
|
|
216
216
|
"on_conflict": "symbol",
|
|
217
217
|
},
|
|
218
218
|
{
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_futures.py
RENAMED
|
@@ -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.
|
|
210
|
-
_updated_at: ClassVar[str] = "2026-
|
|
211
|
-
_change_note: ClassVar[Optional[str]] =
|
|
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"]:
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_korea_stock.py
RENAMED
|
@@ -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.
|
|
208
|
-
_updated_at: ClassVar[str] = "2026-
|
|
209
|
-
_change_note: ClassVar[Optional[str]] =
|
|
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"]:
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_stock.py
RENAMED
|
@@ -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.
|
|
228
|
-
_updated_at: ClassVar[str] = "2026-
|
|
229
|
-
_change_note: ClassVar[Optional[str]] =
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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.
|
|
604
|
-
_updated_at: ClassVar[str] = "2026-
|
|
605
|
-
_change_note: ClassVar[Optional[str]] =
|
|
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"]:
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_futures.py
RENAMED
|
@@ -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.
|
|
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": "
|
|
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
|
|
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
|
-
"
|
|
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": "
|
|
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
|
|
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": "
|
|
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`
|
|
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).
|
|
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="
|
|
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
|
-
"""
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
tz = None
|
|
478
|
+
from zoneinfo import ZoneInfo
|
|
479
|
+
tz = ZoneInfo(self.timezone)
|
|
479
480
|
else:
|
|
480
481
|
tz = pytz.timezone(self.timezone)
|
|
481
|
-
|
|
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
|
-
|
|
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
|
|
8
|
+
version = "2.1.0"
|
|
9
9
|
license = "AGPL-3.0-or-later"
|
|
10
10
|
description = "ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의"
|
|
11
11
|
readme = "README.md"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/bases/components.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/expression/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/connection_rule.py
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/credential.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/field_binding.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/order_diagnostics.py
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/plugin_resource.py
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/resilience.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/models/validation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_futures.py
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_futures.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/calculation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/fundamental_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_external.py
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_status.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_futures.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_stock.py
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/session_gate.py
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_korea_stock.py
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_stock.py
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/__init__.py
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/news_analyst.json
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/risk_manager.json
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/presets/strategist.json
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/registry/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/registry/node_registry.py
RENAMED
|
File without changes
|
{programgarden_core-2.0.1 → programgarden_core-2.1.0}/programgarden_core/registry/plugin_registry.py
RENAMED
|
File without changes
|
|
File without changes
|