programgarden 1.27.0__tar.gz → 1.28.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {programgarden-1.27.0 → programgarden-1.28.0}/PKG-INFO +2 -2
  2. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/__init__.py +1 -1
  3. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/context.py +29 -9
  4. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/executor.py +169 -69
  5. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resolver.py +121 -0
  6. {programgarden-1.27.0 → programgarden-1.28.0}/pyproject.toml +2 -2
  7. {programgarden-1.27.0 → programgarden-1.28.0}/README.md +0 -0
  8. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/binding_validator.py +0 -0
  9. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/client.py +0 -0
  10. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/code_worker.py +0 -0
  11. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/__init__.py +0 -0
  12. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/checkpoint_manager.py +0 -0
  13. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/query_builder.py +0 -0
  14. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/workflow_position_tracker.py +0 -0
  15. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  16. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/deep_fixtures.py +0 -0
  17. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/node_runner.py +0 -0
  18. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/plugin/__init__.py +0 -0
  19. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/plugin/sandbox.py +0 -0
  20. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/providers/__init__.py +0 -0
  21. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/providers/llm_errors.py +0 -0
  22. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/providers/llm_provider.py +0 -0
  23. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/reconnect_handler.py +0 -0
  24. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resource/__init__.py +0 -0
  25. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resource/context.py +0 -0
  26. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resource/limiter.py +0 -0
  27. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resource/monitor.py +0 -0
  28. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resource/throttle.py +0 -0
  29. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/semantic_rules.py +0 -0
  30. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.27.0
3
+ Version: 1.28.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -16,7 +16,7 @@ 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
18
  Requires-Dist: programgarden-community (>=1.13.11,<2.0.0)
19
- Requires-Dist: programgarden-core (>=1.18.0,<2.0.0)
19
+ Requires-Dist: programgarden-core (>=1.20.0,<2.0.0)
20
20
  Requires-Dist: programgarden-finance (>=1.6.14,<2.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)
@@ -91,7 +91,7 @@ except ImportError:
91
91
  # Community package not installed
92
92
  pass
93
93
 
94
- __version__ = "1.27.0"
94
+ __version__ = "1.28.0"
95
95
  __all__ = [
96
96
  # Core
97
97
  "ProgramGarden",
@@ -2626,26 +2626,46 @@ class ExecutionContext:
2626
2626
  logger.warning(f"Failed to sync fills from history: {e}")
2627
2627
  return 0
2628
2628
 
2629
+ # 노드 출력에 실려 외부로 나가면 안 되는 키 (부분일치, 소문자 비교).
2630
+ # BrokerNode 는 `connection.appkey/appsecret` 을 하류 노드에 넘기기 위해 **출력에 싣는다**
2631
+ # (내부 전송 경로라 지울 수 없다). 따라서 외부로 나가는 모든 경계에서 가려야 한다.
2632
+ SENSITIVE_OUTPUT_KEYS = frozenset(
2633
+ {"appkey", "appsecret", "secret", "password", "token", "api_key", "apikey", "private_key"}
2634
+ )
2635
+
2636
+ def _sanitize_value(self, value: Any) -> Any:
2637
+ """dict/list 를 재귀적으로 훑어 민감 키를 가린다."""
2638
+ if isinstance(value, dict):
2639
+ return self._sanitize_outputs(value)
2640
+ if isinstance(value, (list, tuple)):
2641
+ # 리스트 안의 dict 도 가려야 한다 (예: credentials[] 배열).
2642
+ sanitized_items = [self._sanitize_value(item) for item in value]
2643
+ return type(value)(sanitized_items) if isinstance(value, tuple) else sanitized_items
2644
+ return value
2645
+
2629
2646
  def _sanitize_outputs(self, outputs: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
2630
2647
  """Remove sensitive information from outputs."""
2631
2648
  # None은 None으로 반환, 빈 딕셔너리 {}는 그대로 유지
2632
2649
  if outputs is None:
2633
2650
  return None
2634
-
2635
- # Filter sensitive keywords
2636
- sensitive_keys = {"appkey", "appsecret", "secret", "password", "token", "api_key"}
2651
+
2637
2652
  sanitized = {}
2638
-
2639
2653
  for k, v in outputs.items():
2640
- if any(sk in k.lower() for sk in sensitive_keys):
2654
+ if any(sk in str(k).lower() for sk in self.SENSITIVE_OUTPUT_KEYS):
2641
2655
  sanitized[k] = "[REDACTED]"
2642
- elif isinstance(v, dict):
2643
- sanitized[k] = self._sanitize_outputs(v)
2644
2656
  else:
2645
- sanitized[k] = v
2646
-
2657
+ sanitized[k] = self._sanitize_value(v)
2658
+
2647
2659
  return sanitized
2648
2660
 
2661
+ def get_all_outputs_sanitized(self, node_id: str) -> Dict[str, Any]:
2662
+ """외부로 내보낼 노드 출력 — 민감 키가 가려진 사본.
2663
+
2664
+ 내부 전송(바인딩 해석 · broker connection auto-inject)은 `get_all_outputs()` 의
2665
+ raw 값을 그대로 쓰고, **외부 노출면**(리스너 이벤트 · `get_state()`)만 이걸 쓴다.
2666
+ """
2667
+ return self._sanitize_outputs(self.get_all_outputs(node_id)) or {}
2668
+
2649
2669
  # === Logging ===
2650
2670
 
2651
2671
  def log(
@@ -373,6 +373,43 @@ class LSClientManager:
373
373
  cls._credentials.clear()
374
374
 
375
375
 
376
+ def broker_credential_key(product: Optional[str]) -> str:
377
+ """BrokerNode 자격증명의 시크릿 저장소 키 (product 별 분리).
378
+
379
+ 단일 슬롯이면 한 워크플로우에 브로커가 둘 이상일 때(해외주식 + 국내주식) 뒤에 실행된
380
+ BrokerNode 가 앞의 자격증명을 덮어써, 하류가 **다른 계좌의 앱키**로 로그인하게 된다.
381
+ """
382
+ return f"broker_credentials:{product or 'unknown'}"
383
+
384
+
385
+ def _resolve_broker_credentials(
386
+ broker_connection: Optional[Dict[str, Any]],
387
+ context: "ExecutionContext",
388
+ ) -> tuple:
389
+ """브로커 자격증명 조회 → ``(appkey, appsecret, paper_trading)``.
390
+
391
+ 정본은 **시크릿 저장소**다 — 자격증명은 노드 출력에 실리지 않는다(평문 유출 방지).
392
+ 조회 순서:
393
+ 1. product 별 슬롯 (정확 — 다중 브로커 워크플로우에서도 안 섞인다)
394
+ 2. 레거시 단일 슬롯 (product 를 모를 때)
395
+ 3. ``connection`` 안의 평문 키 — **레거시 폴백**. 옛 워크플로우/외부 호출자와
396
+ deep_validate 픽스처가 아직 이 모양을 쓴다.
397
+ """
398
+ conn = broker_connection or {}
399
+ product = conn.get("product")
400
+
401
+ cred: Dict[str, Any] = {}
402
+ if product:
403
+ cred = context.get_credential(broker_credential_key(product)) or {}
404
+ if not cred:
405
+ cred = context.get_credential() or {}
406
+
407
+ appkey = cred.get("appkey") or conn.get("appkey", "")
408
+ appsecret = cred.get("appsecret") or conn.get("appsecret", "")
409
+ paper_trading = cred.get("paper_trading", conn.get("paper_trading", False))
410
+ return appkey, appsecret, paper_trading
411
+
412
+
376
413
  def ensure_ls_login(
377
414
  appkey: str,
378
415
  appsecret: str,
@@ -1974,6 +2011,12 @@ class ScreenerNodeExecutor(NodeExecutorBase):
1974
2011
  "market_cap": mcap,
1975
2012
  # g3101 enrich 시 volume 이 채워졌을 수 있음
1976
2013
  "volume": int(sym.get("volume") or 0),
2014
+ # 상류가 준 값 통과 (없으면 빈 문자열). LS 마스터에는 섹터가 없다.
2015
+ # 그래도 키는 내보낸다 — yfinance 분기와 키 집합이 다르면 같은 포트가
2016
+ # 분기마다 다른 모양이 되고, 선언이 어느 쪽을 적든 반대 분기에서 거짓말이 된다.
2017
+ "name": sym.get("name", "") or "",
2018
+ "market": sym.get("market", "") or "",
2019
+ "sector": sym.get("sector", "") or "",
1977
2020
  })
1978
2021
 
1979
2022
  # 2단계: volume_min 명시 시 g3101로 거래량 확인 (이미 enrich 된 경우 skip)
@@ -2218,8 +2261,11 @@ class ScreenerNodeExecutor(NodeExecutorBase):
2218
2261
  return entry.get("symbol", "")
2219
2262
  return entry
2220
2263
 
2264
+ # 상류(SymbolQueryNode 등)가 준 name / market 을 그대로 통과시키려면 원본 dict 이 필요하다.
2265
+ # 예전엔 (원본코드, yf티커) 두 개만 들고 다녀서 종목명이 **여기서 소실**됐다 —
2266
+ # 표에 종목명 열을 넣은 예제가 조용히 '-' 만 찍고 있었다.
2221
2267
  lookup_pairs = [
2222
- (_original_symbol(s), _to_yfinance_ticker(s))
2268
+ (_original_symbol(s), _to_yfinance_ticker(s), s if isinstance(s, dict) else {})
2223
2269
  for s in symbols
2224
2270
  ]
2225
2271
 
@@ -2230,7 +2276,7 @@ class ScreenerNodeExecutor(NodeExecutorBase):
2230
2276
  attempted = 0
2231
2277
  info_succeeded = 0
2232
2278
 
2233
- for original_sym, yf_ticker in lookup_pairs[:max_results * 2]: # 여유분
2279
+ for original_sym, yf_ticker, src in lookup_pairs[:max_results * 2]: # 여유분
2234
2280
  if not yf_ticker:
2235
2281
  continue
2236
2282
  attempted += 1
@@ -2280,6 +2326,8 @@ class ScreenerNodeExecutor(NodeExecutorBase):
2280
2326
  filtered.append({
2281
2327
  "exchange": mapped_exchange,
2282
2328
  "symbol": original_sym,
2329
+ "name": src.get("name") or info.get("shortName") or info.get("longName") or "",
2330
+ "market": src.get("market", "") or "",
2283
2331
  "market_cap": mcap,
2284
2332
  "volume": vol,
2285
2333
  "price": price,
@@ -3156,11 +3204,17 @@ class BrokerNodeExecutor(NodeExecutorBase):
3156
3204
  paper_trading = config.get("paper_trading", paper_trading)
3157
3205
 
3158
3206
  if appkey and appsecret:
3159
- context.set_secret("credential_id", {
3207
+ cred_payload = {
3160
3208
  "appkey": appkey,
3161
3209
  "appsecret": appsecret,
3162
3210
  "paper_trading": paper_trading,
3163
- })
3211
+ }
3212
+ # 자격증명은 **시크릿 저장소에만** 둔다 — 노드 출력(`connection`)에 실으면
3213
+ # 리스너(SSE) · get_state · 체크포인트로 평문이 새어 나간다.
3214
+ # product 별 슬롯: 한 워크플로우에 브로커가 둘 이상이면(해외+국내) 단일 슬롯은 덮어써진다.
3215
+ context.set_secret(broker_credential_key(product), cred_payload)
3216
+ # 레거시 단일 슬롯 — 아직 product 별 조회로 이관되지 않은 소비처를 위해 유지한다.
3217
+ context.set_secret("credential_id", cred_payload)
3164
3218
  context.log("info", f"Broker credentials stored (paper_trading={paper_trading})", node_id)
3165
3219
  else:
3166
3220
  context.log("warning", "No credentials found - some features may not work", node_id)
@@ -3255,14 +3309,15 @@ class BrokerNodeExecutor(NodeExecutorBase):
3255
3309
  context=context,
3256
3310
  )
3257
3311
 
3312
+ # 🔐 `connection` 은 노드 출력이라 리스너(SSE) · get_state · 체크포인트로 외부에 나간다.
3313
+ # 자격증명은 싣지 않는다 — 하류는 `product` 로 시크릿 저장소에서 꺼낸다
3314
+ # (`_resolve_broker_credentials`). 여기 있던 평문 appkey/appsecret 이 실제로 새고 있었다.
3258
3315
  return {
3259
3316
  "connected": True,
3260
3317
  "connection": {
3261
3318
  "provider": provider,
3262
3319
  "product": product,
3263
3320
  "paper_trading": paper_trading,
3264
- "appkey": appkey,
3265
- "appsecret": appsecret,
3266
3321
  }
3267
3322
  }
3268
3323
 
@@ -4406,7 +4461,11 @@ class AccountNodeExecutor(NodeExecutorBase):
4406
4461
  "purchase_amount": item.FcurrBuyAmt,
4407
4462
  })
4408
4463
 
4409
- held_symbols = [p["symbol"] for p in positions]
4464
+ # held_symbols 선언된 출력 포트다. 예전엔 여기서 계산만 하고 **반환 dict 에 싣지
4465
+ # 않아** 늘 비어 있었다 — 바인딩하면 정적 검증은 통과하고 런타임엔 None 이었다.
4466
+ held_symbols = [
4467
+ {"exchange": p.get("exchange", ""), "symbol": p["symbol"]} for p in positions
4468
+ ]
4410
4469
 
4411
4470
  # block2 = 전체 평가 요약. `orderable_amount` is set later by
4412
4471
  # COSOQ02701; pre-seed it as None so consumers can distinguish
@@ -4469,6 +4528,7 @@ class AccountNodeExecutor(NodeExecutorBase):
4469
4528
 
4470
4529
  context.log("info", f"AccountNode: {len(positions)} positions fetched", node_id)
4471
4530
  return {
4531
+ "held_symbols": held_symbols,
4472
4532
  "positions": positions,
4473
4533
  "balance": balance_info,
4474
4534
  }
@@ -4590,6 +4650,10 @@ class AccountNodeExecutor(NodeExecutorBase):
4590
4650
 
4591
4651
  context.log("info", f"AccountNode (korea_stock): {len(positions)} positions fetched", node_id)
4592
4652
  return {
4653
+ "held_symbols": [
4654
+ {"exchange": p.get("exchange", "KRX"), "symbol": p["symbol"]}
4655
+ for p in positions
4656
+ ],
4593
4657
  "positions": positions,
4594
4658
  "balance": balance_info,
4595
4659
  }
@@ -4656,7 +4720,7 @@ class AccountNodeExecutor(NodeExecutorBase):
4656
4720
  "pnl_amount": float(item.AbrdFutsEvalPnlAmt) if item.AbrdFutsEvalPnlAmt else 0.0,
4657
4721
  "currency": item.CrcyCodeVal.strip() if item.CrcyCodeVal else "USD",
4658
4722
  })
4659
- held_symbols.append(symbol)
4723
+ held_symbols.append({"exchange": "HKEX", "symbol": symbol})
4660
4724
 
4661
4725
  # 2. CIDBQ05300: 예탁자산 조회 (CIDBQ03000 상위호환).
4662
4726
  # Wrapped in its own try so a balance failure no longer drops
@@ -4738,6 +4802,7 @@ class AccountNodeExecutor(NodeExecutorBase):
4738
4802
 
4739
4803
  context.log("info", f"AccountNode (futures): {len(positions)} positions, {len(balance_by_currency)} currencies", node_id)
4740
4804
  return {
4805
+ "held_symbols": held_symbols,
4741
4806
  "positions": positions,
4742
4807
  "balance": balance_info,
4743
4808
  }
@@ -4754,6 +4819,7 @@ class AccountNodeExecutor(NodeExecutorBase):
4754
4819
  other consumers can refuse to coerce missing balances to 0.0.
4755
4820
  """
4756
4821
  result: Dict[str, Any] = {
4822
+ "held_symbols": [],
4757
4823
  "positions": [],
4758
4824
  "balance": {
4759
4825
  "cash": 0.0,
@@ -5297,7 +5363,9 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5297
5363
  serialized_positions.append({
5298
5364
  "symbol": sym,
5299
5365
  "exchange": getattr(pos, 'market_code', 'NASDAQ'),
5366
+ "market_code": getattr(pos, 'market_code', ''),
5300
5367
  "name": getattr(pos, 'symbol_name', sym),
5368
+ "qty": quantity,
5301
5369
  "quantity": quantity, # NewOrderNode 호환
5302
5370
  "price": current_price, # NewOrderNode 호환
5303
5371
  "avg_price": float(pos.buy_price),
@@ -5305,6 +5373,7 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5305
5373
  "pnl_rate": float(pos.pnl_rate) if pos.pnl_rate else 0,
5306
5374
  "pnl_amount": float(pos.pnl_amount) if pos.pnl_amount else 0,
5307
5375
  "currency": getattr(pos, 'currency_code', 'USD'),
5376
+ "eval_amount": float(pos.eval_amount) if pos.eval_amount else 0,
5308
5377
  "product": "overseas_stock", # 상품 유형
5309
5378
  })
5310
5379
 
@@ -5683,13 +5752,17 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5683
5752
  serialized_positions.append({
5684
5753
  "symbol": sym,
5685
5754
  "exchange": "KRX",
5755
+ "market_code": getattr(pos, 'market', ''),
5686
5756
  "name": getattr(pos, 'symbol_name', sym),
5757
+ "qty": quantity,
5687
5758
  "quantity": quantity,
5688
5759
  "price": current_price,
5689
5760
  "avg_price": float(pos.buy_price),
5690
5761
  "current_price": current_price,
5691
5762
  "pnl_rate": float(pos.pnl_rate) if pos.pnl_rate else 0,
5692
5763
  "pnl_amount": float(pos.pnl_amount) if pos.pnl_amount else 0,
5764
+ "currency": "KRW",
5765
+ "eval_amount": float(pos.eval_amount) if pos.eval_amount else 0,
5693
5766
  "product": "korea_stock",
5694
5767
  })
5695
5768
 
@@ -5753,20 +5826,31 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5753
5826
  """해외주식 Tracker에서 현재 데이터 추출 (리스트 형태로 반환)"""
5754
5827
  positions = []
5755
5828
  for symbol, pos in tracker.get_positions().items():
5829
+ quantity = pos.quantity
5830
+ current_price = float(pos.current_price)
5756
5831
  positions.append({
5757
5832
  "symbol": symbol,
5758
5833
  "name": getattr(pos, 'name', getattr(pos, 'symbol_name', symbol)),
5759
- "qty": pos.quantity,
5834
+ # REST 스냅샷 갈래(_ls_stock_with_tracker)와 **같은 키 집합**이어야 한다.
5835
+ # 안 그러면 같은 포트가 스냅샷일 땐 exchange/quantity 를 주고 실시간 틱일 땐
5836
+ # 안 주는, 시점마다 모양이 바뀌는 출력이 된다 (주문 노드가 조용히 깨진다).
5837
+ "exchange": getattr(pos, 'market_code', 'NASDAQ'),
5838
+ "market_code": getattr(pos, 'market_code', ''),
5839
+ "qty": quantity,
5840
+ "quantity": quantity, # NewOrderNode 호환
5841
+ "price": current_price, # NewOrderNode 호환
5760
5842
  "avg_price": float(pos.buy_price),
5761
- "current_price": float(pos.current_price),
5843
+ "current_price": current_price,
5762
5844
  "pnl_rate": float(pos.pnl_rate) if pos.pnl_rate else 0,
5763
5845
  "pnl_amount": float(pos.pnl_amount) if pos.pnl_amount else 0,
5764
5846
  "currency": getattr(pos, 'currency_code', 'USD'),
5765
5847
  "eval_amount": float(pos.eval_amount) if pos.eval_amount else 0,
5766
- "market_code": getattr(pos, 'market_code', ''),
5848
+ "product": "overseas_stock",
5767
5849
  })
5768
5850
 
5769
- symbols = [p["symbol"] for p in positions]
5851
+ held_symbols = [
5852
+ {"exchange": p["exchange"], "symbol": p["symbol"]} for p in positions
5853
+ ]
5770
5854
 
5771
5855
  # balance를 JSON 직렬화 가능한 형태로 변환
5772
5856
  # get_balances()는 Dict[str, StockBalanceInfo]를 반환 (통화별)
@@ -5823,6 +5907,7 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5823
5907
  }
5824
5908
 
5825
5909
  return {
5910
+ "held_symbols": held_symbols,
5826
5911
  "positions": positions,
5827
5912
  "balance": balance,
5828
5913
  "open_orders": open_orders,
@@ -5856,8 +5941,11 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5856
5941
  "name": getattr(pos, 'symbol_name', symbol),
5857
5942
  "direction": "long" if is_long else "short",
5858
5943
  "close_side": "sell" if is_long else "buy",
5944
+ # REST 스냅샷 갈래(_ls_futureoption_with_tracker)와 같은 키 집합
5945
+ "qty": quantity,
5859
5946
  "quantity": quantity, # qty → quantity (NewOrderNode 호환)
5860
5947
  "price": current_price, # current_price → price (NewOrderNode 호환)
5948
+ "product": "overseas_futures",
5861
5949
  "entry_price": float(getattr(pos, 'entry_price', 0)),
5862
5950
  "current_price": current_price,
5863
5951
  "pnl_amount": float(getattr(pos, 'pnl_amount', 0) or 0),
@@ -5900,6 +5988,10 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5900
5988
  }
5901
5989
 
5902
5990
  return {
5991
+ "held_symbols": [
5992
+ {"exchange": pos.get("exchange", ""), "symbol": pos["symbol"]}
5993
+ for pos in positions
5994
+ ],
5903
5995
  "positions": positions,
5904
5996
  "balance": balance,
5905
5997
  "open_orders": open_orders,
@@ -5909,17 +6001,24 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5909
6001
  """국내주식 KrStockAccountTracker에서 현재 데이터 추출"""
5910
6002
  positions = []
5911
6003
  for symbol, pos in tracker.get_positions().items():
6004
+ quantity = pos.quantity
6005
+ current_price = float(pos.current_price)
5912
6006
  positions.append({
5913
6007
  "symbol": symbol,
5914
6008
  "name": getattr(pos, 'symbol_name', symbol),
5915
- "qty": pos.quantity,
6009
+ # REST 스냅샷 갈래(_ls_korea_stock_with_tracker)와 같은 키 집합
6010
+ "exchange": "KRX",
6011
+ "market_code": getattr(pos, 'market', ''),
6012
+ "qty": quantity,
6013
+ "quantity": quantity, # NewOrderNode 호환
6014
+ "price": current_price, # NewOrderNode 호환
5916
6015
  "avg_price": float(pos.buy_price),
5917
- "current_price": float(pos.current_price),
6016
+ "current_price": current_price,
5918
6017
  "pnl_rate": pos.pnl_rate,
5919
6018
  "pnl_amount": float(pos.pnl_amount),
5920
6019
  "currency": "KRW",
5921
6020
  "eval_amount": float(pos.eval_amount),
5922
- "market_code": getattr(pos, 'market', ''),
6021
+ "product": "korea_stock",
5923
6022
  })
5924
6023
 
5925
6024
  # KrStockAccountTracker.get_balance() → KrStockBalanceInfo (단일 객체)
@@ -5954,6 +6053,10 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5954
6053
  }
5955
6054
 
5956
6055
  return {
6056
+ "held_symbols": [
6057
+ {"exchange": pos.get("exchange", ""), "symbol": pos["symbol"]}
6058
+ for pos in positions
6059
+ ],
5957
6060
  "positions": positions,
5958
6061
  "balance": balance,
5959
6062
  "open_orders": open_orders,
@@ -5977,6 +6080,7 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5977
6080
  consumers do not silently treat the unavailable balance as 0.
5978
6081
  """
5979
6082
  result: Dict[str, Any] = {
6083
+ "held_symbols": [],
5980
6084
  "positions": [],
5981
6085
  "balance": {
5982
6086
  "cash": 0.0,
@@ -6122,9 +6226,8 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6122
6226
  import asyncio
6123
6227
  from datetime import datetime
6124
6228
 
6125
- appkey = broker_connection.get("appkey", "")
6126
- appsecret = broker_connection.get("appsecret", "")
6127
- paper_trading = broker_connection.get("paper_trading", False)
6229
+ # 🔐 자격증명은 시크릿 저장소에서 — `connection` 출력에는 더 이상 싣지 않는다.
6230
+ appkey, appsecret, paper_trading = _resolve_broker_credentials(broker_connection, context)
6128
6231
 
6129
6232
  # 현재 이벤트 루프 캡처 (콜백에서 사용)
6130
6233
  loop = asyncio.get_running_loop()
@@ -6290,9 +6393,8 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6290
6393
  import asyncio
6291
6394
  from datetime import datetime
6292
6395
 
6293
- appkey = broker_connection.get("appkey", "")
6294
- appsecret = broker_connection.get("appsecret", "")
6295
- paper_trading = broker_connection.get("paper_trading", False)
6396
+ # 🔐 자격증명은 시크릿 저장소에서 — `connection` 출력에는 더 이상 싣지 않는다.
6397
+ appkey, appsecret, paper_trading = _resolve_broker_credentials(broker_connection, context)
6296
6398
 
6297
6399
  # 현재 이벤트 루프 캡처 (콜백에서 사용)
6298
6400
  loop = asyncio.get_running_loop()
@@ -6465,8 +6567,8 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6465
6567
  import asyncio
6466
6568
  from datetime import datetime
6467
6569
 
6468
- appkey = broker_connection.get("appkey", "")
6469
- appsecret = broker_connection.get("appsecret", "")
6570
+ # 🔐 자격증명은 시크릿 저장소에서 — `connection` 출력에는 더 이상 싣지 않는다.
6571
+ appkey, appsecret, _paper = _resolve_broker_credentials(broker_connection, context)
6470
6572
 
6471
6573
  loop = asyncio.get_running_loop()
6472
6574
 
@@ -6629,43 +6731,6 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6629
6731
 
6630
6732
  return []
6631
6733
 
6632
- def _build_full_data(
6633
- self,
6634
- symbols_raw: List[Dict[str, str]],
6635
- prices: Dict[str, float],
6636
- volumes: Dict[str, int],
6637
- bids: Dict[str, float],
6638
- asks: Dict[str, float],
6639
- ) -> Dict[str, Dict[str, Any]]:
6640
- """
6641
- 종목별 전체 시세 데이터 구조 생성
6642
-
6643
- Returns:
6644
- {
6645
- "AAPL": {"symbol": "AAPL", "exchange": "NASDAQ", "price": 192.30, ...},
6646
- "TSLA": {...},
6647
- }
6648
- """
6649
- data = {}
6650
- for entry in symbols_raw:
6651
- if isinstance(entry, dict):
6652
- symbol = entry.get("symbol", "")
6653
- exchange = entry.get("exchange", "")
6654
- else:
6655
- symbol = entry
6656
- exchange = ""
6657
-
6658
- if symbol:
6659
- data[symbol] = {
6660
- "symbol": symbol,
6661
- "exchange": exchange,
6662
- "price": prices.get(symbol),
6663
- "volume": volumes.get(symbol),
6664
- "bid": bids.get(symbol),
6665
- "ask": asks.get(symbol),
6666
- }
6667
- return data
6668
-
6669
6734
 
6670
6735
  class RealOrderEventNodeExecutor(NodeExecutorBase):
6671
6736
  """
@@ -7539,9 +7604,9 @@ class MarketStatusNodeExecutor(NodeExecutorBase):
7539
7604
  )
7540
7605
 
7541
7606
  # 2. LS 로그인 (broker 와 동일 instance 재사용)
7542
- appkey = broker_connection.get("appkey", "")
7543
- appsecret = broker_connection.get("appsecret", "")
7544
- paper_trading = bool(broker_connection.get("paper_trading", False))
7607
+ # 🔐 자격증명은 시크릿 저장소에서 — `connection` 출력에는 더 이상 싣지 않는다.
7608
+ appkey, appsecret, paper_trading = _resolve_broker_credentials(broker_connection, context)
7609
+ paper_trading = bool(paper_trading)
7545
7610
  broker_product = broker_connection.get("product", "overseas_stock")
7546
7611
 
7547
7612
  ls, success, error = ensure_ls_login(
@@ -9297,6 +9362,36 @@ class IfNodeExecutor(NodeExecutorBase):
9297
9362
  return False
9298
9363
 
9299
9364
 
9365
+ # 전일대비(delta)의 부호 규칙은 LS TR 마다 다르다 — 실측으로만 확정된다 (2026-07-13):
9366
+ # g3101 (해외주식) : diff = 절댓값, 부호는 sign 에 별도 → NVDA sign='5' diff='2.41' rate=-1.14
9367
+ # t1102 (국내주식) : change = 절댓값, 부호는 sign 에 별도 → 삼성 sign='5' change=30500 diff=-10.70
9368
+ # o3105 (해외선물) : YdiffP = 이미 부호 포함(문서의 "Absolute" 는 오기) → HBIN26 sign='5' YdiffP=-105.0
9369
+ # 등락률(%)은 세 TR 모두 부호를 갖는다.
9370
+ #
9371
+ # 그래서 "무조건 abs() 후 sign 적용"은 위험하다 — o3105 처럼 이미 음수인 값을 양수로 뒤집는다.
9372
+ # 방향 코드가 명확할 때만 크기를 그 방향으로 맞추고, 불명이면 원값의 부호를 신뢰한다.
9373
+ _LS_DOWN_SIGNS = ("4", "5", "-") # 4=하한, 5=하락
9374
+ _LS_UP_SIGNS = ("1", "2", "+") # 1=상한, 2=상승 (3=보합 → 값이 0 이라 통과시켜도 무해)
9375
+
9376
+
9377
+ def _ls_signed_change(magnitude: Any, sign: Any) -> float:
9378
+ """LS 전일대비(delta)를 방향 코드에 맞춰 부호 있는 값으로 정규화한다.
9379
+
9380
+ 멱등하다 — 이미 부호가 실려 온 값(o3105)에 다시 적용해도 결과가 바뀌지 않는다.
9381
+ 방향 코드가 비었거나 알 수 없으면 **원값을 그대로** 둔다(멋대로 양수로 만들지 않는다).
9382
+ """
9383
+ try:
9384
+ v = float(magnitude or 0)
9385
+ except (TypeError, ValueError):
9386
+ return 0.0
9387
+ s = str(sign or "").strip()
9388
+ if s in _LS_DOWN_SIGNS:
9389
+ return -abs(v)
9390
+ if s in _LS_UP_SIGNS:
9391
+ return abs(v)
9392
+ return v
9393
+
9394
+
9300
9395
  class MarketDataNodeExecutor(NodeExecutorBase):
9301
9396
  """
9302
9397
  MarketDataNode executor - REST API 현재가 조회 (당일 데이터만)
@@ -9509,7 +9604,8 @@ class MarketDataNodeExecutor(NodeExecutorBase):
9509
9604
  "symbol": symbol,
9510
9605
  "exchange": resolved_exchange,
9511
9606
  "price": float(out_block.price or 0),
9512
- "change": float(out_block.diff or 0),
9607
+ # g3101 `diff` 절댓값 — 부호는 `sign` 에 따로 온다.
9608
+ "change": _ls_signed_change(out_block.diff, getattr(out_block, "sign", "")),
9513
9609
  "change_pct": float(out_block.rate or 0),
9514
9610
  "volume": int(out_block.volume or 0),
9515
9611
  "open": float(out_block.open or 0),
@@ -9594,7 +9690,9 @@ class MarketDataNodeExecutor(NodeExecutorBase):
9594
9690
  "exchange": exchange,
9595
9691
  "symbol_name": out_block.SymbolNm or symbol,
9596
9692
  "price": float(out_block.TrdP or 0),
9597
- "change": float(out_block.YdiffP or 0),
9693
+ # o3105 `YdiffP` 는 이미 부호를 포함한다(실측). 헬퍼는 멱등이라 값이 안 바뀌며,
9694
+ # LS 가 언젠가 절댓값으로 바꿔 보내도 `YdiffSign` 으로 방어된다.
9695
+ "change": _ls_signed_change(out_block.YdiffP, getattr(out_block, "YdiffSign", "")),
9598
9696
  "change_pct": float(out_block.Diff or 0),
9599
9697
  "volume": int(out_block.TotQ or 0),
9600
9698
  "open": float(out_block.OpenP or 0),
@@ -9667,9 +9765,9 @@ class MarketDataNodeExecutor(NodeExecutorBase):
9667
9765
  blk = response.block
9668
9766
 
9669
9767
  # sign: 1=상한/2=상승 → 양수, 4=하한/5=하락 → 음수
9670
- change_val = int(blk.change or 0)
9671
- if blk.sign in ("4", "5"):
9672
- change_val = -change_val
9768
+ # 국내 현재가 `change` 절댓값 — 부호는 `sign` 에 따로 온다 (해외와 동일 규칙).
9769
+ # 국내 호가는 단위 정수라 기존 int 출력 타입을 유지한다.
9770
+ change_val = int(_ls_signed_change(blk.change, getattr(blk, "sign", "")))
9673
9771
 
9674
9772
  values.append({
9675
9773
  "symbol": symbol,
@@ -19279,7 +19377,9 @@ class WorkflowJob:
19279
19377
  entry: Dict[str, Any] = {
19280
19378
  "state": cached_state.value,
19281
19379
  "node_type": node.node_type,
19282
- "outputs": self.context.get_all_outputs(node_id),
19380
+ # 🔐 외부 노출면 — BrokerNode 는 하류 전송용으로 connection.appkey/appsecret 을
19381
+ # 출력에 싣는다. 리스너 경로는 이미 가리는데 여기만 raw 로 새고 있었다.
19382
+ "outputs": self.context.get_all_outputs_sanitized(node_id),
19283
19383
  }
19284
19384
  if node_id in self._node_errors:
19285
19385
  entry["error"] = self._node_errors[node_id]
@@ -326,6 +326,9 @@ class WorkflowResolver:
326
326
  # 10.7 AIAgentNode ai_model 엣지 필수 검증 (LLM 없이는 애초에 동작 불가)
327
327
  self._validate_ai_agent_nodes(workflow, result)
328
328
 
329
+ # 10.8 Display 노드 columns 키가 상류 출력 스키마에 실재하는지 (없는 키는 표에 '-' 만 찍힌다)
330
+ self._validate_display_columns(workflow, registry, result)
331
+
329
332
  # 11. Static recommendations (topology analysis)
330
333
  for rec in run_static_recommendation_rules(
331
334
  workflow,
@@ -1279,6 +1282,124 @@ class WorkflowResolver:
1279
1282
  )
1280
1283
  )
1281
1284
 
1285
+ def _validate_display_columns(self, workflow, registry, result: ValidationResult) -> None:
1286
+ """Display 노드의 `columns` 키가 상류 출력 스키마에 실재하는지 검증한다.
1287
+
1288
+ `columns` 는 `{{ }}` 바인딩이 아니라 **평범한 문자열 목록**이라 표현식 검증
1289
+ (`_validate_expression_references`)의 사정권 밖이었다. 그래서 상류가 내보내지 않는
1290
+ 이름을 적어도 아무도 막지 않았고, 표는 그 칸에 조용히 `-` 만 찍은 채 워크플로우는
1291
+ `completed / errors=0` 으로 보고했다.
1292
+ (실측 2026-07-13: 챗봇이 `current_price`/`change_percent` 로 저작 → 시세 노드는
1293
+ `price`/`change_pct` 를 내보냄 → 사용자가 요청한 '현재가'가 표에서 사라졌다.)
1294
+
1295
+ 🔴 **오탐 0 원칙 — 검증된 포트에만 건다.**
1296
+ `fields` 를 선언했다는 것만으로는 부족하다. 예전엔 라이브러리 곳곳의 선언이 런타임보다
1297
+ **불완전**했다(예: `ScreenerNode.symbols` 런타임은 price/market_cap/volume/sector 를
1298
+ 담는데 공유 상수 `SYMBOL_LIST_FIELDS` 는 exchange/symbol 둘만 선언 — 2026-07-14 노드별
1299
+ 상수로 쪼개서 해소). 증명 안 된 포트에 이 가드를 걸면 **정상 워크플로우를 대량 오탐**한다.
1300
+
1301
+ 그래서 `tests/test_output_schema_contract.py` 로 **선언 == 런타임** 이 증명된 포트만
1302
+ 여기 등록한다. 다른 포트의 선언을 바로잡을 때마다 계약 검사에 추가하고 이 목록을 넓힌다.
1303
+ """
1304
+ import re as _re
1305
+
1306
+ # (node_type, port) — 계약 검사로 선언 == 런타임 이 증명된 포트만.
1307
+ # 증명 위치: `tests/test_output_schema_contract.py::CASES`
1308
+ # 🔴 실시간 시세의 `ohlcv_data` / `data` 는 **일부러 뺐다** — 값이 `{종목: [봉]}` dict 라
1309
+ # 표의 열(column)이 봉 필드인지 종목코드인지 정해져 있지 않다. 근거 없이 걸면 오탐이다.
1310
+ VERIFIED_PORTS = {
1311
+ ("OverseasStockMarketDataNode", "value"),
1312
+ ("OverseasStockMarketDataNode", "values"),
1313
+ ("OverseasFuturesMarketDataNode", "value"),
1314
+ ("OverseasFuturesMarketDataNode", "values"),
1315
+ ("KoreaStockMarketDataNode", "value"),
1316
+ ("KoreaStockMarketDataNode", "values"),
1317
+ # 종목 리스트
1318
+ ("WatchlistNode", "symbols"),
1319
+ ("MarketUniverseNode", "symbols"),
1320
+ ("ScreenerNode", "symbols"),
1321
+ ("OverseasStockSymbolQueryNode", "symbols"),
1322
+ ("KoreaStockSymbolQueryNode", "symbols"),
1323
+ ("OverseasFuturesSymbolQueryNode", "symbols"),
1324
+ # 계좌 (REST)
1325
+ ("OverseasStockAccountNode", "positions"),
1326
+ ("KoreaStockAccountNode", "positions"),
1327
+ ("OverseasFuturesAccountNode", "positions"),
1328
+ ("OverseasStockAccountNode", "held_symbols"),
1329
+ ("KoreaStockAccountNode", "held_symbols"),
1330
+ ("OverseasFuturesAccountNode", "held_symbols"),
1331
+ # 계좌 (실시간)
1332
+ ("OverseasStockRealAccountNode", "positions"),
1333
+ ("KoreaStockRealAccountNode", "positions"),
1334
+ ("OverseasFuturesRealAccountNode", "positions"),
1335
+ ("OverseasStockRealAccountNode", "held_symbols"),
1336
+ ("KoreaStockRealAccountNode", "held_symbols"),
1337
+ ("OverseasFuturesRealAccountNode", "held_symbols"),
1338
+ }
1339
+
1340
+ DISPLAY_TYPES = {"TableDisplayNode", "ChartDisplayNode"}
1341
+ nodes_by_id = {
1342
+ n.get("id"): n for n in workflow.nodes
1343
+ if isinstance(n, dict) and n.get("id")
1344
+ }
1345
+
1346
+ for node in workflow.nodes:
1347
+ if not isinstance(node, dict) or node.get("type") not in DISPLAY_TYPES:
1348
+ continue
1349
+ columns = node.get("columns")
1350
+ data_expr = node.get("data")
1351
+ if not columns or not isinstance(columns, list) or not isinstance(data_expr, str):
1352
+ continue
1353
+
1354
+ m = _re.search(r"nodes\.([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)", data_expr)
1355
+ if not m:
1356
+ continue
1357
+ src_id, port = m.group(1), m.group(2)
1358
+ src_node = nodes_by_id.get(src_id)
1359
+ if not src_node:
1360
+ continue # 노드 부재는 _validate_expression_references 가 잡는다
1361
+ if (src_node.get("type"), port) not in VERIFIED_PORTS:
1362
+ continue # 선언이 런타임과 일치한다고 증명되지 않은 포트 → 검사하지 않는다
1363
+
1364
+ schema = registry.get_schema(src_node.get("type"))
1365
+ if not schema:
1366
+ continue
1367
+ declared: Set[str] = set()
1368
+ for out in (getattr(schema, "outputs", None) or []):
1369
+ nm = out.get("name") if isinstance(out, dict) else getattr(out, "name", None)
1370
+ if nm != port:
1371
+ continue
1372
+ fields = out.get("fields") if isinstance(out, dict) else getattr(out, "fields", None)
1373
+ for f in (fields or []):
1374
+ fn = f.get("name") if isinstance(f, dict) else getattr(f, "name", None)
1375
+ if fn:
1376
+ declared.add(fn)
1377
+ break
1378
+ if not declared:
1379
+ continue # 출력 모양이 열려 있음 → 검사 근거 없음 (오탐 방지)
1380
+
1381
+ for col in columns:
1382
+ key = col.get("key") if isinstance(col, dict) else col
1383
+ if not isinstance(key, str) or not key or key in declared:
1384
+ continue
1385
+ result.add(
1386
+ build_error(
1387
+ ErrorCode.INVALID_EXPRESSION_REF,
1388
+ f"{node.get('type')} '{node.get('id')}' lists column '{key}', but node "
1389
+ f"'{src_id}' does not output a field with that name on port '{port}'. "
1390
+ f"The column would render as '-' while the workflow still reports success.",
1391
+ location=ErrorLocation(
1392
+ node_id=node.get("id"),
1393
+ node_type=node.get("type"),
1394
+ field_path="columns",
1395
+ ),
1396
+ suggestion=(
1397
+ f"Use one of the fields '{src_id}.{port}' actually outputs: "
1398
+ f"{', '.join(sorted(declared))}."
1399
+ ),
1400
+ )
1401
+ )
1402
+
1282
1403
  def _validate_credential_references(
1283
1404
  self,
1284
1405
  workflow,
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.27.0"
8
+ version = "1.28.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -29,7 +29,7 @@ lxml = "^6.0.2"
29
29
  pytickersymbols = {version = ">=1.17.5", python = ">=3.12,<4.0"}
30
30
  aiosqlite = "^0.20.0"
31
31
  litellm = ">=1.40.0"
32
- programgarden-core = "^1.18.0"
32
+ programgarden-core = "^1.20.0"
33
33
  programgarden-finance = "^1.6.14"
34
34
  programgarden-community = "^1.13.11"
35
35
 
File without changes