programgarden-core 1.31.1__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/PKG-INFO +3 -3
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/README.md +1 -1
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/code_node.py +17 -9
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/expression/evaluator.py +43 -8
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/en.json +1 -19
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/ko.json +1 -19
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/credential.py +0 -10
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_korea_stock.py +1 -1
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/base.py +16 -2
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/code.py +21 -8
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data.py +1 -1
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_futures.py +3 -5
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_korea_stock.py +3 -4
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/data_stock.py +3 -23
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/display.py +1 -1
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/fundamental_stock.py +8 -5
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/order.py +2 -2
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/risk.py +1 -1
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol.py +9 -5
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_futures.py +1 -1
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/trigger.py +33 -28
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/pyproject.toml +3 -3
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/__init__.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/__init__.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/client.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/components.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/listener.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/mixins.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/products.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/sql.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/bases/storage.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/exceptions.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/expression/__init__.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/i18n/__init__.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/i18n/translator.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/korea_alias.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/__init__.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/connection_rule.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/edge.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/event.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/exchange.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/field_binding.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/job.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/order_diagnostics.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/plugin_resource.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/resilience.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/resource.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/validation.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/workflow.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/__init__.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_futures.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_stock.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/ai.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_futures.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_korea_stock.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_stock.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/broker.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/calculation.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/condition.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/event.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/fundamental_korea_stock.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/infra.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_external.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_status.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_futures.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_korea_stock.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_stock.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/orderable_quantity_futures.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/portfolio.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_futures.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_korea_stock.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_stock.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/session_gate.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_korea_stock.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_stock.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/__init__.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/news_analyst.json +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/risk_manager.json +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/strategist.json +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/technical_analyst.json +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/registry/__init__.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/registry/credential_registry.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/registry/node_registry.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/registry/plugin_registry.py +0 -0
- {programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/retry_executor.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: programgarden-core
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의
|
|
5
5
|
License-Expression: AGPL-3.0-or-later
|
|
6
6
|
Author: 프로그램동산
|
|
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.12
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.13
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
-
Requires-Dist: pydantic (>=2.0.
|
|
13
|
+
Requires-Dist: pydantic (>=2.0.1,<3.0.0)
|
|
14
14
|
Requires-Dist: tzdata (>=2025.2,<2026.0)
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
|
|
@@ -59,7 +59,7 @@ poetry add programgarden-core
|
|
|
59
59
|
| analysis | 2 | BacktestEngineNode, BenchmarkCompareNode |
|
|
60
60
|
| ai | 2 | LLMModelNode, AIAgentNode |
|
|
61
61
|
|
|
62
|
-
> Community adds
|
|
62
|
+
> Community adds four nodes: TelegramNode, FearGreedIndexNode, FileReaderNode and PerformanceReportNode, for 77 total.
|
|
63
63
|
|
|
64
64
|
## 사용 예시
|
|
65
65
|
|
|
@@ -43,7 +43,7 @@ poetry add programgarden-core
|
|
|
43
43
|
| analysis | 2 | BacktestEngineNode, BenchmarkCompareNode |
|
|
44
44
|
| ai | 2 | LLMModelNode, AIAgentNode |
|
|
45
45
|
|
|
46
|
-
> Community adds
|
|
46
|
+
> Community adds four nodes: TelegramNode, FearGreedIndexNode, FileReaderNode and PerformanceReportNode, for 77 total.
|
|
47
47
|
|
|
48
48
|
## 사용 예시
|
|
49
49
|
|
|
@@ -104,9 +104,11 @@ FORBIDDEN_ATTR_NAMES: frozenset = frozenset({
|
|
|
104
104
|
})
|
|
105
105
|
|
|
106
106
|
# ── Denylist: introspection-escape / exfiltration dunders & frame attrs ──────
|
|
107
|
-
# NOTE: enforcement is the
|
|
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-1.31.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
|
|
@@ -149,6 +151,21 @@ class DateNamespace:
|
|
|
149
151
|
'iso': '%Y-%m-%d',
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
def __init__(self, *, as_of: Optional[str] = None):
|
|
155
|
+
"""Optionally pin replay helpers to an explicit timezone-aware instant.
|
|
156
|
+
|
|
157
|
+
Live callers omit as_of and retain their existing local-clock behavior.
|
|
158
|
+
The execution host, never a model-supplied node setting, owns this clock.
|
|
159
|
+
"""
|
|
160
|
+
self._as_of = None
|
|
161
|
+
if as_of is not None:
|
|
162
|
+
self._as_of = datetime.fromisoformat(as_of.replace("Z", "+00:00"))
|
|
163
|
+
if self._as_of.tzinfo is None or self._as_of.utcoffset() is None:
|
|
164
|
+
raise ValueError("Replay as_of must include its timezone")
|
|
165
|
+
|
|
166
|
+
def _today(self) -> date:
|
|
167
|
+
return self._as_of.date() if self._as_of is not None else date.today()
|
|
168
|
+
|
|
152
169
|
def _format_date(self, d: date, fmt: Optional[str] = None) -> str:
|
|
153
170
|
"""날짜를 지정된 포맷으로 변환"""
|
|
154
171
|
if fmt is None:
|
|
@@ -159,40 +176,40 @@ class DateNamespace:
|
|
|
159
176
|
|
|
160
177
|
def today(self, format: Optional[str] = None) -> str:
|
|
161
178
|
"""오늘 날짜"""
|
|
162
|
-
return self._format_date(
|
|
179
|
+
return self._format_date(self._today(), format)
|
|
163
180
|
|
|
164
181
|
def now(self) -> str:
|
|
165
182
|
"""현재 시간 (ISO 형식)"""
|
|
166
|
-
return datetime.now().isoformat()[:19]
|
|
183
|
+
return self._as_of.isoformat(timespec="seconds") if self._as_of is not None else datetime.now().isoformat()[:19]
|
|
167
184
|
|
|
168
185
|
def ago(self, days: int, format: Optional[str] = None) -> str:
|
|
169
186
|
"""n일 전"""
|
|
170
|
-
d =
|
|
187
|
+
d = self._today() - timedelta(days=int(days))
|
|
171
188
|
return self._format_date(d, format)
|
|
172
189
|
|
|
173
190
|
def later(self, days: int, format: Optional[str] = None) -> str:
|
|
174
191
|
"""n일 후"""
|
|
175
|
-
d =
|
|
192
|
+
d = self._today() + timedelta(days=int(days))
|
|
176
193
|
return self._format_date(d, format)
|
|
177
194
|
|
|
178
195
|
def months_ago(self, months: int, format: Optional[str] = None) -> str:
|
|
179
196
|
"""n개월 전 (30일 기준)"""
|
|
180
|
-
d =
|
|
197
|
+
d = self._today() - timedelta(days=int(months) * 30)
|
|
181
198
|
return self._format_date(d, format)
|
|
182
199
|
|
|
183
200
|
def year_start(self, format: Optional[str] = None) -> str:
|
|
184
201
|
"""연초 (1월 1일)"""
|
|
185
|
-
d = date(
|
|
202
|
+
d = date(self._today().year, 1, 1)
|
|
186
203
|
return self._format_date(d, format)
|
|
187
204
|
|
|
188
205
|
def year_end(self, format: Optional[str] = None) -> str:
|
|
189
206
|
"""연말 (12월 31일)"""
|
|
190
|
-
d = date(
|
|
207
|
+
d = date(self._today().year, 12, 31)
|
|
191
208
|
return self._format_date(d, format)
|
|
192
209
|
|
|
193
210
|
def month_start(self, format: Optional[str] = None) -> str:
|
|
194
211
|
"""월초"""
|
|
195
|
-
d =
|
|
212
|
+
d = self._today().replace(day=1)
|
|
196
213
|
return self._format_date(d, format)
|
|
197
214
|
|
|
198
215
|
|
|
@@ -926,6 +943,15 @@ class SafeEvaluator:
|
|
|
926
943
|
obj = self._eval_node(node.value)
|
|
927
944
|
if isinstance(obj, dict):
|
|
928
945
|
return obj.get(node.attr)
|
|
946
|
+
# An output PORT wins over a proxy helper of the same name: a node
|
|
947
|
+
# whose port is called `count` (OpenOrdersNode), `sum`, `first`, `last`,
|
|
948
|
+
# `map` or `filter` must resolve to the port value, not to the bound
|
|
949
|
+
# helper method (observed 2026-09-24: `{{ nodes.open_orders.count }}`
|
|
950
|
+
# returned `NodeOutputProxy.count` and the CodeNode input could not be
|
|
951
|
+
# serialized — "Object of type method is not JSON serializable").
|
|
952
|
+
if isinstance(obj, NodeOutputProxy) and isinstance(obj._data, dict) and node.attr in obj._data:
|
|
953
|
+
value = obj._data[node.attr]
|
|
954
|
+
return NodeOutputProxy(value) if isinstance(value, list) else value
|
|
929
955
|
return getattr(obj, node.attr, None)
|
|
930
956
|
|
|
931
957
|
# 인덱싱 (arr[0], dict["key"])
|
|
@@ -937,6 +963,15 @@ class SafeEvaluator:
|
|
|
937
963
|
# 함수 호출
|
|
938
964
|
if isinstance(node, ast.Call):
|
|
939
965
|
func = self._eval_node(node.func)
|
|
966
|
+
if not callable(func) and isinstance(node.func, ast.Attribute):
|
|
967
|
+
# `{{ nodes.open_orders.count() }}`: the bare attribute resolves to
|
|
968
|
+
# the `count` PORT (see the Attribute branch), but a CALL of that
|
|
969
|
+
# name still means the proxy helper, so workflows written against
|
|
970
|
+
# the helper keep working on nodes that also declare the port.
|
|
971
|
+
owner = self._eval_node(node.func.value)
|
|
972
|
+
helper = getattr(owner, node.func.attr, None) if isinstance(owner, NodeOutputProxy) else None
|
|
973
|
+
if callable(helper):
|
|
974
|
+
func = helper
|
|
940
975
|
if not callable(func):
|
|
941
976
|
raise ExpressionError(f"호출 불가능한 객체: {func}")
|
|
942
977
|
args = [self._eval_node(arg) for arg in node.args]
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/en.json
RENAMED
|
@@ -64,8 +64,6 @@
|
|
|
64
64
|
"nodes.EventHandlerNode.name": "Event Handler",
|
|
65
65
|
"nodes.FearGreedIndexNode.description": "Fetches the CNN Fear & Greed Index, a market sentiment indicator ranging from 0 (Extreme Fear) to 100 (Extreme Greed). No broker connection or API key required. Outputs the current index value (0-100), label (Extreme Fear / Fear / Neutral / Greed / Extreme Greed), and previous day's close value. Built-in resilience with retry on API failures. Typical connection: FearGreedIndexNode → ConditionNode (for sentiment-based trading signals) or → SummaryDisplayNode.",
|
|
66
66
|
"nodes.FearGreedIndexNode.name": "Fear & Greed Index",
|
|
67
|
-
"nodes.FundamentalDataNode.description": "Fetches company financial data via Financial Modeling Prep (FMP) API. Supports four data types: company profile, income statement, balance sheet, and key metrics. Configure period (annual/quarter) and limit (number of periods to fetch). Requires FMP API credentials. Outputs financial data array and query summary. Typical connection: WatchlistNode → FundamentalDataNode → ConditionNode (for fundamental screening) or → TableDisplayNode.",
|
|
68
|
-
"nodes.FundamentalDataNode.name": "Fundamental Data (FMP)",
|
|
69
67
|
"nodes.PerformanceReportNode.name": "Performance Report (quantstats)",
|
|
70
68
|
"nodes.PerformanceReportNode.description": "Computes risk/return metrics (Sharpe, Sortino, max drawdown, CAGR, annualized volatility, Calmar) from an equity curve, price series, or return series using quantstats. Optionally computes beta and alpha against a benchmark series. Emits a drawdown time series and a formatted text report, and runs headless via the matplotlib Agg backend. Requires the optional 'perf' extra (pip install 'programgarden-community[perf]'); a missing or partially-installed extra raises a structured MissingDependencyError. Typical connection: BacktestEngineNode → PerformanceReportNode → TableDisplayNode.",
|
|
71
69
|
"nodes.FieldMappingNode.description": "Renames field names in data objects to a standard format. Use to normalize external API responses so downstream nodes can access data with consistent field names (e.g., rename 'adj_close' to 'close', 'vol' to 'volume'). Configure mappings as from/to pairs. Outputs mapped_data with renamed fields. Set preserve_unmapped=true to keep original fields alongside renamed ones. Note: this node only renames fields — it does not generate or transform data values. Typical connection: HTTPRequestNode → FieldMappingNode → ConditionNode.",
|
|
@@ -293,7 +291,7 @@
|
|
|
293
291
|
"fields.CurrencyRateNode.target_currencies": "Target currencies. Select currencies to get exchange rates against the base currency.",
|
|
294
292
|
"fields.CurrencyRateNode.timeout_seconds": "API request timeout (seconds). Default 30s, adjust based on your network conditions.",
|
|
295
293
|
"fields.CodeNode.code": "Python source that defines async def execute(data, params, context). Sandboxed: only pure-computation stdlib imports (math, statistics, json, datetime, ...) are allowed; no credentials, network, filesystem, or introspection.",
|
|
296
|
-
"fields.CodeNode.outputs": "Output port declarations [{name, type}]. Leave empty for a single 'result' port carrying the whole return value. Declared ports are consumed downstream by {{ nodes.<id>.<port> }} expressions written into a consumer's generic input field (TableDisplayNode.data, IfNode.left/right, ...), not by typed-port matching; declaring them enables static typo-guarding.",
|
|
294
|
+
"fields.CodeNode.outputs": "Output port declarations [{name, type}]. Leave empty for a single 'result' port carrying the whole return value: return 3 yields result=3; return {'result':3} yields result={'result':3}. Declare [{name:'result',type:'number'}] to map a returned dict's result key to the number port. Declared ports are consumed downstream by {{ nodes.<id>.<port> }} expressions written into a consumer's generic input field (TableDisplayNode.data, IfNode.left/right, ...), not by typed-port matching; declaring them enables static typo-guarding.",
|
|
297
295
|
"fields.CodeNode.params": "Parameters passed to execute() as `params`. Expression bindings are allowed (e.g. a period or threshold).",
|
|
298
296
|
"fields.CodeNode.data": "Input data passed to execute() as `data`. Bind the whole upstream value (e.g. {{ nodes.hist.values }}) and loop over it inside the function.",
|
|
299
297
|
"fields.FieldMappingNode.data": "Input data to transform. Bind from HTTPRequestNode response or other node outputs.",
|
|
@@ -318,13 +316,6 @@
|
|
|
318
316
|
"fields.FileReaderNode.max_file_size_mb": "Maximum file size per file (MB). Range 1-50, default 10MB.",
|
|
319
317
|
"fields.FearGreedIndexNode.resilience": "Retry and fallback settings for API call failures",
|
|
320
318
|
"fields.FearGreedIndexNode.timeout_seconds": "API request timeout (seconds). Default 30s, adjust based on your network conditions.",
|
|
321
|
-
"fields.FundamentalDataNode.credential_id": "FMP API key issued by financialmodelingprep.com. Select, replace or unlink its credential in node details. You can save an unlinked draft and run offline fixture checks; live data remains unverified until you connect a valid key with access to the selected endpoint.",
|
|
322
|
-
"fields.FundamentalDataNode.symbols": "Symbols to query. [{symbol, exchange}] format.",
|
|
323
|
-
"fields.FundamentalDataNode.data_type": "Data type: profile, income_statement, balance_sheet, key_metrics",
|
|
324
|
-
"fields.FundamentalDataNode.period": "Period: annual or quarter. Used for financial statements and key metrics.",
|
|
325
|
-
"fields.FundamentalDataNode.limit": "Number of periods to fetch (most recent N). Default 1.",
|
|
326
|
-
"fields.FundamentalDataNode.timeout_seconds": "API request timeout (seconds). Default 30s.",
|
|
327
|
-
"fields.FundamentalDataNode.resilience": "Retry and fallback settings for API call failures",
|
|
328
319
|
"fields.PerformanceReportNode.data": "Input series: an array of numbers or of {date, <value_field>} objects (equity curve, price levels, or periodic returns).",
|
|
329
320
|
"fields.PerformanceReportNode.data_kind": "Kind of the input series: 'equity', 'prices', or 'returns'. Level series ('equity'/'prices') are converted to returns internally.",
|
|
330
321
|
"fields.PerformanceReportNode.value_field": "Key holding the numeric value when rows are objects (default 'close'; falls back to value/equity/return).",
|
|
@@ -655,13 +646,6 @@
|
|
|
655
646
|
"fieldNames.FileReaderNode.max_file_size_mb": "Max Size (MB)",
|
|
656
647
|
"fieldNames.FearGreedIndexNode.resilience": "Resilience",
|
|
657
648
|
"fieldNames.FearGreedIndexNode.timeout_seconds": "Timeout (sec)",
|
|
658
|
-
"fieldNames.FundamentalDataNode.credential_id": "FMP Credential",
|
|
659
|
-
"fieldNames.FundamentalDataNode.symbols": "Symbols",
|
|
660
|
-
"fieldNames.FundamentalDataNode.data_type": "Data Type",
|
|
661
|
-
"fieldNames.FundamentalDataNode.period": "Period",
|
|
662
|
-
"fieldNames.FundamentalDataNode.limit": "Limit",
|
|
663
|
-
"fieldNames.FundamentalDataNode.timeout_seconds": "Timeout (sec)",
|
|
664
|
-
"fieldNames.FundamentalDataNode.resilience": "Resilience",
|
|
665
649
|
"fieldNames.PerformanceReportNode.data": "Data Series",
|
|
666
650
|
"fieldNames.PerformanceReportNode.data_kind": "Data Kind",
|
|
667
651
|
"fieldNames.PerformanceReportNode.value_field": "Value Field",
|
|
@@ -886,8 +870,6 @@
|
|
|
886
870
|
"outputs.FearGreedIndexNode.label": "Index label (Extreme Fear / Fear / Neutral / Greed / Extreme Greed)",
|
|
887
871
|
"outputs.FearGreedIndexNode.previous_close": "Previous day's Fear & Greed Index value",
|
|
888
872
|
"outputs.FearGreedIndexNode.value": "Fear & Greed Index (0~100)",
|
|
889
|
-
"outputs.FundamentalDataNode.data": "Financial data array [{symbol, exchange, ...}]",
|
|
890
|
-
"outputs.FundamentalDataNode.summary": "Query summary {data_type, symbol_count, record_count}",
|
|
891
873
|
"outputs.PerformanceReportNode.metrics": "Metrics object {sharpe, sortino, max_drawdown, cagr, volatility, calmar[, beta, alpha]} (null on insufficient data).",
|
|
892
874
|
"outputs.PerformanceReportNode.report": "Formatted multi-line performance report text.",
|
|
893
875
|
"outputs.PerformanceReportNode.drawdown_series": "Drawdown time series as an array of {date, drawdown}.",
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/i18n/locales/ko.json
RENAMED
|
@@ -68,8 +68,6 @@
|
|
|
68
68
|
"nodes.FileReaderNode.name": "파일 리더",
|
|
69
69
|
"nodes.FearGreedIndexNode.description": "CNN 공포/탐욕 지수를 조회합니다. 0(극단적 공포)부터 100(극단적 탐욕)까지의 시장 심리 지표입니다. 브로커 연결이나 API 키가 필요 없습니다. 출력: 현재 지수값(0-100), 라벨(Extreme Fear / Fear / Neutral / Greed / Extreme Greed), 전일 종가. API 오류 시 자동 재시도 기능 내장. 일반적인 연결: FearGreedIndexNode → ConditionNode (심리 기반 매매 시그널) 또는 → SummaryDisplayNode.",
|
|
70
70
|
"nodes.FearGreedIndexNode.name": "공포/탐욕 지수",
|
|
71
|
-
"nodes.FundamentalDataNode.description": "Financial Modeling Prep (FMP) API로 기업 재무 데이터를 조회합니다. 4가지 데이터 유형: 기업 프로필, 손익계산서, 대차대조표, 핵심 지표. 기간(연간/분기)과 조회 수(최근 N기)를 설정합니다. FMP API 인증 정보 필요. 출력: 재무 데이터 배열, 조회 요약. 일반적인 연결: WatchlistNode → FundamentalDataNode → ConditionNode (펀더멘털 스크리닝) 또는 → TableDisplayNode.",
|
|
72
|
-
"nodes.FundamentalDataNode.name": "재무 데이터 (FMP)",
|
|
73
71
|
"nodes.PerformanceReportNode.name": "성과 리포트 (quantstats)",
|
|
74
72
|
"nodes.PerformanceReportNode.description": "자산곡선(equity)·가격·수익률 시계열로부터 quantstats 로 리스크·수익 지표(Sharpe, Sortino, 최대낙폭, CAGR, 연율 변동성, Calmar)를 산출합니다. 벤치마크 시계열을 지정하면 beta/alpha 를 추가로 계산합니다. 낙폭 시계열과 서식 리포트 텍스트를 출력하며, matplotlib Agg 백엔드로 헤드리스 동작합니다. 선택 'perf' extra 필요(pip install 'programgarden-community[perf]') — 미설치/부분설치 시 구조화된 MissingDependencyError 를 발생시킵니다. 대표 연결: BacktestEngineNode → PerformanceReportNode → TableDisplayNode.",
|
|
75
73
|
"nodes.HTTPRequestNode.description": "GET, POST, PUT, PATCH, DELETE를 지원하는 외부 REST API 호출 노드입니다. URL({{ }} 표현식 지원), 헤더, 쿼리 파라미터, JSON 요청 본문을 설정합니다. 인증 방식: Bearer 토큰, 헤더 키, 기본 인증, 쿼리 파라미터. 서버 에러(5xx), 속도 제한(429), 네트워크 장애 시 자동 재시도 기능 내장. 출력: 응답 데이터, HTTP 상태 코드, 응답 헤더, 응답 시간(ms). 실시간 데이터 소스 연결 시 ThrottleNode 필수. 일반적인 연결: ThrottleNode → HTTPRequestNode → FieldMappingNode. AI Agent 도구로 사용 가능.",
|
|
@@ -293,7 +291,7 @@
|
|
|
293
291
|
"fields.CurrencyRateNode.target_currencies": "대상 통화 목록. 기준 통화 대비 환율을 조회할 통화를 선택하세요.",
|
|
294
292
|
"fields.CurrencyRateNode.timeout_seconds": "API 요청 타임아웃 (초). 기본 30초, 네트워크 환경에 따라 조정하세요.",
|
|
295
293
|
"fields.CodeNode.code": "async def execute(data, params, context) 를 정의하는 파이썬 소스. 샌드박스: 순수 계산용 표준 라이브러리(math, statistics, json, datetime, ...) import 만 허용, credential·네트워크·파일시스템·introspection 불가.",
|
|
296
|
-
"fields.CodeNode.outputs": "출력 포트 선언 [{name, type}]. 비워두면 반환값 전체를 담는 단일 'result' 포트가 됩니다. 선언한 포트는 소비자 노드의 범용 입력 필드(TableDisplayNode.data, IfNode.left/right 등)에 {{ nodes.<id>.<port> }} 표현식으로 소비됩니다 — 타입드 포트 매칭이 아니라 이름으로 dict 키를 꺼내는 방식. 선언하면 정적 오타 검증이 적용됩니다.",
|
|
294
|
+
"fields.CodeNode.outputs": "출력 포트 선언 [{name, type}]. 비워두면 반환값 전체를 담는 단일 'result' 포트가 됩니다. 선언한 포트는 소비자 노드의 범용 입력 필드(TableDisplayNode.data, IfNode.left/right 등)에 {{ nodes.<id>.<port> }} 표현식으로 소비됩니다 — 타입드 포트 매칭이 아니라 이름으로 dict 키를 꺼내는 방식. 선언하면 정적 오타 검증이 적용됩니다. outputs를 비우면 반환값 전체가 result입니다. return 3은 result=3, return {'result':3}은 result={'result':3}이 됩니다. 반환 딕셔너리의 result 키를 숫자 포트로 사용하려면 outputs에 name:result, type:number를 선언하세요.",
|
|
297
295
|
"fields.CodeNode.params": "execute() 에 `params` 로 전달되는 파라미터. 표현식 바인딩 허용(예: 기간·임계값).",
|
|
298
296
|
"fields.CodeNode.data": "execute() 에 `data` 로 전달되는 입력 데이터. 상류 값 전체를 바인딩(예: {{ nodes.hist.values }})하고 함수 안에서 반복하세요.",
|
|
299
297
|
"fields.FieldMappingNode.data": "변환할 입력 데이터. HTTPRequestNode 응답이나 다른 노드 출력을 바인딩하세요.",
|
|
@@ -318,13 +316,6 @@
|
|
|
318
316
|
"fields.FileReaderNode.max_file_size_mb": "파일당 최대 크기 (MB). 1~50, 기본 10MB.",
|
|
319
317
|
"fields.FearGreedIndexNode.resilience": "API 호출 실패 시 재시도 및 폴백 설정",
|
|
320
318
|
"fields.FearGreedIndexNode.timeout_seconds": "API 요청 타임아웃 (초). 기본 30초, 네트워크 환경에 따라 조정하세요.",
|
|
321
|
-
"fields.FundamentalDataNode.credential_id": "financialmodelingprep.com에서 발급받은 FMP API 키를 등록하고 선택하세요. 노드 상세정보에서 교체·연결 해제가 가능합니다. 키 없이 초안 저장과 샘플 데이터 검증은 가능하지만, 실제 데이터 조회는 해당 자료의 이용권한이 있는 키를 연결한 뒤 확인해야 합니다.",
|
|
322
|
-
"fields.FundamentalDataNode.symbols": "조회할 종목 목록. [{symbol, exchange}] 형식.",
|
|
323
|
-
"fields.FundamentalDataNode.data_type": "데이터 유형: profile(기업정보), income_statement(손익계산서), balance_sheet(대차대조표), key_metrics(핵심지표)",
|
|
324
|
-
"fields.FundamentalDataNode.period": "기간: annual(연간), quarter(분기). 재무제표/핵심지표 조회 시 사용.",
|
|
325
|
-
"fields.FundamentalDataNode.limit": "가져올 기간 수 (최근 N개). 기본 1.",
|
|
326
|
-
"fields.FundamentalDataNode.timeout_seconds": "API 요청 타임아웃 (초). 기본 30초.",
|
|
327
|
-
"fields.FundamentalDataNode.resilience": "API 호출 실패 시 재시도 및 폴백 설정",
|
|
328
319
|
"fields.PerformanceReportNode.data": "입력 시계열: 숫자 배열 또는 {date, <value_field>} 객체 배열(자산곡선/가격/수익률).",
|
|
329
320
|
"fields.PerformanceReportNode.data_kind": "입력 종류: 'equity'/'prices'/'returns'. 레벨 시계열('equity'/'prices')은 내부에서 수익률로 변환됩니다.",
|
|
330
321
|
"fields.PerformanceReportNode.value_field": "행이 객체일 때 숫자 값을 담은 키(기본 'close'; value/equity/return 폴백).",
|
|
@@ -655,13 +646,6 @@
|
|
|
655
646
|
"fieldNames.FileReaderNode.max_file_size_mb": "최대크기(MB)",
|
|
656
647
|
"fieldNames.FearGreedIndexNode.resilience": "재시도설정",
|
|
657
648
|
"fieldNames.FearGreedIndexNode.timeout_seconds": "타임아웃(초)",
|
|
658
|
-
"fieldNames.FundamentalDataNode.credential_id": "FMP 인증",
|
|
659
|
-
"fieldNames.FundamentalDataNode.symbols": "종목",
|
|
660
|
-
"fieldNames.FundamentalDataNode.data_type": "데이터유형",
|
|
661
|
-
"fieldNames.FundamentalDataNode.period": "기간",
|
|
662
|
-
"fieldNames.FundamentalDataNode.limit": "조회수",
|
|
663
|
-
"fieldNames.FundamentalDataNode.timeout_seconds": "타임아웃(초)",
|
|
664
|
-
"fieldNames.FundamentalDataNode.resilience": "재시도설정",
|
|
665
649
|
"fieldNames.PerformanceReportNode.data": "데이터 시계열",
|
|
666
650
|
"fieldNames.PerformanceReportNode.data_kind": "데이터 종류",
|
|
667
651
|
"fieldNames.PerformanceReportNode.value_field": "값 필드",
|
|
@@ -886,8 +870,6 @@
|
|
|
886
870
|
"outputs.FearGreedIndexNode.label": "지수 라벨 (Extreme Fear / Fear / Neutral / Greed / Extreme Greed)",
|
|
887
871
|
"outputs.FearGreedIndexNode.previous_close": "전일 공포/탐욕 지수",
|
|
888
872
|
"outputs.FearGreedIndexNode.value": "공포/탐욕 지수 (0~100)",
|
|
889
|
-
"outputs.FundamentalDataNode.data": "재무 데이터 배열 [{symbol, exchange, ...}]",
|
|
890
|
-
"outputs.FundamentalDataNode.summary": "조회 요약 {data_type, symbol_count, record_count}",
|
|
891
873
|
"outputs.PerformanceReportNode.metrics": "지표 객체 {sharpe, sortino, max_drawdown, cagr, volatility, calmar[, beta, alpha]} (데이터 부족 시 null).",
|
|
892
874
|
"outputs.PerformanceReportNode.report": "서식 성과 리포트 텍스트.",
|
|
893
875
|
"outputs.PerformanceReportNode.drawdown_series": "낙폭 시계열 [{date, drawdown}] 배열.",
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/credential.py
RENAMED
|
@@ -91,16 +91,6 @@ class Credential(BaseModel):
|
|
|
91
91
|
# Built-in credential type schemas
|
|
92
92
|
# 디자인(레이아웃, 간격, 스타일) 요소는 포함하지 않음 - 클라이언트 개발자가 직접 구현
|
|
93
93
|
BUILTIN_CREDENTIAL_SCHEMAS: Dict[str, CredentialTypeSchema] = {
|
|
94
|
-
"fmp_api": CredentialTypeSchema(
|
|
95
|
-
type_id="fmp_api",
|
|
96
|
-
name="FMP 재무 데이터 API",
|
|
97
|
-
description="증권사 계좌와 별도로 등록하는 Financial Modeling Prep 재무 데이터 API 키",
|
|
98
|
-
widget_schema={"fields": [
|
|
99
|
-
{"key": "name", "type": "text", "label": "인증정보 이름", "required": True},
|
|
100
|
-
{"key": "api_key", "type": "password", "label": "FMP API 키", "required": True,
|
|
101
|
-
"description": "https://site.financialmodelingprep.com/developer/docs/dashboard 에서 발급"},
|
|
102
|
-
]},
|
|
103
|
-
),
|
|
104
94
|
# ============================================================
|
|
105
95
|
# LS증권 해외주식 (overseas_stock) - 모의투자 미지원
|
|
106
96
|
# ============================================================
|
|
@@ -111,7 +111,7 @@ class KoreaStockAccountNode(BaseNode):
|
|
|
111
111
|
}
|
|
112
112
|
],
|
|
113
113
|
},
|
|
114
|
-
"expected_output": "positions
|
|
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-1.31.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-1.31.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-1.31.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",
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/fundamental_stock.py
RENAMED
|
@@ -64,7 +64,7 @@ class OverseasStockFundamentalNode(BaseNode):
|
|
|
64
64
|
|
|
65
65
|
_usage: ClassVar[Dict[str, Any]] = {
|
|
66
66
|
"when_to_use": [
|
|
67
|
-
"Retrieve
|
|
67
|
+
"Retrieve LS security-detail data (PER, EPS, market cap, 52w high/low, industry) for a US-listed stock using the existing broker connection",
|
|
68
68
|
"Screen or rank stocks by fundamental criteria — pair with ConditionNode or FieldMappingNode for filtering",
|
|
69
69
|
"Give an AI Agent context about a stock's valuation alongside price data for investment thesis generation",
|
|
70
70
|
],
|
|
@@ -72,6 +72,7 @@ class OverseasStockFundamentalNode(BaseNode):
|
|
|
72
72
|
"For Korean domestic stock fundamentals — use KoreaStockFundamentalNode",
|
|
73
73
|
"When you only need price/volume data — use OverseasStockMarketDataNode",
|
|
74
74
|
"For real-time earnings or news events — fundamental data is updated daily, not tick-by-tick",
|
|
75
|
+
"For financial-statement history, free cash flow, ROE or PBR — these are not returned by this node; use a separately verified data source",
|
|
75
76
|
],
|
|
76
77
|
"typical_scenarios": [
|
|
77
78
|
"SplitNode.item → OverseasStockFundamentalNode → ConditionNode (filter by PER < 20)",
|
|
@@ -80,7 +81,8 @@ class OverseasStockFundamentalNode(BaseNode):
|
|
|
80
81
|
],
|
|
81
82
|
}
|
|
82
83
|
_features: ClassVar[List[str]] = [
|
|
83
|
-
"Returns per,
|
|
84
|
+
"Returns per, eps, market_cap, shares_outstanding, high_52w, low_52w and industry fields",
|
|
85
|
+
"Uses the LS overseas-stock broker credential; no separate financial-data subscription key is required",
|
|
84
86
|
"Item-based execution: pair with SplitNode to fetch fundamentals for each symbol in a universe",
|
|
85
87
|
"is_tool_enabled=True — AI Agent can call this node to analyze stock valuation autonomously",
|
|
86
88
|
"Broker connection is auto-injected via DAG traversal from OverseasStockBrokerNode",
|
|
@@ -130,7 +132,7 @@ class OverseasStockFundamentalNode(BaseNode):
|
|
|
130
132
|
}
|
|
131
133
|
],
|
|
132
134
|
},
|
|
133
|
-
"expected_output": "value port: {symbol, exchange, per,
|
|
135
|
+
"expected_output": "value port: {symbol, exchange, per, eps, market_cap, shares_outstanding, high_52w, low_52w, industry}.",
|
|
134
136
|
},
|
|
135
137
|
{
|
|
136
138
|
"title": "Fundamental screening — filter low-PER stocks",
|
|
@@ -172,7 +174,7 @@ class OverseasStockFundamentalNode(BaseNode):
|
|
|
172
174
|
"Supported exchanges: NYSE, NASDAQ, AMEX. Broker connection is auto-injected."
|
|
173
175
|
),
|
|
174
176
|
"output_consumption": (
|
|
175
|
-
"The `value` port emits: {symbol, exchange, per,
|
|
177
|
+
"The `value` port emits: {symbol, exchange, per, eps, market_cap, shares_outstanding, high_52w, low_52w, industry}. "
|
|
176
178
|
"Access individual fields via `{{ nodes.fundamental.value.per }}`."
|
|
177
179
|
),
|
|
178
180
|
"common_combinations": [
|
|
@@ -182,7 +184,8 @@ class OverseasStockFundamentalNode(BaseNode):
|
|
|
182
184
|
],
|
|
183
185
|
"pitfalls": [
|
|
184
186
|
"Fundamental data is updated daily — do not use it as a real-time signal",
|
|
185
|
-
"
|
|
187
|
+
"Missing broker fields can be empty or zero. For a positive-PER screen require a finite numeric per > 0 before comparing the upper bound; missing/zero/negative data must not create a buy signal",
|
|
188
|
+
"LS does not document the PER computation basis or the market-cap currency unit here. Do not claim TTM equivalence or compare cross-provider values without confirming the contract",
|
|
186
189
|
"The symbol field expects a single dict; use SplitNode for multi-symbol fundamental queries",
|
|
187
190
|
],
|
|
188
191
|
}
|
|
@@ -389,7 +389,7 @@ class OverseasStockNewOrderNode(BaseOrderNode):
|
|
|
389
389
|
"Supports extended price_type options: LOO, LOC, MOO, MOC for open/close auction orders",
|
|
390
390
|
"is_tool_enabled=True — AI Agent can call this node as a tool to place orders autonomously",
|
|
391
391
|
"retry is disabled by default (resilience.retry.enabled=False); only pure network-connection failures may be retried, never a submitted order",
|
|
392
|
-
"US daytime (Blue Ocean) session accepts LIMIT orders only — a market order is refused at intake with rsp_cd=00891 and no order number. A workflow scheduled across both the daytime and the regular overnight session must therefore use order_type='limit' for it to work in both.",
|
|
392
|
+
"US daytime (Blue Ocean) session accepts LIMIT orders only — a market order is refused at intake with rsp_cd=00891 and no order number (00891 is a generic order-price rejection: it was also observed on 2026-09-24 for a price of 1 USD or more sent with more than two decimals — '주문단가 오류. $1이상은 소수점 2째 자리까지 입력가능합니다'). A workflow scheduled across both the daytime and the regular overnight session must therefore use order_type='limit' for it to work in both.",
|
|
393
393
|
"A limit order needs a price. Buy orders fall back to a current-price lookup, and so do sell orders since engine 1.37.3, but that lookup (g3101) only accepts exchange codes 81/82 — it fails for a holding listed on any other market. Prefer feeding the price the account balance already carries: price='{{ item.current_price }}'.",
|
|
394
394
|
]
|
|
395
395
|
_anti_patterns: ClassVar[List[Dict[str, str]]] = [
|
|
@@ -438,7 +438,7 @@ class OverseasStockNewOrderNode(BaseOrderNode):
|
|
|
438
438
|
{"id": "market", "type": "OverseasStockMarketDataNode", "symbols": [{"symbol": "AAPL", "exchange": "NASDAQ"}], "fields": ["price"]},
|
|
439
439
|
{"id": "historical", "type": "OverseasStockHistoricalDataNode", "symbols": [{"symbol": "AAPL", "exchange": "NASDAQ"}], "period": "1d", "count": 20},
|
|
440
440
|
{"id": "condition", "type": "ConditionNode", "plugin": "RSI", "items": {"from": "{{ item.time_series }}", "extract": {"symbol": "{{ item.symbol }}", "exchange": "{{ item.exchange }}", "date": "{{ row.date }}", "close": "{{ row.close }}"}}, "fields": {"period": 14, "oversold_threshold": 30}},
|
|
441
|
-
{"id": "sizing", "type": "PositionSizingNode", "method": "fixed_percent", "max_percent": 5, "balance": "{{ nodes.account.balance }}", "price": '{{ nodes.market.
|
|
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-1.31.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 = "1.
|
|
8
|
+
version = "2.1.0"
|
|
9
9
|
license = "AGPL-3.0-or-later"
|
|
10
10
|
description = "ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의"
|
|
11
11
|
readme = "README.md"
|
|
@@ -16,7 +16,7 @@ packages = [
|
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[tool.poetry.dependencies]
|
|
19
|
-
pydantic = ">=2.0.
|
|
19
|
+
pydantic = ">=2.0.1,<3.0.0"
|
|
20
20
|
tzdata = "^2025.2"
|
|
21
21
|
|
|
22
22
|
[tool.poetry.group.dev.dependencies]
|
|
@@ -26,5 +26,5 @@ mypy = "^1.0.0"
|
|
|
26
26
|
ruff = "^0.1.0"
|
|
27
27
|
|
|
28
28
|
[build-system]
|
|
29
|
-
requires = ["poetry-core>=2.0.
|
|
29
|
+
requires = ["poetry-core>=2.0.1,<3.0.0"]
|
|
30
30
|
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.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-1.31.1 → programgarden_core-2.1.0}/programgarden_core/expression/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/i18n/translator.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/__init__.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/connection_rule.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/exchange.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/field_binding.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/plugin_resource.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/resilience.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/resource.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/validation.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/models/workflow.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_futures.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/account_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_futures.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/backtest_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/calculation.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/condition.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_external.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/market_status.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/open_orders_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/portfolio.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_futures.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/realtime_stock.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/session_gate.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/nodes/symbol_stock.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/__init__.py
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/news_analyst.json
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/risk_manager.json
RENAMED
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/presets/strategist.json
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/registry/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-1.31.1 → programgarden_core-2.1.0}/programgarden_core/registry/node_registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|