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.
- {programgarden-1.25.0 → programgarden-1.26.0}/PKG-INFO +2 -2
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/deep_fixtures.py +57 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/executor.py +516 -90
- {programgarden-1.25.0 → programgarden-1.26.0}/pyproject.toml +2 -2
- {programgarden-1.25.0 → programgarden-1.26.0}/README.md +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/__init__.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/binding_validator.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/client.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/code_worker.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/context.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/__init__.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/node_runner.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resolver.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resource/context.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.25.0 → programgarden-1.26.0}/programgarden/tools/sqlite_tools.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
1629
|
-
for sym_info in
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
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=
|
|
2606
|
+
body=o3101.O3101InBlock(gubun="0")
|
|
2593
2607
|
)
|
|
2594
|
-
|
|
2608
|
+
|
|
2595
2609
|
result = await query.req_async()
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
8992
|
-
#
|
|
8993
|
-
|
|
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
|
-
|
|
9074
|
-
|
|
9075
|
-
#
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
#
|
|
9079
|
-
|
|
9080
|
-
|
|
9081
|
-
|
|
9082
|
-
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
|
|
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":
|
|
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
|
|
17201
|
-
|
|
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
|
-
|
|
17219
|
-
|
|
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(
|
|
17225
|
-
and not isinstance(
|
|
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
|
-
|
|
17619
|
+
port_symbols = value_data
|
|
17230
17620
|
elif isinstance(value_data, dict):
|
|
17231
|
-
|
|
17232
|
-
|
|
17233
|
-
|
|
17234
|
-
|
|
17235
|
-
|
|
17236
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.25.0 → programgarden-1.26.0}/programgarden/database/workflow_risk_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|