programgarden 1.25.0__tar.gz → 1.26.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.25.0 → programgarden-1.26.0}/PKG-INFO +2 -2
  2. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/deep_fixtures.py +57 -0
  3. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/executor.py +516 -90
  4. {programgarden-1.25.0 → programgarden-1.26.0}/pyproject.toml +2 -2
  5. {programgarden-1.25.0 → programgarden-1.26.0}/README.md +0 -0
  6. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/__init__.py +0 -0
  7. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/binding_validator.py +0 -0
  8. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/client.py +0 -0
  9. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/code_worker.py +0 -0
  10. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/context.py +0 -0
  11. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/__init__.py +0 -0
  12. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/checkpoint_manager.py +0 -0
  13. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/query_builder.py +0 -0
  14. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/workflow_position_tracker.py +0 -0
  15. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  16. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/node_runner.py +0 -0
  17. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/plugin/__init__.py +0 -0
  18. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/plugin/sandbox.py +0 -0
  19. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/providers/__init__.py +0 -0
  20. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/providers/llm_errors.py +0 -0
  21. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/providers/llm_provider.py +0 -0
  22. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/reconnect_handler.py +0 -0
  23. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resolver.py +0 -0
  24. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resource/__init__.py +0 -0
  25. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resource/context.py +0 -0
  26. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resource/limiter.py +0 -0
  27. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resource/monitor.py +0 -0
  28. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resource/throttle.py +0 -0
  29. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/semantic_rules.py +0 -0
  30. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.25.0
3
+ Version: 1.26.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.16.0,<2.0.0)
19
+ Requires-Dist: programgarden-core (>=1.18.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)
@@ -318,6 +318,63 @@ def real_order_event_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
318
318
  }
319
319
 
320
320
 
321
+ def futures_contract_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
322
+ """FuturesContractNode deep fixture (no o3101 master query).
323
+
324
+ Real shape: ``{"symbols": [{exchange, symbol}], "contracts": [...], "count": int}``.
325
+
326
+ The month code is derived from the fixture anchor, not the wall clock, so a deep
327
+ run is reproducible. The *symbol string* is therefore not a real listed contract —
328
+ that is fine and deliberate: deep_validate checks field/type/flow integrity, and
329
+ downstream fixtures key off the symbol string only as an opaque identifier.
330
+ """
331
+ raw = config.get("base_products") or []
332
+ if isinstance(raw, str):
333
+ raw = [p.strip() for p in raw.split(",") if p.strip()]
334
+ products = [str(p).strip().upper() for p in raw if str(p).strip()] or ["HMH"]
335
+
336
+ # 거래소는 ExchCd 로 정규화한다. config 값을 그대로 되뱉으면, 형제 노드의 enum('6')을
337
+ # 넣은 워크플로우가 게이트를 통과한 뒤 첫 라이브 실행에서만 죽는다 — 게이트의 존재 이유가 없어진다.
338
+ _ENUM_TO_EXCHCD = {"1": "", "2": "CME", "3": "SGX", "4": "EUREX", "5": "ICE", "6": "HKEX", "7": "OSE"}
339
+ exchange = str(config.get("futures_exchange") or "").strip().upper()
340
+ exchange = _ENUM_TO_EXCHCD.get(exchange, exchange) or "HKEX"
341
+
342
+ # 월물 문자 코드 (F=1월 … Z=12월) — 실제 노드와 같은 표기를 쓴다.
343
+ # contract_selection 을 실제 노드와 같은 규칙으로 반영한다(front=당월, next=익월,
344
+ # quarterly=3·6·9·12월 중 최근접). fixture 가 이걸 무시하면 세 설정이 같은 심볼을 내
345
+ # 배선 검증이 selection 오류를 못 잡는다.
346
+ month_letters = "FGHJKMNQUVXZ"
347
+ selection = str(config.get("contract_selection") or "front").strip().lower()
348
+ month = _FIXTURE_ANCHOR.month
349
+ if selection == "next":
350
+ month += 1
351
+ elif selection == "quarterly":
352
+ while month % 3:
353
+ month += 1
354
+ year = _FIXTURE_ANCHOR.year + (month - 1) // 12
355
+ month = (month - 1) % 12 + 1
356
+ letter = month_letters[month - 1]
357
+ yy = f"{year % 100:02d}"
358
+
359
+ contracts: List[Dict[str, Any]] = []
360
+ for product in products:
361
+ contracts.append(
362
+ {
363
+ "symbol": f"{product}{letter}{yy}",
364
+ "exchange": exchange,
365
+ "base_product": product,
366
+ "base_product_name": product,
367
+ "name": f"{product}({year}.{month:02d})",
368
+ "contract_month": f"{year:04d}-{month:02d}",
369
+ }
370
+ )
371
+ return {
372
+ "symbols": [{"exchange": c["exchange"], "symbol": c["symbol"]} for c in contracts],
373
+ "contracts": contracts,
374
+ "count": len(contracts),
375
+ }
376
+
377
+
321
378
  def market_status_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
322
379
  """MarketStatusNode deep fixture (markets open).
323
380
 
@@ -1509,20 +1509,27 @@ class MarketUniverseNodeExecutor(NodeExecutorBase):
1509
1509
  MarketUniverseNode executor - 대표지수 종목
1510
1510
 
1511
1511
  ⚠️ 해외주식(overseas_stock) 전용 노드입니다. 해외선물은 지원하지 않습니다.
1512
-
1512
+
1513
+ 거래소 라벨은 반드시 LS 가 아는 미국 원장(NASDAQ/NYSE/AMEX)이어야 한다 —
1514
+ 하류 MarketData/주문 노드의 EXCHANGE_CODES 가 이 라벨로 거래소 코드를 정하고,
1515
+ 모르는 라벨은 조용히 NASDAQ(82)으로 폴백해 종목을 무음 유실시키기 때문이다.
1516
+
1513
1517
  pytickersymbols 라이브러리를 활용하여 미국 대표지수 구성종목을 조회합니다.
1514
1518
  Broker 연결 없이 독립적으로 실행됩니다.
1515
-
1519
+
1516
1520
  지원 인덱스 (LS증권 거래 가능 거래소):
1517
1521
  - NASDAQ 100 (~101개)
1518
1522
  - S&P 500 (~503개)
1519
1523
  - S&P 100
1520
1524
  - DOW JONES (다우존스 30)
1521
-
1525
+
1522
1526
  Note: pytickersymbols는 유럽/아시아 인덱스도 지원하지만,
1523
1527
  LS증권에서 거래 가능한 미국 인덱스만 권장합니다.
1524
1528
  """
1525
-
1529
+
1530
+ # LS 가 조회/주문할 수 있는 미국 원장. google 접두사가 이 안에 있어야 채택한다.
1531
+ US_EXCHANGES = {"NASDAQ", "NYSE", "AMEX"}
1532
+
1526
1533
  # 인덱스별 심볼 캐시 (앱 실행 중 재사용)
1527
1534
  _cache: Dict[str, List[Dict[str, str]]] = {}
1528
1535
  _cache_time: Dict[str, float] = {}
@@ -1623,26 +1630,31 @@ class MarketUniverseNodeExecutor(NodeExecutorBase):
1623
1630
  if not symbol:
1624
1631
  continue
1625
1632
 
1626
- # 거래소 정보 추출 (있으면 사용, 없으면 기본값)
1633
+ # 거래소 결정 `google` 접두사가 실제 상장 거래소다 ("NASDAQ:AMGN", "NYSE:MMM").
1634
+ #
1635
+ # 예전엔 symbols 배열의 **첫 항목**만 보고 yahoo 접미사로 힌트를 뽑은 뒤 break 했다.
1636
+ # pytickersymbols 는 해외 상장분을 첫 자리에 두는 경우가 많아(AMGN → "AMG.F"),
1637
+ # DOW30 30종목 중 29개가 FRA(프랑크푸르트)로 오염됐다. 그리고 하류
1638
+ # EXCHANGE_CODES 는 FRA 를 몰라 조용히 82(NASDAQ)로 폴백 → 진짜 NYSE 종목이
1639
+ # 무음 유실됐다(실측: 예제 08 이 회당 30종목 중 8건만 수신).
1627
1640
  exchange = default_exchange
1628
- stock_symbols = stock.get("symbols", [])
1629
- for sym_info in stock_symbols:
1630
- if sym_info.get("yahoo"):
1631
- # yahoo 심볼에서 거래소 힌트 추출 가능
1632
- yahoo_sym = sym_info.get("yahoo", "")
1633
- if "." in yahoo_sym:
1634
- # 예: "7203.T" 도쿄 거래소
1635
- suffix = yahoo_sym.split(".")[-1]
1636
- exchange_hints = {
1637
- "T": "TSE", # Tokyo
1638
- "L": "LSE", # London
1639
- "PA": "EPA", # Paris
1640
- "DE": "FRA", # Frankfurt
1641
- "F": "FRA",
1642
- }
1643
- exchange = exchange_hints.get(suffix, exchange)
1641
+ resolved_exchange = ""
1642
+ for sym_info in (stock.get("symbols") or []):
1643
+ google_sym = (sym_info.get("google") or "").strip()
1644
+ if ":" not in google_sym:
1645
+ continue
1646
+ prefix = google_sym.split(":", 1)[0].strip().upper()
1647
+ if prefix not in self.US_EXCHANGES:
1648
+ continue
1649
+ # USD 상장분이 곧 LS 가 조회할 미국 원장이다 — 있으면 최우선.
1650
+ if (sym_info.get("currency") or "").strip().upper() == "USD":
1651
+ resolved_exchange = prefix
1644
1652
  break
1645
-
1653
+ resolved_exchange = resolved_exchange or prefix
1654
+
1655
+ if resolved_exchange:
1656
+ exchange = resolved_exchange
1657
+
1646
1658
  symbols.append({
1647
1659
  "exchange": exchange,
1648
1660
  "symbol": symbol,
@@ -2582,28 +2594,66 @@ class SymbolQueryNodeExecutor(NodeExecutorBase):
2582
2594
  if not success:
2583
2595
  return {"symbols": [], "count": 0, "error": error}
2584
2596
 
2585
- futures_exchange = config.get("futures_exchange", "1") # 1: 전체
2597
+ futures_exchange = str(config.get("futures_exchange", "1") or "1") # 1: 전체
2586
2598
  futures_contract_month = config.get("futures_contract_month", "") # 월물 필터
2587
-
2599
+
2588
2600
  all_symbols = []
2589
-
2601
+
2590
2602
  try:
2603
+ # gubun 은 거래소 필터가 **아니다** — 실측: 0/1/2/6 어느 값이든 같은 전체 목록을 준다.
2604
+ # 거래소 좁히기는 응답의 ExchCd 로 클라이언트에서 한다(아래). gubun 은 "0" 고정.
2591
2605
  query = ls.overseas_futureoption().market().해외선물마스터조회(
2592
- body=o3101.O3101InBlock(gubun=futures_exchange)
2606
+ body=o3101.O3101InBlock(gubun="0")
2593
2607
  )
2594
-
2608
+
2595
2609
  result = await query.req_async()
2596
-
2597
- if result and hasattr(result, 'block') and result.block:
2598
- for item in result.block:
2599
- # 거래소 코드 이름 매핑
2600
- exchcd = getattr(item, 'ExchCd', '')
2601
- exchange_name = getattr(item, 'ExchNm', '') or self._futures_exchcd_to_name(exchcd)
2610
+ rows = list(getattr(result, 'block', None) or [])
2611
+
2612
+ except Exception as e:
2613
+ context.log("error", f"o3101 API error: {str(e)}", node_id)
2614
+ return {"symbols": [], "count": 0, "error": str(e)}
2615
+
2616
+ wanted_exchange = self.FUTURES_EXCHANGE_CODES.get(futures_exchange, futures_exchange)
2617
+ if wanted_exchange in ("1", "", "ALL"):
2618
+ wanted_exchange = "" # 전체
2619
+
2620
+ listed_exchanges = sorted({
2621
+ (getattr(item, 'ExchCd', '') or '').strip().upper() for item in rows
2622
+ } - {""})
2623
+
2624
+ # 요청한 거래소가 이 계좌의 마스터에 아예 없으면 **조용히 빈 배열을 주지 않는다**.
2625
+ # 빈 유니버스는 하류를 전부 no-op 시키고 워크플로우는 '성공'한 척 아무것도 안 한다 —
2626
+ # 이 저장소가 없애려는 바로 그 무음 실패다. 무엇을 쓸 수 있는지 알려주고 실패한다.
2627
+ # (해외선물 권한은 거래소별이라, 이 계좌에 없는 거래소를 고르면 여기서 걸린다.)
2628
+ if wanted_exchange and rows and wanted_exchange not in listed_exchanges:
2629
+ raise RuntimeError(
2630
+ f"OverseasFuturesSymbolQueryNode[{node_id}]: exchange '{wanted_exchange}' "
2631
+ f"(futures_exchange={futures_exchange!r}) has no contracts in this account's LS "
2632
+ f"master. LS currently lists: {', '.join(listed_exchanges) or '(none)'}. "
2633
+ f"Overseas-futures entitlement is per exchange — check the account, or use "
2634
+ f"futures_exchange='1' for all exchanges."
2635
+ )
2636
+
2637
+ try:
2638
+ if rows:
2639
+ for item in rows:
2640
+ exchcd = (getattr(item, 'ExchCd', '') or '').strip().upper()
2641
+ if wanted_exchange and exchcd != wanted_exchange:
2642
+ continue
2643
+
2602
2644
  contract_month = f"{getattr(item, 'LstngYr', '')}{getattr(item, 'LstngM', '')}"
2603
-
2645
+ # 만기 경과 월물 제외 — LS 는 만기 지난 종목에 시세도 과거봉도 주지 않고
2646
+ # 에러도 내지 않는다(빈 배열). 그런 종목이 하류로 새면 워크플로우가 조용히 죽는다.
2647
+ if self._is_expired_contract(getattr(item, 'LstngYr', ''), getattr(item, 'LstngM', '')):
2648
+ continue
2649
+
2604
2650
  all_symbols.append({
2605
- "exchange": exchange_name,
2651
+ # exchange 는 **레지스트리 거래소 코드**(HKEX 등)여야 한다. LS 의 ExchNm 은
2652
+ # 한글 거래소명('홍콩거래소')이라, 그대로 실으면 주문 노드가 그 한글을
2653
+ # ExchCode 파라미터로 전송해 주문이 깨진다. 한글명은 exchange_name 으로 분리.
2654
+ "exchange": exchcd,
2606
2655
  "exchange_code": exchcd,
2656
+ "exchange_name": getattr(item, 'ExchNm', '') or self._futures_exchcd_to_name(exchcd),
2607
2657
  "symbol": getattr(item, 'Symbol', ''),
2608
2658
  "name": getattr(item, 'SymbolNm', ''),
2609
2659
  "base_product": getattr(item, 'BscGdsCd', ''),
@@ -2611,11 +2661,11 @@ class SymbolQueryNodeExecutor(NodeExecutorBase):
2611
2661
  "currency": getattr(item, 'CrncyCd', ''),
2612
2662
  "contract_month": contract_month,
2613
2663
  })
2614
-
2664
+
2615
2665
  # 월물 필터링 적용
2616
2666
  if futures_contract_month:
2617
2667
  all_symbols = self._filter_by_contract_month(all_symbols, futures_contract_month)
2618
-
2668
+
2619
2669
  except Exception as e:
2620
2670
  context.log("error", f"o3101 API error: {str(e)}", node_id)
2621
2671
  return {"symbols": [], "count": 0, "error": str(e)}
@@ -2643,6 +2693,36 @@ class SymbolQueryNodeExecutor(NodeExecutorBase):
2643
2693
  }
2644
2694
  return mapping.get(exchcd, exchcd)
2645
2695
 
2696
+ # 노드 스키마의 거래소 enum(1~7) → o3101 응답의 ExchCd.
2697
+ # o3101 의 gubun 입력은 거래소 필터가 아니므로(실측), 좁히기는 이 표로 클라이언트에서 한다.
2698
+ FUTURES_EXCHANGE_CODES = {
2699
+ "1": "", # 전체
2700
+ "2": "CME",
2701
+ "3": "SGX",
2702
+ "4": "EUREX",
2703
+ "5": "ICE",
2704
+ "6": "HKEX",
2705
+ "7": "OSE",
2706
+ }
2707
+
2708
+ def _is_expired_contract(self, year_raw: str, month_raw: str) -> bool:
2709
+ """만기가 지난 월물인가 (현재 연-월보다 이른가)."""
2710
+ year_raw = (year_raw or "").strip()
2711
+ month_raw = (month_raw or "").strip().upper()
2712
+ if not year_raw or not month_raw:
2713
+ return False # 판정 불가 — 버리지 않는다
2714
+ month = FUTURES_MONTH_CODES.get(month_raw)
2715
+ if month is None and month_raw.isdigit():
2716
+ month = int(month_raw)
2717
+ try:
2718
+ year = int(year_raw)
2719
+ except ValueError:
2720
+ return False
2721
+ if not month or not 1 <= month <= 12:
2722
+ return False
2723
+ now = datetime.now()
2724
+ return (year, month) < (now.year, now.month)
2725
+
2646
2726
  def _futures_exchcd_to_name(self, exchcd: str) -> str:
2647
2727
  """해외선물 거래소 코드 → 이름"""
2648
2728
  mapping = {
@@ -2703,11 +2783,249 @@ class SymbolQueryNodeExecutor(NodeExecutorBase):
2703
2783
  # 월 코드만 (예: "F", "G")
2704
2784
  if len(month_filter) == 1:
2705
2785
  return [s for s in symbols if s.get("contract_month", "").endswith(month_filter)]
2706
-
2786
+
2707
2787
  # 연도+월 코드 (예: "2026F")
2708
2788
  return [s for s in symbols if s.get("contract_month", "") == month_filter]
2709
2789
 
2710
2790
 
2791
+ # 선물 월물 코드 (거래소 공통) — LS o3101 의 LstngM 이 이 문자를 그대로 준다.
2792
+ FUTURES_MONTH_CODES: Dict[str, int] = {
2793
+ "F": 1, "G": 2, "H": 3, "J": 4, "K": 5, "M": 6,
2794
+ "N": 7, "Q": 8, "U": 9, "V": 10, "X": 11, "Z": 12,
2795
+ }
2796
+ # 분기 월물 (3/6/9/12월) — 지수선물의 유동성이 몰리는 월물.
2797
+ QUARTERLY_MONTHS = (3, 6, 9, 12)
2798
+
2799
+
2800
+ class FuturesContractNodeExecutor(NodeExecutorBase):
2801
+ """FuturesContractNode executor — 기초자산 → **현재 상장 월물** 해소 (o3101).
2802
+
2803
+ 워크플로우가 월물 종목코드(HMHU26)를 하드코딩하면 만기가 지나는 순간 조용히 죽는다.
2804
+ LS 는 만기 경과 종목에 과거봉도 현재가도 주지 않고 **에러도 내지 않기** 때문이다(빈 배열).
2805
+ 이 노드는 저작 시점이 아니라 실행 시점에 o3101 마스터를 조회해 살아있는 월물만 고른다.
2806
+
2807
+ 출력 `symbols` 는 WatchlistNode 와 동일한 [{exchange, symbol}] 계약이라
2808
+ 하류(historical/market/condition/order) 배선이 그대로 유지된다.
2809
+ """
2810
+
2811
+ async def execute(
2812
+ self,
2813
+ node_id: str,
2814
+ node_type: str,
2815
+ config: Dict[str, Any],
2816
+ context: ExecutionContext,
2817
+ **kwargs,
2818
+ ) -> Dict[str, Any]:
2819
+ from programgarden_finance import LS, o3101 # noqa: F401 (LS 는 ensure_ls_login 이 사용)
2820
+
2821
+ base_products = config.get("base_products") or []
2822
+ if isinstance(base_products, str):
2823
+ # LLM Tool 경로에서 JSON 문자열/콤마 문자열로 오는 경우 방어
2824
+ import json as _json
2825
+ try:
2826
+ parsed = _json.loads(base_products)
2827
+ base_products = parsed if isinstance(parsed, list) else [base_products]
2828
+ except (ValueError, _json.JSONDecodeError):
2829
+ base_products = [p.strip() for p in base_products.split(",") if p.strip()]
2830
+ base_products = [str(p).strip().upper() for p in base_products if str(p).strip()]
2831
+
2832
+ selection = str(config.get("contract_selection") or "front").strip().lower()
2833
+ # 거래소는 ExchCd('HKEX')로 받는다. 다만 형제 노드(OverseasFuturesSymbolQueryNode)는
2834
+ # 같은 이름의 필드를 enum('6'=HKEX)으로 쓴다 — LLM/사용자가 그 값을 그대로 넣어도
2835
+ # 죽지 않도록 여기서 흡수한다.
2836
+ exchange_filter = (config.get("futures_exchange") or "").strip().upper()
2837
+ exchange_filter = SymbolQueryNodeExecutor.FUTURES_EXCHANGE_CODES.get(
2838
+ exchange_filter, exchange_filter
2839
+ )
2840
+ if exchange_filter in ("1", "ALL"):
2841
+ exchange_filter = "" # 전체
2842
+
2843
+ if not base_products:
2844
+ raise RuntimeError(
2845
+ f"FuturesContractNode[{node_id}]: `base_products` is empty. Give at least one "
2846
+ f"underlying code (e.g. ['HMH'] = Mini Hang Seng, ['HMCE'] = Mini H-Shares)."
2847
+ )
2848
+
2849
+ # deep_validate / dry_run: 브로커를 건드리지 않는다. 스키마 형태의 fixture 로
2850
+ # 하류를 흐르게 해서 배선·타입 검증이 끝까지 진행되게 한다.
2851
+ if context.is_deep_validate or context.is_dry_run:
2852
+ from programgarden import deep_fixtures as _df
2853
+ fixture = _df.futures_contract_fixture(config)
2854
+ override = context.get_deep_fixture(node_id, node_type)
2855
+ if context.is_dry_run and not context.is_deep_validate:
2856
+ context.log(
2857
+ "info",
2858
+ f"[dry_run] {node_type} simulated (no o3101 contract-master query)",
2859
+ node_id,
2860
+ )
2861
+ return _df.apply_override(fixture, override)
2862
+
2863
+ cred = context.get_credential("credential_id")
2864
+ appkey = (cred or {}).get("appkey", "")
2865
+ appsecret = (cred or {}).get("appsecret", "")
2866
+ paper_trading = (cred or {}).get("paper_trading", False)
2867
+ if not appkey or not appsecret:
2868
+ raise RuntimeError(
2869
+ f"FuturesContractNode[{node_id}]: no broker credential. This node resolves the live "
2870
+ f"contract month through the LS contract-master query (o3101), which needs a broker "
2871
+ f"session — wire an OverseasFuturesBrokerNode upstream."
2872
+ )
2873
+
2874
+ ls, success, error = ensure_ls_login(
2875
+ appkey, appsecret, paper_trading, context, node_id, "overseas_futures"
2876
+ )
2877
+ if not success:
2878
+ raise RuntimeError(f"FuturesContractNode[{node_id}]: LS login failed: {error}")
2879
+
2880
+ # LS 는 같은 앱키로 요청이 몰리면 o3101 에 **빈 블록**을 돌려준다(에러가 아니라 빈 배열).
2881
+ # 그 한 번에 워크플로우를 죽이면, 실제로는 멀쩡한 전략이 스케줄 한 틱을 통째로 날린다.
2882
+ # 짧게 몇 번 다시 물어보고, 그래도 비어 있을 때만 (진짜 권한/장애로 보고) 크게 실패한다.
2883
+ rows: List[Any] = []
2884
+ last_error: Optional[Exception] = None
2885
+ for attempt in range(3):
2886
+ if attempt:
2887
+ await asyncio.sleep(1.5 * attempt)
2888
+ try:
2889
+ query = ls.overseas_futureoption().market().해외선물마스터조회(
2890
+ body=o3101.O3101InBlock(gubun="0")
2891
+ )
2892
+ result = await query.req_async()
2893
+ except Exception as e: # noqa: BLE001 — 원인을 그대로 사용자에게 전달한다
2894
+ last_error = e
2895
+ context.log("warning", f"o3101 조회 실패 (시도 {attempt + 1}/3): {e}", node_id)
2896
+ continue
2897
+ rows = getattr(result, "block", None) or []
2898
+ if rows:
2899
+ break
2900
+ context.log("warning", f"o3101 이 빈 마스터를 반환 (시도 {attempt + 1}/3)", node_id)
2901
+
2902
+ if not rows:
2903
+ if last_error is not None:
2904
+ raise RuntimeError(
2905
+ f"FuturesContractNode[{node_id}]: LS contract-master query (o3101) failed "
2906
+ f"after 3 attempts: {last_error}"
2907
+ ) from last_error
2908
+ raise RuntimeError(
2909
+ f"FuturesContractNode[{node_id}]: LS returned an empty contract master (o3101) "
2910
+ f"on 3 attempts. The broker session may lack overseas-futures entitlement, or too "
2911
+ f"many requests are sharing this app key at once."
2912
+ )
2913
+
2914
+ # 거래소 필터를 걸기 **전** 목록도 들고 있는다 — 실패 메시지의 "쓸 수 있는 코드" 를
2915
+ # 필터 뒤 목록에서 뽑으면, 거래소를 잘못 골랐을 때 "LS 에 아무것도 없다" 로 읽혀
2916
+ # 진짜 원인(거래소 오지정)을 가린다.
2917
+ unfiltered = self._parse_master(rows, "")
2918
+ listed = [c for c in unfiltered if not exchange_filter or c["exchange"] == exchange_filter]
2919
+
2920
+ if exchange_filter and not listed and unfiltered:
2921
+ raise RuntimeError(
2922
+ f"FuturesContractNode[{node_id}]: exchange '{exchange_filter}' has no listed "
2923
+ f"contracts in this account's LS master. LS currently lists: "
2924
+ f"{', '.join(sorted({c['exchange'] for c in unfiltered}))}. "
2925
+ f"Leave futures_exchange empty to search every exchange."
2926
+ )
2927
+
2928
+ available = sorted({c["base_product"] for c in listed})
2929
+ contracts: List[Dict[str, Any]] = []
2930
+ for product in base_products:
2931
+ candidates = sorted(
2932
+ (c for c in listed if c["base_product"] == product),
2933
+ key=lambda c: (c["year"], c["month"]),
2934
+ )
2935
+ if not candidates:
2936
+ raise RuntimeError(
2937
+ f"FuturesContractNode[{node_id}]: no listed contract for underlying "
2938
+ f"'{product}'"
2939
+ + (f" on exchange '{exchange_filter}'" if exchange_filter else "")
2940
+ + f". LS currently lists: {', '.join(available) or '(none)'}."
2941
+ )
2942
+ picked = self._select(candidates, selection)
2943
+ if picked is None:
2944
+ months = ", ".join(c["contract_month"] for c in candidates)
2945
+ raise RuntimeError(
2946
+ f"FuturesContractNode[{node_id}]: contract_selection='{selection}' has no match "
2947
+ f"for '{product}'. LS lists these months: {months}."
2948
+ )
2949
+ contracts.append(picked)
2950
+
2951
+ symbols = [{"exchange": c["exchange"], "symbol": c["symbol"]} for c in contracts]
2952
+ detail = [
2953
+ {
2954
+ "symbol": c["symbol"],
2955
+ "exchange": c["exchange"],
2956
+ "base_product": c["base_product"],
2957
+ "base_product_name": c["base_product_name"],
2958
+ "name": c["name"],
2959
+ "contract_month": c["contract_month"],
2960
+ }
2961
+ for c in contracts
2962
+ ]
2963
+ context.log(
2964
+ "info",
2965
+ f"월물 해소 ({selection}): "
2966
+ + ", ".join(f"{c['base_product']}→{c['symbol']}({c['contract_month']})" for c in contracts),
2967
+ node_id,
2968
+ )
2969
+ return {"symbols": symbols, "contracts": detail, "count": len(symbols)}
2970
+
2971
+ def _parse_master(self, rows: Any, exchange_filter: str) -> List[Dict[str, Any]]:
2972
+ """o3101 응답을 파싱하고 **만기 경과 월물을 제거**한다.
2973
+
2974
+ LS 마스터는 만기가 지난 월물을 이미 빼주지만, 그것에만 의존하지 않는다 —
2975
+ 마스터에 죽은 월물이 하루라도 남아 있으면 그게 '근월물'로 뽑혀 워크플로우가
2976
+ 조용히 죽기 때문이다. 현재 연-월보다 이른 월물은 여기서 잘라낸다.
2977
+ """
2978
+ now = datetime.now()
2979
+ cur = (now.year, now.month)
2980
+
2981
+ parsed: List[Dict[str, Any]] = []
2982
+ for item in rows:
2983
+ symbol = getattr(item, "Symbol", "") or ""
2984
+ base_product = (getattr(item, "BscGdsCd", "") or "").strip().upper()
2985
+ exchange = (getattr(item, "ExchCd", "") or "").strip().upper()
2986
+ year_raw = (getattr(item, "LstngYr", "") or "").strip()
2987
+ month_raw = (getattr(item, "LstngM", "") or "").strip().upper()
2988
+ if not symbol or not base_product or not year_raw or not month_raw:
2989
+ continue
2990
+ if exchange_filter and exchange != exchange_filter:
2991
+ continue
2992
+
2993
+ month = FUTURES_MONTH_CODES.get(month_raw)
2994
+ if month is None and month_raw.isdigit():
2995
+ month = int(month_raw) # LS 가 숫자 월을 주는 경우 대비
2996
+ try:
2997
+ year = int(year_raw)
2998
+ except ValueError:
2999
+ continue
3000
+ if not month or not 1 <= month <= 12:
3001
+ continue
3002
+ if (year, month) < cur:
3003
+ continue # 만기 경과 — 이 종목은 시세도 과거봉도 오지 않는다
3004
+
3005
+ parsed.append({
3006
+ "symbol": symbol,
3007
+ "exchange": exchange,
3008
+ "base_product": base_product,
3009
+ "base_product_name": getattr(item, "BscGdsNm", "") or "",
3010
+ "name": getattr(item, "SymbolNm", "") or "",
3011
+ "contract_month": f"{year:04d}-{month:02d}",
3012
+ "year": year,
3013
+ "month": month,
3014
+ })
3015
+ return parsed
3016
+
3017
+ def _select(self, candidates: List[Dict[str, Any]], selection: str) -> Optional[Dict[str, Any]]:
3018
+ """만기 오름차순 candidates 에서 월물 1건을 고른다."""
3019
+ if selection == "next":
3020
+ return candidates[1] if len(candidates) > 1 else None
3021
+ if selection == "quarterly":
3022
+ for c in candidates:
3023
+ if c["month"] in QUARTERLY_MONTHS:
3024
+ return c
3025
+ return None
3026
+ return candidates[0] # front (기본)
3027
+
3028
+
2711
3029
  class BrokerNodeExecutor(NodeExecutorBase):
2712
3030
  """BrokerNode executor
2713
3031
 
@@ -8961,6 +9279,12 @@ class MarketDataNodeExecutor(NodeExecutorBase):
8961
9279
  "AMEX": "81",
8962
9280
  }
8963
9281
 
9282
+ # 거래소 라벨이 미지일 때 두 원장을 시도한 뒤, 응답한 코드를 라벨로 되돌리는 역매핑.
9283
+ CODE_EXCHANGES = {
9284
+ "82": "NASDAQ",
9285
+ "81": "NYSE",
9286
+ }
9287
+
8964
9288
  async def execute(
8965
9289
  self,
8966
9290
  node_id: str,
@@ -8978,21 +9302,50 @@ class MarketDataNodeExecutor(NodeExecutorBase):
8978
9302
  # (dict/list 완전 재귀)를 직접 호출해야 한다. (형제 executor 패턴과 동일)
8979
9303
  config = evaluate_all_bindings(config, context, node_id)
8980
9304
 
8981
- # 입력 symbols 가져오기 (포트 또는 config에서 명시적 입력 필수)
9305
+ # symbols 획득 우선순위: config.symbol(단수) > input.symbols > config.symbols
9306
+ #
9307
+ # 단수 config.symbol 을 먼저 보는 이유: 상류 배열 입력이 있으면 메인 루프가 이 노드를
9308
+ # auto-iterate 로 종목당 1회씩 돌리며 {{ item }} 을 해당 종목으로 해소한다
9309
+ # (_execute_with_auto_iterate). 그런데 여기서 input.symbols(=전체 배열)를 먼저 읽으면
9310
+ # 매 iteration 이 다시 전 종목을 조회해 N종목 × N회 = N² 중복이 된다
9311
+ # (실측: 예제 07 4종목→16건 / 10 5종목→25건 / 08 30종목→LS 폭주 후 job cancelled).
9312
+ # HistoricalDataNodeExecutor 는 이미 단수 우선이라 정상이었다(~9575) — 그 패턴에 맞춘다.
9313
+ config_symbol = config.get("symbol")
9314
+ if isinstance(config_symbol, str):
9315
+ # LLM Tool 경로에서 JSON 문자열로 오는 경우 (형제 executor 와 동일 방어)
9316
+ import json as _json
9317
+ try:
9318
+ _parsed = _json.loads(config_symbol)
9319
+ config_symbol = _parsed if isinstance(_parsed, dict) else None
9320
+ except (ValueError, _json.JSONDecodeError):
9321
+ config_symbol = None
9322
+
8982
9323
  input_symbols = context.get_output(f"_input_{node_id}", "symbols")
8983
9324
  config_symbols = config.get("symbols")
8984
- symbols = input_symbols or config_symbols
9325
+
9326
+ # auto-iterate 중이라면 **이번 아이템 1건만** 조회한다. 여기서 _input_.symbols(=상류가
9327
+ # 넘긴 전체 배열)를 읽으면 iteration 마다 전 종목을 재조회해 N² 가 된다.
9328
+ # `{{ item }}` 리터럴이 있으면 config.symbol 로 들어오지만, 노드 가이드가 권장하는
9329
+ # "Watchlist → MarketData" 배선(리터럴 없이 엣지만)에는 config.symbol 이 없다 —
9330
+ # 그 모양이 곧 챗봇 산출물이라 리터럴 유무와 무관하게 iteration 컨텍스트로 막는다.
9331
+ # (실측: 리터럴 없는 3종목 배선이 values 9건 = 3²)
9332
+ iteration_item = getattr(context, "_iteration_item", None)
9333
+
9334
+ if isinstance(config_symbol, dict) and config_symbol.get("symbol"):
9335
+ symbols = [config_symbol]
9336
+ elif isinstance(iteration_item, dict) and iteration_item.get("symbol"):
9337
+ symbols = [iteration_item]
9338
+ else:
9339
+ symbols = input_symbols or config_symbols
8985
9340
 
8986
9341
  # deep_validate: MarketDataNode (REST current-price) would ensure_ls_login
8987
9342
  # unconditionally. Inject a fixture so the flow completes without a
8988
9343
  # network call, derived from the requested symbols.
8989
9344
  if context.is_deep_validate:
8990
9345
  from programgarden import deep_fixtures as _df
8991
- # Evaluate own config bindings (e.g. symbols/symbol) before the fixture
8992
- # short-circuit so a wrong binding here is still surfaced.
8993
- config = evaluate_all_bindings(config, context, node_id)
8994
- _deep_symbols = input_symbols or config.get("symbols") or config.get("symbol")
8995
- fixture = _df.market_data_fixture(config, _deep_symbols)
9346
+ # 실경로와 **같은** symbols 쓴다. 예전엔 여기만 단수 symbol 폴백을 갖고 있어
9347
+ # deep_validate 초록인데 라이브만 다르게 도는 상태였다(게이트가 거짓말함).
9348
+ fixture = _df.market_data_fixture(config, symbols)
8996
9349
  override = context.get_deep_fixture(node_id, node_type)
8997
9350
  return _df.apply_override(fixture, override)
8998
9351
 
@@ -9070,28 +9423,54 @@ class MarketDataNodeExecutor(NodeExecutorBase):
9070
9423
  continue
9071
9424
 
9072
9425
  # 거래소 코드 변환 (81: NYSE/AMEX, 82: NASDAQ)
9073
- exchange_code = self.EXCHANGE_CODES.get(exchange.upper(), "82")
9074
-
9075
- # KEY종목코드 생성 (거래소코드 + 심볼, 예: "82AAPL")
9076
- keysymbol = f"{exchange_code}{symbol}"
9077
-
9078
- # g3101 현재가 조회
9079
- body = G3101InBlock(
9080
- keysymbol=keysymbol,
9081
- exchcd=exchange_code,
9082
- symbol=symbol,
9083
- )
9084
-
9085
- response = api.market().g3101(body=body).req()
9086
-
9426
+ # 라벨을 조용히 82 로 떨어뜨리면 NYSE 종목이 "No data" 로 무음 유실된다
9427
+ # (실측: 예제 08 이 30종목 중 8건만 수신).
9428
+ #
9429
+ # 라벨이 정확해도 첫 시도가 실패하면 다른 원장을 재시도한다 — LS 의 거래소
9430
+ # 코드는 실제 상장 거래소와 항상 일치하지 않는다(실측: WMT 는 NYSE 상장인데
9431
+ # 81 로는 응답이 없고 82 로만 조회된다). 성공한 코드를 실제 거래소로 기록한다.
9432
+ known_code = self.EXCHANGE_CODES.get(exchange.upper())
9433
+ if known_code:
9434
+ candidate_codes = [known_code] + [
9435
+ c for c in ("82", "81") if c != known_code
9436
+ ]
9437
+ else:
9438
+ candidate_codes = ["82", "81"]
9439
+
9440
+ response = None
9441
+ used_code = None
9442
+ for exchange_code in candidate_codes:
9443
+ # KEY종목코드 생성 (거래소코드 + 심볼, 예: "82AAPL")
9444
+ keysymbol = f"{exchange_code}{symbol}"
9445
+
9446
+ # g3101 현재가 조회
9447
+ body = G3101InBlock(
9448
+ keysymbol=keysymbol,
9449
+ exchcd=exchange_code,
9450
+ symbol=symbol,
9451
+ )
9452
+
9453
+ response = api.market().g3101(body=body).req()
9454
+ if response and response.block:
9455
+ used_code = exchange_code
9456
+ break
9457
+
9087
9458
  if response and response.block:
9088
9459
  out_block = response.block
9089
9460
  from datetime import datetime
9090
-
9461
+
9462
+ # 라벨대로 조회에 성공했으면 그대로 둔다(AMEX 처럼 81 을 공유하는 라벨이
9463
+ # NYSE 로 덮이지 않게). 다른 원장으로 성공했을 때만 실제 응답한 거래소로
9464
+ # 정정해 하류(주문 노드 등)가 같은 거래소를 쓰게 한다.
9465
+ if known_code and used_code == known_code:
9466
+ resolved_exchange = exchange
9467
+ else:
9468
+ resolved_exchange = self.CODE_EXCHANGES.get(used_code, exchange)
9469
+
9091
9470
  # values 배열에 단일 항목 추가
9092
9471
  values.append({
9093
9472
  "symbol": symbol,
9094
- "exchange": exchange,
9473
+ "exchange": resolved_exchange,
9095
9474
  "price": float(out_block.price or 0),
9096
9475
  "change": float(out_block.diff or 0),
9097
9476
  "change_pct": float(out_block.rate or 0),
@@ -15847,6 +16226,7 @@ class WorkflowExecutor:
15847
16226
  "SymbolQueryNode": SymbolQueryNodeExecutor(),
15848
16227
  "OverseasStockSymbolQueryNode": SymbolQueryNodeExecutor(),
15849
16228
  "OverseasFuturesSymbolQueryNode": SymbolQueryNodeExecutor(),
16229
+ "FuturesContractNode": FuturesContractNodeExecutor(),
15850
16230
  "SymbolFilterNode": SymbolFilterNodeExecutor(),
15851
16231
  "ExclusionListNode": ExclusionListNodeExecutor(),
15852
16232
  "MarketUniverseNode": MarketUniverseNodeExecutor(),
@@ -17196,44 +17576,66 @@ class WorkflowJob:
17196
17576
  # === 자동 iterate 체크 ===
17197
17577
  # 입력 데이터가 배열이고, 노드가 단일 아이템을 기대하면 자동으로 각 아이템마다 실행
17198
17578
  input_data = None
17579
+ # auto-iterate 소스 선택 — incoming 엣지를 **전부** 훑어 우선순위로 고른다.
17580
+ # 예전엔 첫 매칭 엣지에서 무조건 break 해서 **엣지 선언 순서가 소스를 결정**했다:
17581
+ # - 예제 16/28 은 account 엣지가 먼저라 sizing 이 **계좌 보유종목**을 순회했다
17582
+ # (실측: 워크플로우 어디에도 없는 AUID 를 매수 후보로 처리 — 잔고가 충분했다면
17583
+ # 보유종목에 실제 주문이 나갔다).
17584
+ # - 예제 28 은 `logic.passed_symbols` 를 올바로 명시했는데도 앞선 account 엣지의
17585
+ # break 에 가려 아래 explicit 분기까지 도달조차 못 했다.
17586
+ # 우선순위: 명시 from_port > symbols 포트 > 소스 노드 첫 출력.
17587
+ explicit_data = None
17588
+ symbols_data = None
17589
+ fallback_data = None
17590
+
17199
17591
  for edge in self.workflow.edges:
17200
- if edge.to_node_id == node_id:
17201
- # 명시적 from_port 우선 (예: ExclusionListNode.filtered).
17202
- # 이를 지정하지 않으면 auto-iterate 는 소스 노드의 첫 출력
17203
- # 포트를 집어버린다 — ExclusionListNode 는 첫 포트가
17204
- # `excluded`(블랙리스트)라서, from_port 없이는 제외 종목을
17205
- # 순회하게 되는 silent 버그가 생긴다. IfNode 분기 포트
17206
- # (true/false/result)는 별도 라우팅 의미라 여기서 제외.
17207
- explicit_port = getattr(edge, "from_port", None)
17208
- if explicit_port and explicit_port not in (
17209
- "output", "true", "false", "result",
17210
- ):
17211
- port_data = self.context.get_output(
17212
- edge.from_node_id, explicit_port
17213
- )
17214
- if port_data is not None:
17215
- input_data = port_data
17216
- break
17592
+ if edge.to_node_id != node_id:
17593
+ continue
17217
17594
 
17218
- # symbols 포트 우선 확인 (WatchlistNode 출력)
17219
- input_data = self.context.get_output(edge.from_node_id, "symbols")
17595
+ # 1순위: 명시적 from_port (예: ExclusionListNode.filtered,
17596
+ # LogicNode.passed_symbols). IfNode 분기 포트(true/false/result)
17597
+ # 라우팅 의미라 소스에서 제외.
17598
+ explicit_port = getattr(edge, "from_port", None)
17599
+ if explicit_port and explicit_port not in (
17600
+ "output", "true", "false", "result",
17601
+ ):
17602
+ port_data = self.context.get_output(
17603
+ edge.from_node_id, explicit_port
17604
+ )
17605
+ if port_data is not None and explicit_data is None:
17606
+ explicit_data = port_data
17220
17607
 
17608
+ # 2순위: symbols 포트 (Watchlist/MarketUniverse/SymbolFilter 등 배열 생성 노드)
17609
+ if symbols_data is None:
17610
+ port_symbols = self.context.get_output(edge.from_node_id, "symbols")
17221
17611
  # symbols가 문자열 배열이면 (merge 후) value 포트로 폴백
17222
17612
  # - WatchlistNode symbols: [{exchange, symbol}, ...] → dict 배열 → 그대로 사용
17223
17613
  # - HistoricalDataNode merged symbols: ["TSLA"] → string 배열 → value 포트로 전환
17224
- if (isinstance(input_data, list) and input_data
17225
- and not isinstance(input_data[0], dict)):
17614
+ if (isinstance(port_symbols, list) and port_symbols
17615
+ and not isinstance(port_symbols[0], dict)):
17226
17616
  value_data = self.context.get_output(edge.from_node_id, "value")
17227
17617
  if value_data is not None:
17228
17618
  if isinstance(value_data, list):
17229
- input_data = value_data
17619
+ port_symbols = value_data
17230
17620
  elif isinstance(value_data, dict):
17231
- input_data = [value_data]
17232
-
17233
- if input_data is None:
17234
- # 기본 출력 확인
17235
- input_data = self.context.get_output(edge.from_node_id, None)
17236
- break
17621
+ port_symbols = [value_data]
17622
+ if port_symbols is not None:
17623
+ symbols_data = port_symbols
17624
+
17625
+ # 3순위: 소스 노드의 첫 출력. 단 계좌 노드는 제외한다 —
17626
+ # 첫 포트가 `positions`(보유잔고)라 매수 후보로 오인된다.
17627
+ if fallback_data is None:
17628
+ from_node = self.workflow.nodes.get(edge.from_node_id)
17629
+ from_type = getattr(from_node, "node_type", None) if from_node else None
17630
+ if from_type not in self.NO_ITERATE_SOURCE_NODE_TYPES:
17631
+ fallback_data = self.context.get_output(edge.from_node_id, None)
17632
+
17633
+ if explicit_data is not None:
17634
+ input_data = explicit_data
17635
+ elif symbols_data is not None:
17636
+ input_data = symbols_data
17637
+ else:
17638
+ input_data = fallback_data
17237
17639
 
17238
17640
  should_iterate, port_name, items = self._should_auto_iterate(node.node_type, input_data)
17239
17641
 
@@ -17416,14 +17818,33 @@ class WorkflowJob:
17416
17818
  "CandlestickChartNode", "BarChartNode", "SummaryDisplayNode",
17417
17819
  "WatchlistNode", "MarketUniverseNode", "ScreenerNode", # 배열 생성 노드
17418
17820
  "ExclusionListNode", # 제외 종목 관리 노드 (배열 생성)
17821
+ # 종목 마스터 조회 노드 — 배열을 **생성**하는 쪽이다. 상류에 배열이 붙었다고
17822
+ # 아이템 수만큼 마스터 조회를 반복하면 같은 전체 목록을 N 번 받아온다.
17823
+ "FuturesContractNode",
17824
+ "OverseasFuturesSymbolQueryNode", "OverseasStockSymbolQueryNode", "KoreaStockSymbolQueryNode",
17419
17825
  "SymbolFilterNode", # 집합 연산 노드 (배열 입력/출력)
17826
+ # LogicNode 도 집합 연산 노드다 — 상류 조건 결과 **배열 전체**를 AND/OR 해야 한다.
17827
+ # 종목별로 쪼개 N회 돌면 매 회가 전체 passed_symbols 를 다시 내보내 병합 시 N² 가 된다
17828
+ # (실측: 28 이 5종목인데 logic.passed_symbols 25건 → sizing 이 중복 25건을 순회).
17829
+ "LogicNode",
17420
17830
  "OverseasStockAccountNode", "OverseasFuturesAccountNode", # 계좌 노드
17421
17831
  "OverseasStockRealAccountNode", "OverseasFuturesRealAccountNode",
17832
+ "KoreaStockAccountNode", "KoreaStockRealAccountNode",
17422
17833
  "SQLiteNode", "HTTPRequestNode", # 데이터 노드
17423
17834
  "CodeNode", # custom code: receives the whole array in `data`, loops in-code (one subprocess call)
17424
17835
  "BacktestEngineNode", "BenchmarkCompareNode", # 분석 노드
17425
17836
  }
17426
17837
 
17838
+ # auto-iterate 의 **소스**가 되어선 안 되는 노드 (그 노드 자신의 iterate 여부와 무관).
17839
+ # 계좌 노드의 첫 출력 포트는 `positions`(보유잔고)다. 이게 폴백 소스로 잡히면
17840
+ # 하류가 **보유종목을 신규 매수 후보로 순회**한다 — 실주문 위험. 종목 후보는
17841
+ # Watchlist/Universe/SymbolFilter/Condition 같은 배열 생성 노드에서만 와야 한다.
17842
+ NO_ITERATE_SOURCE_NODE_TYPES = {
17843
+ "AccountNode", "RealAccountNode",
17844
+ "OverseasStockAccountNode", "OverseasFuturesAccountNode", "KoreaStockAccountNode",
17845
+ "OverseasStockRealAccountNode", "OverseasFuturesRealAccountNode", "KoreaStockRealAccountNode",
17846
+ }
17847
+
17427
17848
  def _should_auto_iterate(self, node_type: str, input_data: Any) -> tuple:
17428
17849
  """
17429
17850
  이전 노드 출력이 배열이면 자동 반복 실행
@@ -17535,7 +17956,12 @@ class WorkflowJob:
17535
17956
  return {}
17536
17957
 
17537
17958
  merged = {}
17538
- array_fields = {"value", "values", "items", "data", "result", "results", "passed_symbols", "failed_symbols"}
17959
+ # 배열로 병합할 포트. `symbols`/`symbol_results` 빠져 있어 auto-iterate N회 중
17960
+ # **마지막 1회만 살아남았다**(실측: 28 의 logic 이 5종목 중 1건만 받음).
17961
+ array_fields = {
17962
+ "value", "values", "items", "data", "result", "results",
17963
+ "passed_symbols", "failed_symbols", "symbols", "symbol_results",
17964
+ }
17539
17965
 
17540
17966
  for key in results[0].keys():
17541
17967
  if key in array_fields:
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.25.0"
8
+ version = "1.26.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.16.0"
32
+ programgarden-core = "^1.18.0"
33
33
  programgarden-finance = "^1.6.14"
34
34
  programgarden-community = "^1.13.11"
35
35
 
File without changes