programgarden 1.41.1__tar.gz → 2.0.1__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-1.41.1 → programgarden-2.0.1}/PKG-INFO +13 -5
- {programgarden-1.41.1 → programgarden-2.0.1}/README.md +8 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/code_worker.py +1 -1
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/executor.py +157 -14
- programgarden-2.0.1/programgarden/replay_contracts.py +218 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/resolver.py +56 -1
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/validation_recommender.py +0 -1
- {programgarden-1.41.1 → programgarden-2.0.1}/pyproject.toml +6 -6
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/__init__.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/binding_validator.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/client.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/context.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/__init__.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/adjustment_delivery.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/broker_evidence.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/broker_order_totals.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/broker_snapshot.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/db_naming.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/execution_storage.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/fill_reconciler.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/order_cancellation.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/order_recovery.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/owned_order_cancellation.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/position_reconciliation.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/futures_orderable.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/futures_pnl.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/futures_read_evidence.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/managed_order_control.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/node_runner.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/order_lifecycle.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/resource/context.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.41.1 → programgarden-2.0.1}/programgarden/valuation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: programgarden
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
|
|
5
5
|
License-Expression: AGPL-3.0-or-later
|
|
6
6
|
Author: 프로그램동산
|
|
@@ -15,12 +15,12 @@ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
|
|
|
15
15
|
Requires-Dist: croniter (>=6.0.0,<7.0.0)
|
|
16
16
|
Requires-Dist: litellm (>=1.40.0)
|
|
17
17
|
Requires-Dist: lxml (>=6.0.2,<7.0.0)
|
|
18
|
-
Requires-Dist: programgarden-community (>=
|
|
19
|
-
Requires-Dist: programgarden-core (>=
|
|
20
|
-
Requires-Dist: programgarden-finance (>=
|
|
18
|
+
Requires-Dist: programgarden-community (>=2.0.1,<3.0.0)
|
|
19
|
+
Requires-Dist: programgarden-core (>=2.0.1,<3.0.0)
|
|
20
|
+
Requires-Dist: programgarden-finance (>=2.0.1,<3.0.0)
|
|
21
21
|
Requires-Dist: psutil (>=6.0.0,<7.0.0)
|
|
22
22
|
Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
|
|
23
|
-
Requires-Dist: pydantic (>=2.0.
|
|
23
|
+
Requires-Dist: pydantic (>=2.0.1,<3.0.0)
|
|
24
24
|
Requires-Dist: python-dotenv (>=1.1.0,<2.0.0)
|
|
25
25
|
Requires-Dist: pytickersymbols (>=1.17.5) ; python_version >= "3.12" and python_version < "4.0"
|
|
26
26
|
Requires-Dist: tzdata (>=2025.2,<2026.0)
|
|
@@ -118,6 +118,14 @@ the finance package's observed-response reference and StockAccountTracker;
|
|
|
118
118
|
`02679` is not a generic success code. Missing, malformed, mismatched and failed
|
|
119
119
|
reads retain `error` / `reason=fetch_failed` so workflows can block new entries.
|
|
120
120
|
|
|
121
|
+
Overseas-stock historical queries preserve the exchange supplied by each symbol,
|
|
122
|
+
including when the account has no holdings. Item-bound consumers of an explicitly
|
|
123
|
+
empty selected upstream list are skipped before expression evaluation; this also
|
|
124
|
+
covers SymbolFilter output and explicit edge ports. Empty condition gates retain
|
|
125
|
+
their stronger literal-order protection, while non-item aggregate/report nodes
|
|
126
|
+
continue to run. A skipped order keeps its declared output ports and a no-signal
|
|
127
|
+
result row. These safeguards do not establish that upstream market data is valid.
|
|
128
|
+
|
|
121
129
|
For one-shot workflows, an unhandled `order_result.success=False` returned directly
|
|
122
130
|
by a main-flow node makes the final job status `failed` and emits `WORKFLOW_FAILED`.
|
|
123
131
|
The original broker message remains in node diagnostics and the failure statistics.
|
|
@@ -89,6 +89,14 @@ the finance package's observed-response reference and StockAccountTracker;
|
|
|
89
89
|
`02679` is not a generic success code. Missing, malformed, mismatched and failed
|
|
90
90
|
reads retain `error` / `reason=fetch_failed` so workflows can block new entries.
|
|
91
91
|
|
|
92
|
+
Overseas-stock historical queries preserve the exchange supplied by each symbol,
|
|
93
|
+
including when the account has no holdings. Item-bound consumers of an explicitly
|
|
94
|
+
empty selected upstream list are skipped before expression evaluation; this also
|
|
95
|
+
covers SymbolFilter output and explicit edge ports. Empty condition gates retain
|
|
96
|
+
their stronger literal-order protection, while non-item aggregate/report nodes
|
|
97
|
+
continue to run. A skipped order keeps its declared output ports and a no-signal
|
|
98
|
+
result row. These safeguards do not establish that upstream market data is valid.
|
|
99
|
+
|
|
92
100
|
For one-shot workflows, an unhandled `order_result.success=False` returned directly
|
|
93
101
|
by a main-flow node makes the final job status `failed` and emits `WORKFLOW_FAILED`.
|
|
94
102
|
The original broker message remains in node diagnostics and the failure statistics.
|
|
@@ -73,7 +73,7 @@ class _SandboxedContext:
|
|
|
73
73
|
DateNamespace, FinanceNamespace, StatsNamespace,
|
|
74
74
|
FormatNamespace, ListNamespace,
|
|
75
75
|
)
|
|
76
|
-
self.date = DateNamespace()
|
|
76
|
+
self.date = DateNamespace(as_of=snapshot.get("validation_as_of"))
|
|
77
77
|
self.finance = FinanceNamespace()
|
|
78
78
|
self.stats = StatsNamespace()
|
|
79
79
|
self.format = FormatNamespace()
|
|
@@ -10946,7 +10946,7 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
10946
10946
|
# the risk → order → notify branch is exercised.
|
|
10947
10947
|
if getattr(context, "is_deep_validate", False) and positions and not _passed:
|
|
10948
10948
|
_passed = [
|
|
10949
|
-
|
|
10949
|
+
dict(p)
|
|
10950
10950
|
for p in positions if isinstance(p, dict) and p.get("symbol")
|
|
10951
10951
|
]
|
|
10952
10952
|
context.log(
|
|
@@ -11018,6 +11018,7 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
11018
11018
|
)
|
|
11019
11019
|
|
|
11020
11020
|
# === 플러그인 required_fields 검증 ===
|
|
11021
|
+
required_fields = []
|
|
11021
11022
|
if plugin_schema and hasattr(plugin_schema, 'required_fields'):
|
|
11022
11023
|
required_fields = plugin_schema.required_fields or []
|
|
11023
11024
|
extract = items_config.get("extract", {})
|
|
@@ -11057,6 +11058,20 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
11057
11058
|
"values": [],
|
|
11058
11059
|
}
|
|
11059
11060
|
|
|
11061
|
+
if context.is_deep_validate:
|
|
11062
|
+
# Wrapper rows can have the right keys but no OHLCV values.
|
|
11063
|
+
# A fixture's missing fields are wiring failures, not no-signal.
|
|
11064
|
+
missing_values = [
|
|
11065
|
+
key for key in required_fields
|
|
11066
|
+
if all(row.get(key) in (None, "") for row in data)
|
|
11067
|
+
]
|
|
11068
|
+
if missing_values:
|
|
11069
|
+
raise ValueError(
|
|
11070
|
+
f"ConditionNode '{node_id}' extracted no values for "
|
|
11071
|
+
f"{missing_values}. Bind items.from to the bar array "
|
|
11072
|
+
"(for example item.time_series), not per-symbol wrappers."
|
|
11073
|
+
)
|
|
11074
|
+
|
|
11060
11075
|
# symbols 자동 추출 (data에서)
|
|
11061
11076
|
symbols = []
|
|
11062
11077
|
seen = set()
|
|
@@ -11210,9 +11225,13 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
11210
11225
|
values = result.get("values", [])
|
|
11211
11226
|
|
|
11212
11227
|
except PluginTimeoutError as e:
|
|
11228
|
+
if context.is_deep_validate:
|
|
11229
|
+
raise
|
|
11213
11230
|
context.log("error", f"Plugin timeout: {e}", node_id)
|
|
11214
11231
|
failed_symbols = normalized_symbols
|
|
11215
11232
|
except Exception as e:
|
|
11233
|
+
if context.is_deep_validate:
|
|
11234
|
+
raise
|
|
11216
11235
|
context.log("error", f"Plugin error: {e}", node_id)
|
|
11217
11236
|
import traceback
|
|
11218
11237
|
context.log("debug", f"Plugin traceback: {traceback.format_exc()}", node_id)
|
|
@@ -11242,6 +11261,18 @@ class ConditionNodeExecutor(NodeExecutorBase):
|
|
|
11242
11261
|
# "no signal this pass"). Deep validation's job is flow/field/type
|
|
11243
11262
|
# integrity, not reproducing a market signal, so guarantee at least the
|
|
11244
11263
|
# input symbols flow through. Runtime / dry_run are untouched.
|
|
11264
|
+
if (
|
|
11265
|
+
context.is_deep_validate
|
|
11266
|
+
and symbol_results
|
|
11267
|
+
and all(row.get("error") for row in symbol_results)
|
|
11268
|
+
):
|
|
11269
|
+
raise ValueError(
|
|
11270
|
+
f"ConditionNode '{node_id}' could not evaluate any symbol: "
|
|
11271
|
+
f"{sorted({str(row['error']) for row in symbol_results})}. "
|
|
11272
|
+
"Check extracted bar fields and indicator lookback. If the default "
|
|
11273
|
+
"fixture is too short, provide a longer deep-validation fixture."
|
|
11274
|
+
)
|
|
11275
|
+
|
|
11245
11276
|
if getattr(context, "is_deep_validate", False) and normalized_symbols and not passed_symbols:
|
|
11246
11277
|
passed_symbols = list(normalized_symbols)
|
|
11247
11278
|
failed_symbols = []
|
|
@@ -12809,8 +12840,12 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12809
12840
|
|
|
12810
12841
|
for symbol in symbols:
|
|
12811
12842
|
try:
|
|
12812
|
-
#
|
|
12813
|
-
exchcd =
|
|
12843
|
+
# Preserve the requested symbol's exchange even without holdings.
|
|
12844
|
+
exchcd = (
|
|
12845
|
+
(symbol_exchange_map or {}).get(symbol)
|
|
12846
|
+
or position_market_code.get(symbol)
|
|
12847
|
+
or self.EXCHANGE_CODES.get(symbol_to_exchange.get(symbol), "82")
|
|
12848
|
+
)
|
|
12814
12849
|
|
|
12815
12850
|
# symbols_raw에서 exchange 정보 가져오기 (우선순위: symbols_raw > exchcd 변환)
|
|
12816
12851
|
exchange = symbol_to_exchange.get(symbol) or exchcd_to_exchange.get(exchcd, "NASDAQ")
|
|
@@ -14505,6 +14540,14 @@ class PositionSizingNodeExecutor(NodeExecutorBase):
|
|
|
14505
14540
|
)
|
|
14506
14541
|
|
|
14507
14542
|
# 방법별 수량 계산
|
|
14543
|
+
if context.is_deep_validate and any(
|
|
14544
|
+
not self._get_price(row.get("symbol", ""), row, price_data, context, node_id)
|
|
14545
|
+
for row in symbols
|
|
14546
|
+
):
|
|
14547
|
+
raise ValueError(
|
|
14548
|
+
"Position sizing has candidates without usable prices. Bind market_data "
|
|
14549
|
+
"to matching quote rows or retain price on each candidate."
|
|
14550
|
+
)
|
|
14508
14551
|
if method == "fixed_percent":
|
|
14509
14552
|
result = self._calc_fixed_percent(
|
|
14510
14553
|
symbols, available_balance, max_percent, price_data, context, node_id
|
|
@@ -15551,6 +15594,25 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
15551
15594
|
|
|
15552
15595
|
# dry_run: LS API 미호출, 모의 응답 반환
|
|
15553
15596
|
if context.is_dry_run:
|
|
15597
|
+
if context.is_deep_validate:
|
|
15598
|
+
# Exercise the actual payload contract before simulating success.
|
|
15599
|
+
# Do not enter connection, balance, quote or broker request paths.
|
|
15600
|
+
config = evaluate_all_bindings(config, context, node_id)
|
|
15601
|
+
if not self._normalize_order(config.get("order"), config, context, node_id):
|
|
15602
|
+
upstream = _input_namespace(context, node_id)
|
|
15603
|
+
if (
|
|
15604
|
+
config.get("order") is None
|
|
15605
|
+
and isinstance(upstream, dict)
|
|
15606
|
+
and upstream.get("reason") == EmptyOrderReason.NO_SIGNAL.value
|
|
15607
|
+
and self._extract_upstream_error(upstream) is None
|
|
15608
|
+
):
|
|
15609
|
+
return self._empty_result(EmptyOrderReason.NO_SIGNAL)
|
|
15610
|
+
raise ValueError(
|
|
15611
|
+
f"{node_type} requires order to resolve to an object with "
|
|
15612
|
+
"a symbol and a positive whole-share/contract quantity. "
|
|
15613
|
+
"Use the current sizing order item or a position retaining "
|
|
15614
|
+
"quantity; a symbol string alone is not an order."
|
|
15615
|
+
)
|
|
15554
15616
|
import uuid
|
|
15555
15617
|
order_id = f"DRYRUN-{uuid.uuid4()}"
|
|
15556
15618
|
context.log(
|
|
@@ -20390,6 +20452,9 @@ class CodeNodeExecutor(NodeExecutorBase):
|
|
|
20390
20452
|
"iteration_total": int(getattr(context, "_iteration_total", 0) or 0),
|
|
20391
20453
|
"risk": {},
|
|
20392
20454
|
}
|
|
20455
|
+
validation_as_of = getattr(context, "validation_as_of", None)
|
|
20456
|
+
if validation_as_of is not None:
|
|
20457
|
+
snap["validation_as_of"] = validation_as_of
|
|
20393
20458
|
try:
|
|
20394
20459
|
rt = context.risk_tracker
|
|
20395
20460
|
if rt is not None and hasattr(rt, "get_all_hwm"):
|
|
@@ -20467,6 +20532,30 @@ class CodeNodeExecutor(NodeExecutorBase):
|
|
|
20467
20532
|
|
|
20468
20533
|
value = env.get("value")
|
|
20469
20534
|
|
|
20535
|
+
if context.is_deep_validate or getattr(context, "is_replay_validation", False):
|
|
20536
|
+
from programgarden.replay_contracts import ContractViolation, check_contract, check_codenode_ports
|
|
20537
|
+
ports = config.get("outputs", [])
|
|
20538
|
+
try:
|
|
20539
|
+
check_codenode_ports(ports)
|
|
20540
|
+
for index, port in enumerate(ports):
|
|
20541
|
+
name = port["name"]
|
|
20542
|
+
if isinstance(value, dict):
|
|
20543
|
+
if name not in value:
|
|
20544
|
+
raise ContractViolation(f"{node_id}.{name}", "Declared output is absent")
|
|
20545
|
+
item = value[name]
|
|
20546
|
+
elif len(ports) == 1:
|
|
20547
|
+
# The existing single-port scalar mapping is explicit
|
|
20548
|
+
# public behavior; validate its value without coercion.
|
|
20549
|
+
item = value
|
|
20550
|
+
else:
|
|
20551
|
+
raise ContractViolation(node_id, "Multiple declared ports require a result object")
|
|
20552
|
+
if port.get("type", "any") != "any":
|
|
20553
|
+
check_contract(item, {"type":port["type"]}, f"{node_id}.{name}")
|
|
20554
|
+
except ContractViolation as exc:
|
|
20555
|
+
raise CodeNodeError(ErrorCode.CODE_NODE_EXEC_ERROR, str(exc),
|
|
20556
|
+
suggestion="Return every declared output with its declared JSON type; do not substitute missing values with zero.",
|
|
20557
|
+
details=exc.as_dict()) from exc
|
|
20558
|
+
|
|
20470
20559
|
# Map return → declared ports. No declared ports → whole value on result.
|
|
20471
20560
|
if not declared:
|
|
20472
20561
|
return {"result": value}
|
|
@@ -21963,10 +22052,16 @@ class WorkflowJob:
|
|
|
21963
22052
|
# `{{ nodes.cond.symbols }}` 로 전체를 소비하는 알림/집계/표시 노드까지
|
|
21964
22053
|
# 침묵한다. "통과 0건이면 아무것도 안 하는 게 정답" 은 **주문 노드**
|
|
21965
22054
|
# 이야기이고, 상태 보고 노드는 그날도 말을 해야 한다(요구사항 4).
|
|
22055
|
+
# Empty SymbolFilter/explicit-port lists also have no iteration item.
|
|
22056
|
+
# Preserve non-item aggregate/report consumers and the stronger
|
|
22057
|
+
# condition-gate guard for literal orders.
|
|
21966
22058
|
skip_reason = None
|
|
21967
22059
|
if (
|
|
21968
22060
|
not should_iterate
|
|
21969
|
-
and
|
|
22061
|
+
and (
|
|
22062
|
+
iterate_source == self.ITERATE_SOURCE_PASSED_SYMBOLS
|
|
22063
|
+
or self._references_iteration_item(config)
|
|
22064
|
+
)
|
|
21970
22065
|
and isinstance(input_data, list)
|
|
21971
22066
|
and not input_data
|
|
21972
22067
|
and (
|
|
@@ -21975,8 +22070,8 @@ class WorkflowJob:
|
|
|
21975
22070
|
)
|
|
21976
22071
|
):
|
|
21977
22072
|
skip_reason = (
|
|
21978
|
-
f"upstream
|
|
21979
|
-
"
|
|
22073
|
+
f"selected upstream source '{iterate_source_node_id}' returned an empty array "
|
|
22074
|
+
"— skipped without executing, so no broker "
|
|
21980
22075
|
"request was made"
|
|
21981
22076
|
)
|
|
21982
22077
|
|
|
@@ -21990,14 +22085,14 @@ class WorkflowJob:
|
|
|
21990
22085
|
# `context.log` 는 파드 stdout 에 안 찍히므로 _safe_print 를 병기한다.
|
|
21991
22086
|
self.context.log(
|
|
21992
22087
|
"info",
|
|
21993
|
-
f"{node.node_type}:
|
|
21994
|
-
|
|
21995
|
-
|
|
22088
|
+
f"{node.node_type}: selected upstream source '{iterate_source_node_id}' "
|
|
22089
|
+
"has no items; skipping execution "
|
|
22090
|
+
"(no_upstream_signal; no order/query request).",
|
|
21996
22091
|
node_id,
|
|
21997
22092
|
)
|
|
21998
22093
|
_safe_print(
|
|
21999
22094
|
f" ⏭️ no_upstream_signal: {node_id} ({node.node_type}) "
|
|
22000
|
-
f"← {iterate_source_node_id}
|
|
22095
|
+
f"← {iterate_source_node_id} selected items=0 — not executed"
|
|
22001
22096
|
)
|
|
22002
22097
|
outputs = self._no_signal_skip_outputs(
|
|
22003
22098
|
node.node_type, skip_reason, iterate_source_node_id,
|
|
@@ -22068,6 +22163,20 @@ class WorkflowJob:
|
|
|
22068
22163
|
},
|
|
22069
22164
|
)
|
|
22070
22165
|
|
|
22166
|
+
if (
|
|
22167
|
+
self.context.is_deep_validate
|
|
22168
|
+
and node.node_type == "PositionSizingNode"
|
|
22169
|
+
and outputs.get("reason") in {"no_symbol", "fetch_failed"}
|
|
22170
|
+
):
|
|
22171
|
+
from programgarden_core import ErrorCode, ErrorLocation, build_error
|
|
22172
|
+
self._node_error_infos[node_id] = build_error(
|
|
22173
|
+
ErrorCode.DEEP_VALIDATION_NODE_ERROR,
|
|
22174
|
+
f"Position sizing inputs are invalid: {outputs.get('detail', '')}",
|
|
22175
|
+
location=ErrorLocation(node_id=node_id, node_type=node.node_type),
|
|
22176
|
+
suggestion="Bind symbols, balance and price data required by the sizing method.",
|
|
22177
|
+
details={"stage": "sizing_inputs", "reason": outputs["reason"]},
|
|
22178
|
+
)
|
|
22179
|
+
|
|
22071
22180
|
# An order node that failed did NOT raise — it returned
|
|
22072
22181
|
# order_result.success=False. Reporting that as COMPLETED leaves
|
|
22073
22182
|
# errors_count=0 / last_error=None, i.e. every observable surface
|
|
@@ -22528,7 +22637,7 @@ class WorkflowJob:
|
|
|
22528
22637
|
for port_name, port_type in _declared_output_ports(node_type):
|
|
22529
22638
|
payload[port_name] = _empty_port_value(port_type)
|
|
22530
22639
|
|
|
22531
|
-
message = "No
|
|
22640
|
+
message = "No items available from the selected upstream source."
|
|
22532
22641
|
if node_type.endswith("OrderNode"):
|
|
22533
22642
|
# 주문 노드 소비자(원장/UI/챗봇)는 order_result 와 `result` 행을 본다.
|
|
22534
22643
|
order_result = {
|
|
@@ -22677,6 +22786,16 @@ class WorkflowJob:
|
|
|
22677
22786
|
all_results.append(outputs)
|
|
22678
22787
|
except Exception as e:
|
|
22679
22788
|
self.context.log("warning", f"Auto-iterate [{idx+1}/{total}] failed: {e}", node_id)
|
|
22789
|
+
if getattr(self.context, "is_deep_validate", False):
|
|
22790
|
+
from programgarden_core import ErrorCode, ErrorLocation, build_error
|
|
22791
|
+
# A later successful item must not erase an earlier failure.
|
|
22792
|
+
self._node_error_infos.setdefault(node_id, build_error(
|
|
22793
|
+
ErrorCode.DEEP_VALIDATION_NODE_ERROR,
|
|
22794
|
+
f"Node '{node_id}' failed at iteration {idx + 1}/{total}: {e}",
|
|
22795
|
+
location=ErrorLocation(node_id=node_id, node_type=node.node_type),
|
|
22796
|
+
suggestion="Check this item's input fields and bindings before executing the workflow.",
|
|
22797
|
+
details={"stage": "auto_iteration", "iteration_index": idx},
|
|
22798
|
+
))
|
|
22680
22799
|
# continue_on_error: 기본적으로 계속 진행
|
|
22681
22800
|
all_results.append({"error": str(e), "item": current_item})
|
|
22682
22801
|
finally:
|
|
@@ -22725,12 +22844,32 @@ class WorkflowJob:
|
|
|
22725
22844
|
"""
|
|
22726
22845
|
if total <= 1 or not isinstance(current_item, dict):
|
|
22727
22846
|
return item_config
|
|
22847
|
+
# These executors already select one symbol from the iteration context.
|
|
22848
|
+
# Keep the generic warning for other consumers and conflicting overrides.
|
|
22849
|
+
if (
|
|
22850
|
+
not self.context.is_deep_validate
|
|
22851
|
+
and node_type in {
|
|
22852
|
+
"OverseasStockMarketDataNode", "OverseasFuturesMarketDataNode",
|
|
22853
|
+
"KoreaStockMarketDataNode", "OverseasStockHistoricalDataNode",
|
|
22854
|
+
"OverseasFuturesHistoricalDataNode", "KoreaStockHistoricalDataNode",
|
|
22855
|
+
}
|
|
22856
|
+
and current_item.get("symbol")
|
|
22857
|
+
and all(
|
|
22858
|
+
not override or self._same_symbol_entry(override, current_item)
|
|
22859
|
+
for override in (
|
|
22860
|
+
item_config.get("symbol"),
|
|
22861
|
+
self.context.get_output(f"_input_{node_id}", "symbol"),
|
|
22862
|
+
)
|
|
22863
|
+
)
|
|
22864
|
+
):
|
|
22865
|
+
return item_config
|
|
22728
22866
|
narrowed = None
|
|
22729
22867
|
for port in self._WHOLE_ARRAY_REEVAL_PORTS:
|
|
22730
22868
|
value = item_config.get(port)
|
|
22731
22869
|
if not isinstance(value, list) or len(value) != total:
|
|
22732
22870
|
continue
|
|
22733
|
-
|
|
22871
|
+
matching = [entry for entry in value if self._same_symbol_entry(entry, current_item)]
|
|
22872
|
+
if not matching:
|
|
22734
22873
|
continue
|
|
22735
22874
|
simulated = bool(getattr(self.context, "is_dry_run", False))
|
|
22736
22875
|
self.context.log(
|
|
@@ -22744,7 +22883,11 @@ class WorkflowJob:
|
|
|
22744
22883
|
if simulated:
|
|
22745
22884
|
if narrowed is None:
|
|
22746
22885
|
narrowed = dict(item_config)
|
|
22747
|
-
|
|
22886
|
+
# Preserve the configured projection and quantity. The upstream
|
|
22887
|
+
# item can contain quotes or other numeric fields that are not
|
|
22888
|
+
# inputs to this node (e.g. a symbol-only market-data contract).
|
|
22889
|
+
# Substituting that raw item changed valid bindings in validation.
|
|
22890
|
+
narrowed[port] = [matching[0]]
|
|
22748
22891
|
return narrowed if narrowed is not None else item_config
|
|
22749
22892
|
|
|
22750
22893
|
def _merge_iterate_results(self, results: list) -> Dict[str, Any]:
|
|
@@ -23535,7 +23678,7 @@ class WorkflowJob:
|
|
|
23535
23678
|
if self.context._iteration_item is None:
|
|
23536
23679
|
for k in list(config_copy.keys()):
|
|
23537
23680
|
v = config_copy[k]
|
|
23538
|
-
if
|
|
23681
|
+
if self._references_iteration_item(v):
|
|
23539
23682
|
deferred[k] = config_copy.pop(k)
|
|
23540
23683
|
|
|
23541
23684
|
deep_mode = bool(getattr(self.context, "is_deep_validate", False))
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Deterministic contracts at replay input, mapping and output boundaries.
|
|
2
|
+
|
|
3
|
+
Contracts use an intentionally bounded JSON-schema subset. Unknown constraints
|
|
4
|
+
fail closed; values are never coerced, defaulted, renamed or replaced with zero.
|
|
5
|
+
Semantic identity is explicit contract data, not inferred from field names.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from decimal import Decimal, InvalidOperation
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
CONTRACT_VERSION = "replay-contract-1"
|
|
16
|
+
_KEYS = frozenset({"type", "nullable", "required", "properties", "items", "enum",
|
|
17
|
+
"minimum", "maximum", "minItems", "maxItems", "minLength",
|
|
18
|
+
"maxLength", "const", "additionalProperties", "format",
|
|
19
|
+
"decimalPlaces", "description"})
|
|
20
|
+
_TYPES = {"object": dict, "array": list, "string": str, "number": (int, float),
|
|
21
|
+
"integer": int, "boolean": bool, "null": type(None)}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ContractViolation(ValueError):
|
|
25
|
+
def __init__(self, path: str, reason: str, code: str = "REPLAY_CONTRACT_FAILED"):
|
|
26
|
+
self.path, self.reason, self.code = path, reason, code
|
|
27
|
+
super().__init__(f"{path}: {reason}")
|
|
28
|
+
|
|
29
|
+
def as_dict(self) -> dict[str, str]:
|
|
30
|
+
return {"code": self.code, "path": self.path, "message": str(self)}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def finite_json(value: Any, path: str = "$", depth: int = 0) -> None:
|
|
34
|
+
if depth > 40:
|
|
35
|
+
raise ContractViolation(path, "JSON nesting exceeds the validation limit")
|
|
36
|
+
if value is None or type(value) in (str, int, bool):
|
|
37
|
+
return
|
|
38
|
+
if type(value) is float:
|
|
39
|
+
if not math.isfinite(value):
|
|
40
|
+
raise ContractViolation(path, "NaN and Infinity are not valid evidence")
|
|
41
|
+
return
|
|
42
|
+
if type(value) is dict:
|
|
43
|
+
for key, item in value.items():
|
|
44
|
+
if not isinstance(key, str):
|
|
45
|
+
raise ContractViolation(path, "Object keys must be strings")
|
|
46
|
+
finite_json(item, f"{path}.{key}", depth + 1)
|
|
47
|
+
return
|
|
48
|
+
if type(value) is list:
|
|
49
|
+
for index, item in enumerate(value):
|
|
50
|
+
finite_json(item, f"{path}[{index}]", depth + 1)
|
|
51
|
+
return
|
|
52
|
+
raise ContractViolation(path, "Expected finite JSON data")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_contract(value: Any, schema: dict[str, Any], path: str = "$") -> None:
|
|
56
|
+
"""Validate a finite JSON value without coercion; raise at the first defect."""
|
|
57
|
+
finite_json(value, path)
|
|
58
|
+
finite_json(schema, "contract")
|
|
59
|
+
_validate_schema(schema, "contract", 0)
|
|
60
|
+
_check(value, schema, path, 0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _literal_equal(left, right):
|
|
64
|
+
"""JSON identities do not coerce nested True/1 or False/0 values."""
|
|
65
|
+
if type(left) in (int,float) and type(right) in (int,float):
|
|
66
|
+
return Decimal(str(left))==Decimal(str(right))
|
|
67
|
+
if type(left) is not type(right):
|
|
68
|
+
return False
|
|
69
|
+
if isinstance(left, dict):
|
|
70
|
+
return left.keys()==right.keys() and all(_literal_equal(left[key],right[key]) for key in left)
|
|
71
|
+
if isinstance(left, list):
|
|
72
|
+
return len(left)==len(right) and all(_literal_equal(a,b) for a,b in zip(left,right))
|
|
73
|
+
return left==right
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _validate_schema(schema: Any, path: str, depth: int) -> None:
|
|
77
|
+
def invalid(reason):
|
|
78
|
+
raise ContractViolation(path, reason, "REPLAY_CONTRACT_UNSUPPORTED")
|
|
79
|
+
if depth > 40 or type(schema) is not dict or set(schema) - _KEYS:
|
|
80
|
+
invalid("Unsupported contract")
|
|
81
|
+
kinds = schema.get("type")
|
|
82
|
+
kinds = kinds if type(kinds) is list else [kinds]
|
|
83
|
+
if not kinds or any(type(k) is not str or k not in _TYPES for k in kinds):
|
|
84
|
+
invalid("Explicit supported type required")
|
|
85
|
+
if len(kinds) != len(set(kinds)):
|
|
86
|
+
invalid("Duplicate types are not supported")
|
|
87
|
+
for key in ("nullable", "additionalProperties"):
|
|
88
|
+
if key in schema and type(schema[key]) is not bool:
|
|
89
|
+
invalid(f"{key} must be boolean")
|
|
90
|
+
if "enum" in schema and (type(schema["enum"]) is not list or not schema["enum"]):
|
|
91
|
+
invalid("enum must be a nonempty array")
|
|
92
|
+
for key in ("minItems", "maxItems", "minLength", "maxLength", "decimalPlaces"):
|
|
93
|
+
if key in schema and (type(schema[key]) is not int or schema[key] < 0):
|
|
94
|
+
invalid(f"{key} must be a nonnegative integer")
|
|
95
|
+
if schema.get("decimalPlaces", 0) > 18:
|
|
96
|
+
invalid("decimalPlaces must not exceed 18")
|
|
97
|
+
for key in ("minimum", "maximum"):
|
|
98
|
+
if key in schema and type(schema[key]) not in (int, float):
|
|
99
|
+
invalid(f"{key} must be a finite number")
|
|
100
|
+
for lower, upper in (("minimum", "maximum"), ("minItems", "maxItems"), ("minLength", "maxLength")):
|
|
101
|
+
if lower in schema and upper in schema and schema[lower] > schema[upper]:
|
|
102
|
+
invalid(f"{lower} exceeds {upper}")
|
|
103
|
+
applicable = {"object": {"properties", "required", "additionalProperties"},
|
|
104
|
+
"array": {"items", "minItems", "maxItems"},
|
|
105
|
+
"string": {"minLength", "maxLength", "format"},
|
|
106
|
+
"number": {"minimum", "maximum", "decimalPlaces"},
|
|
107
|
+
"integer": {"minimum", "maximum", "decimalPlaces"}}
|
|
108
|
+
allowed = {"type", "nullable", "const", "enum", "description"}
|
|
109
|
+
for kind in kinds:
|
|
110
|
+
allowed |= applicable.get(kind, set())
|
|
111
|
+
if set(schema) - allowed:
|
|
112
|
+
invalid("Constraint does not apply to the declared type")
|
|
113
|
+
if "description" in schema and type(schema["description"]) is not str:
|
|
114
|
+
invalid("description must be a string")
|
|
115
|
+
if "format" in schema and schema["format"] != "date-time":
|
|
116
|
+
invalid("Unsupported format")
|
|
117
|
+
if "properties" in schema:
|
|
118
|
+
if type(schema["properties"]) is not dict:
|
|
119
|
+
invalid("properties must be an object")
|
|
120
|
+
for name, child in schema["properties"].items():
|
|
121
|
+
_validate_schema(child, f"{path}.properties.{name}", depth + 1)
|
|
122
|
+
if "required" in schema:
|
|
123
|
+
required = schema["required"]
|
|
124
|
+
if type(required) is not list or any(type(k) is not str for k in required):
|
|
125
|
+
invalid("required must contain field names")
|
|
126
|
+
if len(required) != len(set(required)):
|
|
127
|
+
invalid("required contains duplicates")
|
|
128
|
+
if "items" in schema:
|
|
129
|
+
_validate_schema(schema["items"], f"{path}.items", depth + 1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check(value: Any, schema: dict[str, Any], path: str, depth: int) -> None:
|
|
133
|
+
if depth > 40 or not isinstance(schema, dict) or set(schema) - _KEYS:
|
|
134
|
+
raise ContractViolation(path, "Unsupported contract", "REPLAY_CONTRACT_UNSUPPORTED")
|
|
135
|
+
kind = schema.get("type")
|
|
136
|
+
kinds = kind if isinstance(kind, list) else [kind]
|
|
137
|
+
if not kinds or any(not isinstance(k, str) or k not in _TYPES for k in kinds):
|
|
138
|
+
raise ContractViolation(path, "Explicit supported type required", "REPLAY_CONTRACT_UNSUPPORTED")
|
|
139
|
+
matches = (value is None and schema.get("nullable") is True) or any(isinstance(value, _TYPES[k]) and
|
|
140
|
+
not (k in ("number", "integer") and isinstance(value, bool)) for k in kinds)
|
|
141
|
+
if not matches:
|
|
142
|
+
raise ContractViolation(path, f"Expected {kind}, received {type(value).__name__}")
|
|
143
|
+
if "const" in schema and not _literal_equal(value,schema["const"]):
|
|
144
|
+
raise ContractViolation(path, "Value does not match the required identity")
|
|
145
|
+
if "enum" in schema and not any(_literal_equal(value,v) for v in schema["enum"]):
|
|
146
|
+
raise ContractViolation(path, "Value is outside the allowed enumeration")
|
|
147
|
+
if isinstance(value, dict):
|
|
148
|
+
properties, required = schema.get("properties", {}), schema.get("required", [])
|
|
149
|
+
if not isinstance(properties, dict) or not isinstance(required, list) or any(not isinstance(k, str) for k in required):
|
|
150
|
+
raise ContractViolation(path, "Invalid object contract", "REPLAY_CONTRACT_UNSUPPORTED")
|
|
151
|
+
for key in required:
|
|
152
|
+
if key not in value:
|
|
153
|
+
raise ContractViolation(f"{path}.{key}", "Required field is absent")
|
|
154
|
+
for key, item in value.items():
|
|
155
|
+
if key in properties:
|
|
156
|
+
_check(item, properties[key], f"{path}.{key}", depth + 1)
|
|
157
|
+
elif schema.get("additionalProperties") is False:
|
|
158
|
+
raise ContractViolation(f"{path}.{key}", "Undeclared field")
|
|
159
|
+
elif isinstance(value, list):
|
|
160
|
+
for key, valid in (("minItems", len(value) >= schema.get("minItems", 0)),
|
|
161
|
+
("maxItems", len(value) <= schema.get("maxItems", len(value)))):
|
|
162
|
+
if not valid:
|
|
163
|
+
raise ContractViolation(path, f"Array violates {key}")
|
|
164
|
+
if "items" in schema:
|
|
165
|
+
for index, item in enumerate(value):
|
|
166
|
+
_check(item, schema["items"], f"{path}[{index}]", depth + 1)
|
|
167
|
+
elif type(value) in (float, int):
|
|
168
|
+
if value < schema.get("minimum", value) or value > schema.get("maximum", value):
|
|
169
|
+
raise ContractViolation(path, "Number is outside the allowed range")
|
|
170
|
+
if "decimalPlaces" in schema:
|
|
171
|
+
places = schema["decimalPlaces"]
|
|
172
|
+
if type(places) is not int or not 0 <= places <= 18:
|
|
173
|
+
raise ContractViolation(path, "Invalid precision contract", "REPLAY_CONTRACT_UNSUPPORTED")
|
|
174
|
+
try:
|
|
175
|
+
precision = max(0, -Decimal(str(value)).normalize().as_tuple().exponent)
|
|
176
|
+
except InvalidOperation as exc:
|
|
177
|
+
raise ContractViolation(path, "Invalid decimal") from exc
|
|
178
|
+
if precision > places:
|
|
179
|
+
raise ContractViolation(path, "Number exceeds the allowed decimal precision")
|
|
180
|
+
elif isinstance(value, str):
|
|
181
|
+
if not schema.get("minLength", 0) <= len(value) <= schema.get("maxLength", len(value)):
|
|
182
|
+
raise ContractViolation(path, "String length violates the contract")
|
|
183
|
+
if "format" in schema:
|
|
184
|
+
if schema["format"] != "date-time":
|
|
185
|
+
raise ContractViolation(path, "Unsupported format", "REPLAY_CONTRACT_UNSUPPORTED")
|
|
186
|
+
try:
|
|
187
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
raise ContractViolation(path, "Expected an ISO timestamp") from exc
|
|
190
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
191
|
+
raise ContractViolation(path, "Timestamp must include its timezone")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check_observation_identity(actual: dict[str, Any], expected: dict[str, Any]) -> None:
|
|
195
|
+
"""Check explicit units and time identity at a data boundary.
|
|
196
|
+
|
|
197
|
+
The caller supplies only the semantics consumed by this boundary. A currency
|
|
198
|
+
conversion is a separate transformation with its own tested output contract.
|
|
199
|
+
Missing metadata is unverified, not inferred as USD/UTC from node names.
|
|
200
|
+
"""
|
|
201
|
+
allowed = {"symbol", "market", "currency", "timeframe", "as_of"}
|
|
202
|
+
if not expected or set(expected) - allowed:
|
|
203
|
+
raise ContractViolation("metadata", "Unsupported semantic identity", "REPLAY_CONTRACT_UNSUPPORTED")
|
|
204
|
+
for field, value in expected.items():
|
|
205
|
+
check_contract(actual.get(field), {"type": "string", "const": value,
|
|
206
|
+
**({"format": "date-time"} if field == "as_of" else {})}, f"metadata.{field}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def check_codenode_ports(ports: Any) -> None:
|
|
210
|
+
if not isinstance(ports, list):
|
|
211
|
+
raise ContractViolation("outputs", "Declared CodeNode ports must be a list of objects")
|
|
212
|
+
names: set[str] = set()
|
|
213
|
+
for index, port in enumerate(ports):
|
|
214
|
+
if not isinstance(port, dict) or not isinstance(port.get("name"), str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", port["name"]):
|
|
215
|
+
raise ContractViolation(f"outputs[{index}]", "Invalid declared output port")
|
|
216
|
+
if port["name"] in names or port.get("type", "any") not in {*_TYPES, "any"}:
|
|
217
|
+
raise ContractViolation(f"outputs[{index}]", "Duplicate name or unsupported output type")
|
|
218
|
+
names.add(port["name"])
|
|
@@ -193,7 +193,14 @@ class WorkflowResolver:
|
|
|
193
193
|
f"Unknown node type '{node_type}'",
|
|
194
194
|
location=ErrorLocation(node_id=node_id, node_type=node_type),
|
|
195
195
|
available_values=suggest_close_match(node_type or "", known_types),
|
|
196
|
-
suggestion=
|
|
196
|
+
suggestion=(
|
|
197
|
+
"FundamentalDataNode (FMP) was removed. For LS overseas-stock PER/EPS, "
|
|
198
|
+
"use OverseasStockFundamentalNode and rebind its value/values outputs. "
|
|
199
|
+
"It does not supply financial statements or cash-flow history; "
|
|
200
|
+
"do not silently substitute missing data."
|
|
201
|
+
if node_type == "FundamentalDataNode" else
|
|
202
|
+
"Pick a node type from the registered list."
|
|
203
|
+
),
|
|
197
204
|
)
|
|
198
205
|
)
|
|
199
206
|
|
|
@@ -328,6 +335,9 @@ class WorkflowResolver:
|
|
|
328
335
|
# 10.8 Display 노드 columns 키가 상류 출력 스키마에 실재하는지 (없는 키는 표에 '-' 만 찍힌다)
|
|
329
336
|
self._validate_display_columns(workflow, registry, result)
|
|
330
337
|
|
|
338
|
+
# Order routing choices are literals, never prices or item bindings.
|
|
339
|
+
self._validate_order_enums(workflow, registry, result)
|
|
340
|
+
|
|
331
341
|
# 11. Static recommendations (topology analysis)
|
|
332
342
|
for rec in run_static_recommendation_rules(
|
|
333
343
|
workflow,
|
|
@@ -344,6 +354,39 @@ class WorkflowResolver:
|
|
|
344
354
|
|
|
345
355
|
return result
|
|
346
356
|
|
|
357
|
+
def _validate_order_enums(self, workflow, registry, result) -> None:
|
|
358
|
+
"""Enforce fixed price/order types without changing dynamic close sides."""
|
|
359
|
+
from programgarden_core.models.field_binding import ExpressionMode
|
|
360
|
+
|
|
361
|
+
for node in workflow.nodes:
|
|
362
|
+
node_type = node.get("type", "")
|
|
363
|
+
if not node_type.endswith(("NewOrderNode", "ModifyOrderNode", "CancelOrderNode")):
|
|
364
|
+
continue
|
|
365
|
+
node_class = registry.get(node_type)
|
|
366
|
+
if node_class is None:
|
|
367
|
+
continue
|
|
368
|
+
for name, field in node_class.get_field_schema().items():
|
|
369
|
+
if (
|
|
370
|
+
name not in {"price_type", "order_type"}
|
|
371
|
+
or name not in node
|
|
372
|
+
or field.expression_mode != ExpressionMode.FIXED_ONLY
|
|
373
|
+
or not field.enum_values
|
|
374
|
+
or node[name] in field.enum_values
|
|
375
|
+
):
|
|
376
|
+
continue
|
|
377
|
+
result.add(build_error(
|
|
378
|
+
ErrorCode.INVALID_FIELD_ENUM,
|
|
379
|
+
f"Order field '{name}' requires a fixed value from its enum.",
|
|
380
|
+
location=ErrorLocation(
|
|
381
|
+
node_id=node.get("id"), node_type=node_type, field_path=name,
|
|
382
|
+
),
|
|
383
|
+
available_values=field.enum_values,
|
|
384
|
+
suggestion=(
|
|
385
|
+
"Use a listed literal value. Put an order's numeric price in "
|
|
386
|
+
"order.price, not price_type or order_type."
|
|
387
|
+
),
|
|
388
|
+
))
|
|
389
|
+
|
|
347
390
|
def _attach_inline_recommendations(self, result: ValidationResult, workflow) -> None:
|
|
348
391
|
"""Augment specific ErrorInfo entries with related Recommendation hints.
|
|
349
392
|
|
|
@@ -1040,6 +1083,18 @@ class WorkflowResolver:
|
|
|
1040
1083
|
continue
|
|
1041
1084
|
node_id = node.get("id")
|
|
1042
1085
|
|
|
1086
|
+
# A mapping-shaped declaration was previously silently ignored by
|
|
1087
|
+
# the executor, wrapping a named result twice and disabling signals.
|
|
1088
|
+
from programgarden.replay_contracts import ContractViolation, check_codenode_ports
|
|
1089
|
+
try:
|
|
1090
|
+
check_codenode_ports(node.get("outputs", []))
|
|
1091
|
+
except ContractViolation as exc:
|
|
1092
|
+
result.add(build_error(
|
|
1093
|
+
ErrorCode.INVALID_FIELD_TYPE, str(exc),
|
|
1094
|
+
location=ErrorLocation(node_id=node_id, node_type="CodeNode", field_path="outputs"),
|
|
1095
|
+
suggestion="Use a list such as [{\"name\":\"result\",\"type\":\"object\"}], or omit outputs for one whole-result port.",
|
|
1096
|
+
))
|
|
1097
|
+
|
|
1043
1098
|
# 1. credential_id ban
|
|
1044
1099
|
if node.get("credential_id"):
|
|
1045
1100
|
result.add(
|
|
@@ -5,7 +5,7 @@ authors = [
|
|
|
5
5
|
homepage = "https://programgarden.com"
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
7
|
name = "programgarden"
|
|
8
|
-
version = "
|
|
8
|
+
version = "2.0.1"
|
|
9
9
|
license = "AGPL-3.0-or-later"
|
|
10
10
|
description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
|
|
11
11
|
readme = "README.md"
|
|
@@ -17,7 +17,7 @@ packages = [
|
|
|
17
17
|
exclude = ["examples", "tests", "**/__pycache__"]
|
|
18
18
|
|
|
19
19
|
[tool.poetry.dependencies]
|
|
20
|
-
pydantic = ">=2.0.
|
|
20
|
+
pydantic = ">=2.0.1,<3.0.0"
|
|
21
21
|
croniter = "^6.0.0"
|
|
22
22
|
python-dotenv = "^1.1.0"
|
|
23
23
|
tzdata = "^2025.2"
|
|
@@ -36,9 +36,9 @@ litellm = ">=1.40.0"
|
|
|
36
36
|
# 로 통째 실패한다(1.35.1 의 조용한 TypeError 보다 강한 모듈 로드 실패).
|
|
37
37
|
# (1.26.0 이후로도 context.py 는 WorkflowPnLEvent(**event_data) 에 personal_metrics
|
|
38
38
|
# 를 무조건 실으므로 그 하한 근거도 이 상한에 포함된다.)
|
|
39
|
-
programgarden-core = "^
|
|
40
|
-
programgarden-finance = "^
|
|
41
|
-
programgarden-community = "^
|
|
39
|
+
programgarden-core = "^2.0.1"
|
|
40
|
+
programgarden-finance = "^2.0.1"
|
|
41
|
+
programgarden-community = "^2.0.1"
|
|
42
42
|
|
|
43
43
|
[tool.poetry.group.dev.dependencies]
|
|
44
44
|
pytest = "^8.0.0"
|
|
@@ -50,5 +50,5 @@ uvicorn = "^0.40.0"
|
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
[build-system]
|
|
53
|
-
requires = ["poetry-core>=2.0.
|
|
53
|
+
requires = ["poetry-core>=2.0.1,<3.0.0"]
|
|
54
54
|
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/owned_order_cancellation.py
RENAMED
|
File without changes
|
{programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/position_reconciliation.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.41.1 → programgarden-2.0.1}/programgarden/database/workflow_risk_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|