programgarden 1.22.2__tar.gz → 1.22.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {programgarden-1.22.2 → programgarden-1.22.4}/PKG-INFO +6 -5
  2. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/context.py +5 -0
  3. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/executor.py +175 -23
  4. {programgarden-1.22.2 → programgarden-1.22.4}/pyproject.toml +4 -9
  5. {programgarden-1.22.2 → programgarden-1.22.4}/README.md +0 -0
  6. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/__init__.py +0 -0
  7. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/binding_validator.py +0 -0
  8. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/client.py +0 -0
  9. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/database/__init__.py +0 -0
  10. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/database/checkpoint_manager.py +0 -0
  11. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/database/query_builder.py +0 -0
  12. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/database/workflow_position_tracker.py +0 -0
  13. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/database/workflow_risk_tracker.py +0 -0
  14. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/node_runner.py +0 -0
  15. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/plugin/__init__.py +0 -0
  16. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/plugin/sandbox.py +0 -0
  17. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/providers/__init__.py +0 -0
  18. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/providers/llm_errors.py +0 -0
  19. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/providers/llm_provider.py +0 -0
  20. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/reconnect_handler.py +0 -0
  21. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/resolver.py +0 -0
  22. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/resource/__init__.py +0 -0
  23. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/resource/context.py +0 -0
  24. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/resource/limiter.py +0 -0
  25. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/resource/monitor.py +0 -0
  26. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/resource/throttle.py +0 -0
  27. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/tools/__init__.py +0 -0
  28. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/tools/credential_tools.py +0 -0
  29. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/tools/definition_tools.py +0 -0
  30. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/tools/event_tools.py +0 -0
  31. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/tools/job_tools.py +0 -0
  32. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/tools/registry_tools.py +0 -0
  33. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/tools/sqlite_tools.py +0 -0
  34. {programgarden-1.22.2 → programgarden-1.22.4}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.22.2
3
+ Version: 1.22.4
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  Author: 프로그램동산
6
6
  Author-email: coding@programgarden.com
@@ -8,14 +8,15 @@ Requires-Python: >=3.12
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.12
10
10
  Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
11
12
  Requires-Dist: aiohttp (>=3.9.0,<4.0.0)
12
13
  Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
13
14
  Requires-Dist: croniter (>=6.0.0,<7.0.0)
14
15
  Requires-Dist: litellm (>=1.40.0)
15
16
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
16
- Requires-Dist: programgarden-community (>=1.13.7,<2.0.0)
17
- Requires-Dist: programgarden-core (>=1.14.2,<2.0.0)
18
- Requires-Dist: programgarden-finance (>=1.6.9,<2.0.0)
17
+ Requires-Dist: programgarden-community (>=1.13.8,<2.0.0)
18
+ Requires-Dist: programgarden-core (>=1.14.3,<2.0.0)
19
+ Requires-Dist: programgarden-finance (>=1.6.10,<2.0.0)
19
20
  Requires-Dist: psutil (>=6.0.0,<7.0.0)
20
21
  Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
21
22
  Requires-Dist: pydantic (>=2.0.0,<3.0.0)
@@ -150,12 +150,17 @@ class ExecutionContext:
150
150
  workflow_edges: Optional[List[Any]] = None, # List[ResolvedEdge]
151
151
  workflow_nodes: Optional[Dict[str, Any]] = None, # Dict[str, ResolvedNode]
152
152
  storage_dir: Optional[str] = None,
153
+ ls_token_provider: Optional[Any] = None, # sync (appkey, product, paper_trading) -> (token, expires_at_epoch)
153
154
  ):
154
155
  self.job_id = job_id
155
156
  self.workflow_id = workflow_id
156
157
  self._storage_dir = storage_dir
157
158
  self.context_params = context_params or {}
158
159
 
160
+ # Opt-in LS token provider (Verified League §3.2.3). When set, broker
161
+ # logins consume a server-issued token instead of self-issuing.
162
+ self.ls_token_provider = ls_token_provider
163
+
159
164
  # Secrets storage (never logged, separate from context_params)
160
165
  self._secrets: Dict[str, Any] = secrets or {}
161
166
 
@@ -277,7 +277,23 @@ class LSClientManager:
277
277
  # 새 인스턴스 생성 (싱글톤 우회)
278
278
  ls = object.__new__(LS)
279
279
  ls.__init__()
280
-
280
+
281
+ # Verified League §3.2.3: when a token provider is configured, route this
282
+ # LS instance through it (server = single issuer) so login consumes a
283
+ # server-issued token instead of self-issuing via GenerateToken. login()
284
+ # is synchronous, so we register a sync provider (it is also reused by the
285
+ # async refresh path as a fallback). Bound to this instance's
286
+ # appkey/product/paper_trading; returns (access_token, expires_at_epoch).
287
+ token_provider = getattr(context, "ls_token_provider", None)
288
+ if token_provider is not None:
289
+ def _sync_token_provider(
290
+ _appkey=appkey, _product=product, _paper=paper_trading,
291
+ ):
292
+ return token_provider(_appkey, _product, _paper)
293
+
294
+ ls.set_token_provider(provider=_sync_token_provider)
295
+ context.log("info", f"LS token provider attached for {product}", node_id)
296
+
281
297
  # 로그인
282
298
  login_result = ls.login(
283
299
  appkey=appkey,
@@ -8364,39 +8380,48 @@ class LogicNodeExecutor(NodeExecutorBase):
8364
8380
  condition_results: List[Dict[str, Any]] = []
8365
8381
  all_passed_symbols: List[List[str]] = []
8366
8382
  weights: Dict[int, float] = {} # index -> weight
8367
-
8383
+
8368
8384
  for idx, cond in enumerate(conditions):
8369
8385
  # 조건 객체 검증
8370
8386
  if not isinstance(cond, dict):
8371
8387
  context.log("warning", f"Condition at index {idx} is not a dict, skipping", node_id)
8372
8388
  continue
8373
-
8389
+
8374
8390
  # is_condition_met 필수
8391
+ # 다종목 auto-iterate 시 is_condition_met 바인딩이 병합 리스트
8392
+ # ([False, False, ...]) 로 해석될 수 있다. 리스트는 "통과 여부"가
8393
+ # 아니라 "실행 여부"를 뜻하게 되므로, any() 로 스칼라화해 per-condition
8394
+ # 통과 여부로 환원한다. (None 은 미제공 → 기존처럼 False + 경고)
8375
8395
  is_met = cond.get("is_condition_met")
8376
8396
  if is_met is None:
8377
8397
  context.log("warning", f"Condition at index {idx} missing 'is_condition_met', treating as False", node_id)
8378
- is_met = False
8379
-
8380
- # passed_symbols 필수
8381
- passed_symbols = cond.get("passed_symbols")
8382
- if passed_symbols is None:
8383
- passed_symbols = []
8384
- if not isinstance(passed_symbols, list):
8385
- passed_symbols = []
8386
-
8398
+ is_met_scalar = False
8399
+ elif isinstance(is_met, list):
8400
+ is_met_scalar = any(bool(x) for x in is_met)
8401
+ else:
8402
+ is_met_scalar = bool(is_met)
8403
+
8404
+ # passed_symbols: None(미제공) 과 [](명시 빈 리스트) 를 구분한다.
8405
+ # - 명시 리스트(빈 리스트 포함) → "symbol-bearing" 조건 (교집합 참여)
8406
+ # - 미제공/None/비리스트 → "boolean-gate" 조건 (bool 게이팅만)
8407
+ raw_passed = cond.get("passed_symbols")
8408
+ symbols_provided = isinstance(raw_passed, list)
8409
+ passed_symbols = raw_passed if symbols_provided else []
8410
+
8387
8411
  # weight (optional, default 1.0)
8388
8412
  weight = cond.get("weight", 1.0)
8389
8413
  if not isinstance(weight, (int, float)):
8390
8414
  weight = 1.0
8391
8415
  weights[idx] = float(weight)
8392
-
8416
+
8393
8417
  condition_results.append({
8394
8418
  "index": idx,
8395
- "result": bool(is_met),
8419
+ "result": is_met_scalar,
8396
8420
  "passed_symbols": passed_symbols,
8421
+ "symbols_provided": symbols_provided,
8397
8422
  "weight": weight,
8398
8423
  })
8399
- all_passed_symbols.append(passed_symbols if isinstance(passed_symbols, list) else [])
8424
+ all_passed_symbols.append(passed_symbols)
8400
8425
 
8401
8426
  if not condition_results:
8402
8427
  context.log("warning", "No valid condition results to combine", node_id)
@@ -8486,8 +8511,16 @@ class LogicNodeExecutor(NodeExecutorBase):
8486
8511
  elif isinstance(s, str):
8487
8512
  codes.add(s)
8488
8513
  return codes
8489
-
8490
- sets = [extract_codes(s) for s in all_passed_symbols if s]
8514
+
8515
+ # symbol-bearing 조건(passed_symbols 명시 제공)만 교집합에 참여한다.
8516
+ # 빈 리스트도 명시 제공이면 포함 → AND 교집합을 [] 로 영점화한다.
8517
+ # boolean-gate 조건(미제공)은 심볼 의미가 없으므로 교집합에서 제외하고
8518
+ # bool(result) 로만 게이팅된다.
8519
+ sets = [
8520
+ extract_codes(r["passed_symbols"])
8521
+ for r in results
8522
+ if r.get("symbols_provided")
8523
+ ]
8491
8524
  if not sets:
8492
8525
  return []
8493
8526
  common = sets[0]
@@ -9968,11 +10001,18 @@ class BacktestEngineNodeExecutor(NodeExecutorBase):
9968
10001
  context.log("warning", f"External binding evaluation failed for {target_field}: {e}", node_id)
9969
10002
  external_values[target_field] = source_expr
9970
10003
 
10004
+ # 기본 컨텍스트 dict (row 제외) — ExpressionContext 는 mapping 이 아니므로
10005
+ # 반드시 to_dict() 로 평탄화해야 한다. 이전엔 {**expr_context} 로 객체를
10006
+ # 직접 언팩하려다 비어있지 않은 from_data 의 첫 row 에서 TypeError 로
10007
+ # 크래시했다 (dry_run 은 mock 이 빈 time_series 를 줘 루프가 안 돌아 은폐됨).
10008
+ base_dict = expr_context.to_dict()
10009
+
9971
10010
  # 각 row 처리
9972
10011
  for row in from_data:
9973
10012
  record = {}
9974
- row_context = {**expr_context, "row": row}
9975
- row_evaluator = ExpressionEvaluator(row_context)
10013
+ row_ctx = ExpressionContext()
10014
+ row_ctx.variables = {**base_dict, "row": row}
10015
+ row_evaluator = ExpressionEvaluator(row_ctx)
9976
10016
 
9977
10017
  for target_field, source_expr in extract.items():
9978
10018
  if target_field in external_values:
@@ -12891,6 +12931,7 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
12891
12931
  )
12892
12932
  return {
12893
12933
  "order_id": order_id,
12934
+ "modified_order_id": order_id,
12894
12935
  "status": "simulated",
12895
12936
  "dry_run": True,
12896
12937
  "requested": config,
@@ -13038,13 +13079,33 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
13038
13079
  "original_order_id": original_order_id,
13039
13080
  "product": "overseas_stock",
13040
13081
  },
13082
+ "modified_order_id": "",
13041
13083
  "modified_order": None,
13042
13084
  }
13043
13085
 
13044
13086
  new_order_no = ""
13045
13087
  if response.block2:
13046
13088
  new_order_no = str(response.block2.OrdNo) if response.block2.OrdNo else ""
13047
-
13089
+
13090
+ # 정정 빈-주문번호 가드 (거래시간 외/정정 불가 상태 silent no-op 차단)
13091
+ if not new_order_no:
13092
+ msg = response.rsp_msg or "정정 미반영"
13093
+ context.log(
13094
+ "warning",
13095
+ f"Modify order returned no OrderNo: {symbol} - {msg}",
13096
+ node_id,
13097
+ )
13098
+ return {
13099
+ "modify_result": {
13100
+ "success": False,
13101
+ "error": f"Empty modify order number: {msg} (거래시간 외/정정 불가 상태 가능)",
13102
+ "original_order_id": original_order_id,
13103
+ "product": "overseas_stock",
13104
+ },
13105
+ "modified_order_id": "",
13106
+ "modified_order": None,
13107
+ }
13108
+
13048
13109
  context.log(
13049
13110
  "info",
13050
13111
  f"Order modified: {symbol} original={original_order_id} → new={new_order_no}",
@@ -13058,6 +13119,7 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
13058
13119
  "new_order_id": new_order_no,
13059
13120
  "product": "overseas_stock",
13060
13121
  },
13122
+ "modified_order_id": new_order_no,
13061
13123
  "modified_order": {
13062
13124
  "symbol": symbol,
13063
13125
  "exchange": exchange,
@@ -13137,14 +13199,34 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
13137
13199
  "original_order_id": original_order_id,
13138
13200
  "product": "overseas_futures",
13139
13201
  },
13202
+ "modified_order_id": "",
13140
13203
  "modified_order": None,
13141
13204
  }
13142
-
13205
+
13143
13206
  new_order_no = ""
13144
13207
  if response.block2:
13145
13208
  # 해외선물 정정 주문번호 필드: OvrsFutsOrdNo
13146
13209
  new_order_no = str(response.block2.OvrsFutsOrdNo) if hasattr(response.block2, "OvrsFutsOrdNo") and response.block2.OvrsFutsOrdNo else ""
13147
13210
 
13211
+ # 정정 빈-주문번호 가드 (거래시간 외/정정 불가 상태 silent no-op 차단)
13212
+ if not new_order_no:
13213
+ msg = response.rsp_msg or "정정 미반영"
13214
+ context.log(
13215
+ "warning",
13216
+ f"Modify futures order returned no OrderNo: {symbol} - {msg}",
13217
+ node_id,
13218
+ )
13219
+ return {
13220
+ "modify_result": {
13221
+ "success": False,
13222
+ "error": f"Empty modify order number: {msg} (거래시간 외/정정 불가 상태 가능)",
13223
+ "original_order_id": original_order_id,
13224
+ "product": "overseas_futures",
13225
+ },
13226
+ "modified_order_id": "",
13227
+ "modified_order": None,
13228
+ }
13229
+
13148
13230
  context.log(
13149
13231
  "info",
13150
13232
  f"Futures order modified: {symbol} original={original_order_id} → new={new_order_no}",
@@ -13158,6 +13240,7 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
13158
13240
  "new_order_id": new_order_no,
13159
13241
  "product": "overseas_futures",
13160
13242
  },
13243
+ "modified_order_id": new_order_no,
13161
13244
  "modified_order": {
13162
13245
  "symbol": symbol,
13163
13246
  "exchange": exchange_code,
@@ -13220,6 +13303,7 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
13220
13303
  "original_order_id": original_order_id,
13221
13304
  "product": "korea_stock",
13222
13305
  },
13306
+ "modified_order_id": "",
13223
13307
  "modified_order": None,
13224
13308
  }
13225
13309
 
@@ -13227,6 +13311,25 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
13227
13311
  if response.block2:
13228
13312
  new_order_no = str(response.block2.OrdNo) if response.block2.OrdNo else ""
13229
13313
 
13314
+ # 정정 빈-주문번호 가드 (거래시간 외/정정 불가 상태 silent no-op 차단)
13315
+ if not new_order_no:
13316
+ msg = response.rsp_msg or "정정 미반영"
13317
+ context.log(
13318
+ "warning",
13319
+ f"Korea stock modify order returned no OrderNo: {symbol} - {msg}",
13320
+ node_id,
13321
+ )
13322
+ return {
13323
+ "modify_result": {
13324
+ "success": False,
13325
+ "error": f"Empty modify order number: {msg} (거래시간 외/정정 불가 상태 가능)",
13326
+ "original_order_id": original_order_id,
13327
+ "product": "korea_stock",
13328
+ },
13329
+ "modified_order_id": "",
13330
+ "modified_order": None,
13331
+ }
13332
+
13230
13333
  context.log(
13231
13334
  "info",
13232
13335
  f"Korea stock order modified: {symbol} original={original_order_id} → new={new_order_no}",
@@ -13240,6 +13343,7 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
13240
13343
  "new_order_id": new_order_no,
13241
13344
  "product": "korea_stock",
13242
13345
  },
13346
+ "modified_order_id": new_order_no,
13243
13347
  "modified_order": {
13244
13348
  "symbol": symbol,
13245
13349
  "exchange": "KRX",
@@ -13270,6 +13374,7 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
13270
13374
  "success": False,
13271
13375
  "error": error_msg,
13272
13376
  },
13377
+ "modified_order_id": "",
13273
13378
  "modified_order": None,
13274
13379
  }
13275
13380
 
@@ -13286,6 +13391,8 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13286
13391
  - original_order_id: 취소할 주문번호 (필수)
13287
13392
  - symbol: 종목코드
13288
13393
  - exchange: 거래소
13394
+
13395
+ 주의: LS 가 error_msg 없이 취소 미반영(거래시간 외 등)을 반환할 수 있다. 취소 성공은 사후 OpenOrders 재조회로 확인 권장.
13289
13396
  """
13290
13397
 
13291
13398
  # 해외주식 시장 코드 매핑
@@ -13316,6 +13423,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13316
13423
  )
13317
13424
  return {
13318
13425
  "order_id": order_id,
13426
+ "cancelled_order_id": order_id,
13319
13427
  "status": "simulated",
13320
13428
  "dry_run": True,
13321
13429
  "requested": config,
@@ -13449,9 +13557,10 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13449
13557
  "order_id": order_id,
13450
13558
  "product": "overseas_stock",
13451
13559
  },
13560
+ "cancelled_order_id": "",
13452
13561
  "cancelled_order": None,
13453
13562
  }
13454
-
13563
+
13455
13564
  context.log(
13456
13565
  "info",
13457
13566
  f"Order cancelled: {symbol} order_id={order_id}",
@@ -13464,6 +13573,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13464
13573
  "order_id": order_id,
13465
13574
  "product": "overseas_stock",
13466
13575
  },
13576
+ "cancelled_order_id": order_id,
13467
13577
  "cancelled_order": {
13468
13578
  "symbol": symbol,
13469
13579
  "exchange": exchange,
@@ -13481,6 +13591,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13481
13591
  "order_id": order_id,
13482
13592
  "product": "overseas_stock",
13483
13593
  },
13594
+ "cancelled_order_id": "",
13484
13595
  "cancelled_order": None,
13485
13596
  }
13486
13597
 
@@ -13527,6 +13638,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13527
13638
  "order_id": order_id,
13528
13639
  "product": "overseas_futures",
13529
13640
  },
13641
+ "cancelled_order_id": "",
13530
13642
  "cancelled_order": None,
13531
13643
  }
13532
13644
 
@@ -13542,6 +13654,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13542
13654
  "order_id": order_id,
13543
13655
  "product": "overseas_futures",
13544
13656
  },
13657
+ "cancelled_order_id": order_id,
13545
13658
  "cancelled_order": {
13546
13659
  "symbol": symbol,
13547
13660
  "exchange": exchange_code,
@@ -13559,6 +13672,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13559
13672
  "order_id": order_id,
13560
13673
  "product": "overseas_futures",
13561
13674
  },
13675
+ "cancelled_order_id": "",
13562
13676
  "cancelled_order": None,
13563
13677
  }
13564
13678
 
@@ -13600,6 +13714,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13600
13714
  "order_id": order_id,
13601
13715
  "product": "korea_stock",
13602
13716
  },
13717
+ "cancelled_order_id": "",
13603
13718
  "cancelled_order": None,
13604
13719
  }
13605
13720
 
@@ -13615,6 +13730,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13615
13730
  "order_id": order_id,
13616
13731
  "product": "korea_stock",
13617
13732
  },
13733
+ "cancelled_order_id": order_id,
13618
13734
  "cancelled_order": {
13619
13735
  "symbol": symbol,
13620
13736
  "exchange": "KRX",
@@ -13632,6 +13748,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13632
13748
  "order_id": order_id,
13633
13749
  "product": "korea_stock",
13634
13750
  },
13751
+ "cancelled_order_id": "",
13635
13752
  "cancelled_order": None,
13636
13753
  }
13637
13754
 
@@ -13642,6 +13759,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
13642
13759
  "success": False,
13643
13760
  "error": error_msg,
13644
13761
  },
13762
+ "cancelled_order_id": "",
13645
13763
  "cancelled_order": None,
13646
13764
  }
13647
13765
 
@@ -14874,6 +14992,21 @@ class WorkflowExecutor:
14874
14992
  self._executors: Dict[str, NodeExecutorBase] = self._init_executors()
14875
14993
  # 동적 노드 레지스트리 (지연 임포트로 순환 참조 방지)
14876
14994
  self._dynamic_registry = None
14995
+ # Opt-in LS token provider (Verified League §3.2.3). When set, broker
14996
+ # logins fetch the token from this callback (a remote server) instead of
14997
+ # self-issuing via GenerateToken, so the platform server is the single
14998
+ # token issuer and this executor is a pure consumer. login() is sync, so
14999
+ # the provider is a sync callable:
15000
+ # (appkey: str, product: str, paper_trading: bool) -> (access_token, expires_at_epoch)
15001
+ # Left None for standalone/public usage (unchanged self-issue path).
15002
+ self.ls_token_provider = None
15003
+
15004
+ def set_ls_token_provider(self, provider) -> None:
15005
+ """Configure the opt-in LS token provider (Verified League §3.2.3).
15006
+
15007
+ Pass None to clear and revert to the default self-issue path.
15008
+ """
15009
+ self.ls_token_provider = provider
14877
15010
 
14878
15011
  def _get_dynamic_registry(self):
14879
15012
  """DynamicNodeRegistry 싱글톤 반환 (지연 로딩)"""
@@ -15129,8 +15262,9 @@ class WorkflowExecutor:
15129
15262
  workflow_edges=resolved.edges,
15130
15263
  workflow_nodes=resolved.nodes,
15131
15264
  storage_dir=storage_dir,
15265
+ ls_token_provider=self.ls_token_provider,
15132
15266
  )
15133
-
15267
+
15134
15268
  # Set listeners (Option A: inject at creation)
15135
15269
  if listeners:
15136
15270
  context.set_listeners(listeners)
@@ -15253,6 +15387,7 @@ class WorkflowExecutor:
15253
15387
  workflow_edges=resolved.edges,
15254
15388
  workflow_nodes=resolved.nodes,
15255
15389
  storage_dir=storage_dir,
15390
+ ls_token_provider=self.ls_token_provider,
15256
15391
  )
15257
15392
 
15258
15393
  if listeners:
@@ -16264,6 +16399,23 @@ class WorkflowJob:
16264
16399
  input_data = None
16265
16400
  for edge in self.workflow.edges:
16266
16401
  if edge.to_node_id == node_id:
16402
+ # 명시적 from_port 우선 (예: ExclusionListNode.filtered).
16403
+ # 이를 지정하지 않으면 auto-iterate 는 소스 노드의 첫 출력
16404
+ # 포트를 집어버린다 — ExclusionListNode 는 첫 포트가
16405
+ # `excluded`(블랙리스트)라서, from_port 없이는 제외 종목을
16406
+ # 순회하게 되는 silent 버그가 생긴다. IfNode 분기 포트
16407
+ # (true/false/result)는 별도 라우팅 의미라 여기서 제외.
16408
+ explicit_port = getattr(edge, "from_port", None)
16409
+ if explicit_port and explicit_port not in (
16410
+ "output", "true", "false", "result",
16411
+ ):
16412
+ port_data = self.context.get_output(
16413
+ edge.from_node_id, explicit_port
16414
+ )
16415
+ if port_data is not None:
16416
+ input_data = port_data
16417
+ break
16418
+
16267
16419
  # symbols 포트 우선 확인 (WatchlistNode 출력)
16268
16420
  input_data = self.context.get_output(edge.from_node_id, "symbols")
16269
16421
 
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.22.2"
8
+ version = "1.22.4"
9
9
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
10
10
  readme = "README.md"
11
11
 
@@ -28,14 +28,9 @@ lxml = "^6.0.2"
28
28
  pytickersymbols = {version = ">=1.17.5", python = ">=3.12,<4.0"}
29
29
  aiosqlite = "^0.20.0"
30
30
  litellm = ">=1.40.0"
31
- programgarden-core = { version = "^1.14.2", source = "testpypi" }
32
- programgarden-finance = { version = "^1.6.9", source = "testpypi" }
33
- programgarden-community = { version = "^1.13.7", source = "testpypi" }
34
-
35
- [[tool.poetry.source]]
36
- name = "testpypi"
37
- url = "https://test.pypi.org/simple/"
38
- priority = "supplemental"
31
+ programgarden-core = "^1.14.3"
32
+ programgarden-finance = "^1.6.10"
33
+ programgarden-community = "^1.13.8"
39
34
 
40
35
  [tool.poetry.group.dev.dependencies]
41
36
  pytest = "^8.0.0"
File without changes