programgarden-core 2.4.0__tar.gz → 2.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/PKG-INFO +1 -1
  2. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/i18n/locales/en.json +14 -1
  3. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/i18n/locales/ko.json +14 -1
  4. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/credential.py +1 -1
  5. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/ai.py +19 -4
  6. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/broker.py +43 -1
  7. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/data.py +198 -38
  8. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/trigger.py +35 -4
  9. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/registry/node_registry.py +86 -1
  10. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/retry_executor.py +34 -13
  11. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/pyproject.toml +1 -1
  12. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/README.md +0 -0
  13. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/__init__.py +0 -0
  14. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/bases/__init__.py +0 -0
  15. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/bases/client.py +0 -0
  16. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/bases/components.py +0 -0
  17. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/bases/listener.py +0 -0
  18. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/bases/mixins.py +0 -0
  19. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/bases/products.py +0 -0
  20. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/bases/sql.py +0 -0
  21. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/bases/storage.py +0 -0
  22. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/code_node.py +0 -0
  23. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/exceptions.py +0 -0
  24. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/expression/__init__.py +0 -0
  25. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/expression/evaluator.py +0 -0
  26. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/expression/reference.py +0 -0
  27. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/i18n/__init__.py +0 -0
  28. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/i18n/translator.py +0 -0
  29. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/korea_alias.py +0 -0
  30. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/__init__.py +0 -0
  31. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/connection_rule.py +0 -0
  32. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/edge.py +0 -0
  33. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/event.py +0 -0
  34. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/exchange.py +0 -0
  35. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/field_binding.py +0 -0
  36. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/job.py +0 -0
  37. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/order_diagnostics.py +0 -0
  38. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/plugin_resource.py +0 -0
  39. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/resilience.py +0 -0
  40. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/resource.py +0 -0
  41. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/validation.py +0 -0
  42. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/models/workflow.py +0 -0
  43. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/__init__.py +0 -0
  44. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/account_futures.py +0 -0
  45. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/account_korea_stock.py +0 -0
  46. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/account_stock.py +0 -0
  47. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/backtest.py +0 -0
  48. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/backtest_futures.py +0 -0
  49. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/backtest_korea_stock.py +0 -0
  50. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/backtest_stock.py +0 -0
  51. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/base.py +0 -0
  52. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/calculation.py +0 -0
  53. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/code.py +0 -0
  54. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/condition.py +0 -0
  55. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/data_futures.py +0 -0
  56. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/data_korea_stock.py +0 -0
  57. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/data_stock.py +0 -0
  58. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/display.py +0 -0
  59. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/event.py +0 -0
  60. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/fundamental_korea_stock.py +0 -0
  61. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/fundamental_stock.py +0 -0
  62. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/infra.py +0 -0
  63. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/open_orders_futures.py +0 -0
  64. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/open_orders_korea_stock.py +0 -0
  65. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/open_orders_stock.py +0 -0
  66. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/order.py +0 -0
  67. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/orderable_quantity_futures.py +0 -0
  68. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/portfolio.py +0 -0
  69. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/realtime_futures.py +0 -0
  70. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/realtime_korea_stock.py +0 -0
  71. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/realtime_stock.py +0 -0
  72. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/risk.py +0 -0
  73. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/session_gate.py +0 -0
  74. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/symbol.py +0 -0
  75. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/symbol_futures.py +0 -0
  76. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/symbol_korea_stock.py +0 -0
  77. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/nodes/symbol_stock.py +0 -0
  78. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/presets/__init__.py +0 -0
  79. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/presets/news_analyst.json +0 -0
  80. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/presets/risk_manager.json +0 -0
  81. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/presets/strategist.json +0 -0
  82. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/presets/technical_analyst.json +0 -0
  83. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/registry/__init__.py +0 -0
  84. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/registry/credential_registry.py +0 -0
  85. {programgarden_core-2.4.0 → programgarden_core-2.5.1}/programgarden_core/registry/plugin_registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden-core
3
- Version: 2.4.0
3
+ Version: 2.5.1
4
4
  Summary: ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -112,7 +112,7 @@
112
112
  "nodes.NewOrderNode.name": "New Order",
113
113
  "nodes.OverseasFuturesAccountNode.description": "Retrieves overseas futures holdings, margin balance, and position details as a one-time REST API snapshot. Outputs held_symbols, balance (margin, maintenance margin, available margin), and positions (per-contract quantity, average cost, unrealized P&L). Requires an upstream OverseasFuturesBrokerNode for authentication. Use OverseasFuturesOpenOrdersNode separately for pending orders. For real-time updates, use OverseasFuturesRealAccountNode instead. Can be used as an AI Agent tool.",
114
114
  "nodes.OverseasFuturesAccountNode.name": "Futures Account",
115
- "nodes.OverseasFuturesBrokerNode.description": "Establishes an LS Securities OpenAPI connection for overseas futures trading (CME, EUREX, SGX, HKEX). Acts as the authentication gateway — downstream account, market data, and order nodes automatically inherit this broker connection through DAG traversal. Requires credential_id referencing broker_ls_overseas_futures credentials. Supports paper trading mode for risk-free testing. Typical connection: StartNode → OverseasFuturesBrokerNode → FuturesAccountNode / FuturesMarketDataNode / FuturesNewOrderNode.",
115
+ "nodes.OverseasFuturesBrokerNode.description": "Establishes an LS Securities OpenAPI connection for overseas futures trading (CME, EUREX, SGX, HKEX and others — which of them an account opens differs per account). Acts as the authentication gateway — downstream account, market data, and order nodes automatically inherit this broker connection through DAG traversal. Requires credential_id referencing broker_ls_overseas_futures credentials. Works with BOTH live and paper-trading accounts — it follows the mode of the credential you registered (paper trading is risk-free practice; live trading is equally supported). Which exchanges an account opens differs per account, so confirm with an orderable-symbol lookup. Typical connection: StartNode → OverseasFuturesBrokerNode → FuturesAccountNode / FuturesMarketDataNode / FuturesNewOrderNode.",
116
116
  "nodes.OverseasFuturesBrokerNode.name": "Futures Broker",
117
117
  "nodes.OverseasFuturesCancelOrderNode.description": "Cancels a pending overseas futures order by its original order ID. Requires original_order_id from FuturesOpenOrdersNode or FuturesRealOrderEventNode. Outputs cancel result and cancelled order ID. Rate-limited to 5-second minimum interval. Typical connection: FuturesOpenOrdersNode → FuturesCancelOrderNode.",
118
118
  "nodes.OverseasFuturesCancelOrderNode.name": "Futures Cancel Order",
@@ -294,7 +294,9 @@
294
294
  "fields.PerformanceReportNode.benchmark": "Optional benchmark series (same format as data). When provided, beta and alpha are added to metrics.",
295
295
  "fields.PerformanceReportNode.periods_per_year": "Annualization factor (252 for daily, 12 for monthly data). Default 252.",
296
296
  "fields.PerformanceReportNode.risk_free_rate": "Annual risk-free rate as a fraction (e.g. 0.02 for 2%) for Sharpe/Sortino. Default 0.",
297
+ "fields.HTTPRequestNode.auth_required": "Does the API require authentication? Leave unset to let the chatbot decide from context; set true for private APIs (a credential is then required), false for public APIs.",
297
298
  "fields.HTTPRequestNode.body": "Request body for POST/PUT/PATCH. Automatically serialized to JSON.",
299
+
298
300
  "fields.HTTPRequestNode.credential_id": "Credential ID for authentication. Supports: http_bearer, http_header, http_basic, http_query",
299
301
  "fields.HTTPRequestNode.headers": "HTTP headers. Click + to add custom headers (e.g., Content-Type, X-API-Key).",
300
302
  "fields.HTTPRequestNode.method": "HTTP method for the request",
@@ -598,7 +600,9 @@
598
600
  "fieldNames.PerformanceReportNode.benchmark": "Benchmark",
599
601
  "fieldNames.PerformanceReportNode.periods_per_year": "Periods / Year",
600
602
  "fieldNames.PerformanceReportNode.risk_free_rate": "Risk-Free Rate",
603
+ "fieldNames.HTTPRequestNode.auth_required": "Requires Auth",
601
604
  "fieldNames.HTTPRequestNode.body": "Request Body",
605
+
602
606
  "fieldNames.HTTPRequestNode.credential_id": "Credential",
603
607
  "fieldNames.HTTPRequestNode.headers": "Headers",
604
608
  "fieldNames.HTTPRequestNode.method": "HTTP Method",
@@ -872,6 +876,8 @@
872
876
  "ports.http_error": "Error message",
873
877
  "ports.http_request_data": "Request data",
874
878
  "ports.http_response": "API response data",
879
+ "ports.http_results": "Per-item results (one {item, response, status_code, success, error} entry per iterated item)",
880
+
875
881
  "ports.http_status_code": "HTTP status code",
876
882
  "ports.http_success": "Request success flag",
877
883
  "ports.items": "Item list",
@@ -1194,7 +1200,14 @@
1194
1200
  "connection_rules.realtime_to_ai_agent.suggestion": "Place a ThrottleNode between the realtime node and AI Agent to control call frequency.",
1195
1201
  "connection_rules.realtime_to_external_api.reason": "Direct connection from realtime node to external API node is not recommended. Each tick would trigger an external API request, potentially hitting rate limits.",
1196
1202
  "connection_rules.realtime_to_external_api.suggestion": "Place a ThrottleNode between the realtime node and external API node to control request frequency.",
1203
+ "connection.HTTPRequestNode.label": "API key",
1204
+ "connection.KoreaStockBrokerNode.label": "LS Securities Korea stock account",
1205
+ "connection.LLMModelNode.label": "AI model key",
1206
+ "connection.OverseasFuturesBrokerNode.label": "LS Securities overseas futures account",
1207
+ "connection.OverseasStockBrokerNode.label": "LS Securities overseas stock account",
1208
+ "connection.TelegramNode.label": "Telegram bot",
1197
1209
  "connection_rules.realtime_to_http.reason": "Direct connection from realtime node to HTTPRequestNode is not recommended. Each tick would trigger an HTTP request, potentially hitting external API rate limits.",
1210
+
1198
1211
  "connection_rules.realtime_to_http.suggestion": "Place a ThrottleNode between the realtime node and HTTP request node to control request frequency.",
1199
1212
  "fields.BaseOrderNode.rate_limit_interval": "Minimum order interval (seconds). Default 5 seconds. The next order will only execute after this time has elapsed since the last order.",
1200
1213
  "fields.BaseOrderNode.rate_limit_action": "Action when order interval is not met. skip: silently skip, error: raise error",
@@ -112,7 +112,7 @@
112
112
  "nodes.NewOrderNode.name": "신규 주문",
113
113
  "nodes.OverseasFuturesAccountNode.description": "해외선물 보유종목, 증거금 잔고, 포지션 상세를 REST API 1회 조회로 가져옵니다. 출력: held_symbols, balance (증거금, 유지증거금, 가용증거금), positions (계약별 수량, 평균 단가, 미실현 손익). 상위 OverseasFuturesBrokerNode 연결 필수. 미체결 주문은 OverseasFuturesOpenOrdersNode를 별도로 사용하세요. 실시간 업데이트가 필요하면 OverseasFuturesRealAccountNode를 사용하세요. AI Agent 도구로 사용 가능.",
114
114
  "nodes.OverseasFuturesAccountNode.name": "해외선물 계좌 조회",
115
- "nodes.OverseasFuturesBrokerNode.description": "해외선물 거래를 위한 LS증권 OpenAPI 연결을 설정합니다(CME, EUREX, SGX, HKEX). 인증 게이트웨이 역할 — 하위 계좌, 시세, 주문 노드가 DAG 순회를 통해 자동으로 브로커 연결을 상속합니다. credential_id로 broker_ls_overseas_futures 인증 정보를 참조합니다. 위험 없는 테스트를 위한 모의투자 모드를 지원합니다. 일반적인 연결: StartNode → OverseasFuturesBrokerNode → FuturesAccountNode / FuturesMarketDataNode / FuturesNewOrderNode.",
115
+ "nodes.OverseasFuturesBrokerNode.description": "해외선물 거래를 위한 LS증권 OpenAPI 연결을 설정합니다(CME·EUREX·SGX·HKEX 등 — 어느 거래소가 열려 있는지는 계좌마다 다릅니다). 인증 게이트웨이 역할 — 하위 계좌, 시세, 주문 노드가 DAG 순회를 통해 자동으로 브로커 연결을 상속합니다. credential_id로 broker_ls_overseas_futures 인증 정보를 참조합니다. 실계좌와 모의투자 계좌를 모두 지원합니다 — 등록한 자격증명의 모드를 그대로 따릅니다(모의투자는 위험 없는 연습용이고, 실거래도 그대로 가능합니다). 계좌가 여는 거래소는 계좌마다 다르므로, 주문 가능 종목 조회로 확인하세요. 일반적인 연결: StartNode → OverseasFuturesBrokerNode → FuturesAccountNode / FuturesMarketDataNode / FuturesNewOrderNode.",
116
116
  "nodes.OverseasFuturesBrokerNode.name": "해외선물 브로커",
117
117
  "nodes.OverseasFuturesCancelOrderNode.description": "해외선물 미체결 주문을 원래 주문 ID로 취소합니다. FuturesOpenOrdersNode 또는 FuturesRealOrderEventNode에서 original_order_id를 받아야 합니다. 출력: 취소 결과, 취소된 주문 ID. 최소 5초 간격 제한 적용. 일반적인 연결: FuturesOpenOrdersNode → FuturesCancelOrderNode.",
118
118
  "nodes.OverseasFuturesCancelOrderNode.name": "해외선물 취소주문",
@@ -294,7 +294,9 @@
294
294
  "fields.PerformanceReportNode.benchmark": "선택 벤치마크 시계열(data 와 동일 형식). 지정 시 beta/alpha 가 지표에 추가됩니다.",
295
295
  "fields.PerformanceReportNode.periods_per_year": "연율화 계수(일간 252, 월간 12). 기본 252.",
296
296
  "fields.PerformanceReportNode.risk_free_rate": "무위험 수익률(소수, 예 0.02=2%) — Sharpe/Sortino용. 기본 0.",
297
+ "fields.HTTPRequestNode.auth_required": "이 API 가 인증이 필요한가요? 미설정이면 챗봇이 문맥으로 판단합니다. 비공개 API 는 true(자격증명 필요), 공개 API 는 false 로 두세요.",
297
298
  "fields.HTTPRequestNode.body": "요청 본문 (POST/PUT/PATCH용). 자동으로 JSON 직렬화됨.",
299
+
298
300
  "fields.HTTPRequestNode.credential_id": "인증정보 ID. 지원: http_bearer, http_header, http_basic, http_query",
299
301
  "fields.HTTPRequestNode.headers": "HTTP 헤더. + 버튼으로 커스텀 헤더 추가 (예: Content-Type, X-API-Key).",
300
302
  "fields.HTTPRequestNode.method": "HTTP 요청 메서드",
@@ -598,7 +600,9 @@
598
600
  "fieldNames.PerformanceReportNode.benchmark": "벤치마크",
599
601
  "fieldNames.PerformanceReportNode.periods_per_year": "연간 관측수",
600
602
  "fieldNames.PerformanceReportNode.risk_free_rate": "무위험 수익률",
603
+ "fieldNames.HTTPRequestNode.auth_required": "인증 필요 여부",
601
604
  "fieldNames.HTTPRequestNode.body": "요청본문",
605
+
602
606
  "fieldNames.HTTPRequestNode.credential_id": "인증정보",
603
607
  "fieldNames.HTTPRequestNode.headers": "헤더",
604
608
  "fieldNames.HTTPRequestNode.method": "HTTP메소드",
@@ -872,6 +876,8 @@
872
876
  "ports.http_error": "에러 메시지",
873
877
  "ports.http_request_data": "요청 데이터",
874
878
  "ports.http_response": "API 응답 데이터",
879
+ "ports.http_results": "항목별 결과 (반복된 항목마다 {item, response, status_code, success, error} 한 건)",
880
+
875
881
  "ports.http_status_code": "HTTP 상태 코드",
876
882
  "ports.http_success": "요청 성공 여부",
877
883
  "ports.items": "아이템 목록",
@@ -1194,7 +1200,14 @@
1194
1200
  "connection_rules.realtime_to_ai_agent.suggestion": "실시간 노드와 AI Agent 사이에 ThrottleNode를 배치하여 호출 빈도를 제어하세요.",
1195
1201
  "connection_rules.realtime_to_external_api.reason": "실시간 노드에서 외부 API 노드로의 직접 연결은 권장하지 않습니다. 틱 데이터마다 외부 API 요청이 발생하여 rate limit에 걸릴 수 있습니다.",
1196
1202
  "connection_rules.realtime_to_external_api.suggestion": "실시간 노드와 외부 API 노드 사이에 ThrottleNode를 배치하여 요청 빈도를 제어하세요.",
1203
+ "connection.HTTPRequestNode.label": "API 키",
1204
+ "connection.KoreaStockBrokerNode.label": "LS증권 국내주식 계좌",
1205
+ "connection.LLMModelNode.label": "AI 모델 키",
1206
+ "connection.OverseasFuturesBrokerNode.label": "LS증권 해외선물 계좌",
1207
+ "connection.OverseasStockBrokerNode.label": "LS증권 해외주식 계좌",
1208
+ "connection.TelegramNode.label": "텔레그램 봇",
1197
1209
  "connection_rules.realtime_to_http.reason": "실시간 노드에서 HTTPRequestNode로의 직접 연결은 권장하지 않습니다. 틱 데이터마다 HTTP 요청이 발생하여 외부 API 제한에 걸릴 수 있습니다.",
1210
+
1198
1211
  "connection_rules.realtime_to_http.suggestion": "실시간 노드와 HTTP 요청 노드 사이에 ThrottleNode를 배치하여 요청 빈도를 제어하세요.",
1199
1212
  "fields.BaseOrderNode.rate_limit_interval": "최소 주문 간격 (초). 기본값 5초. 직전 주문 후 이 시간이 지나야 다음 주문이 실행됩니다.",
1200
1213
  "fields.BaseOrderNode.rate_limit_action": "주문 간격 미달 시 동작. skip: 조용히 건너뜀, error: 에러 발생",
@@ -126,7 +126,7 @@ BUILTIN_CREDENTIAL_SCHEMAS: Dict[str, CredentialTypeSchema] = {
126
126
  }
127
127
  ),
128
128
  # ============================================================
129
- # LS증권 해외선물 (overseas_futures) - 모의투자 지원
129
+ # LS증권 해외선물 (overseas_futures) - 실거래·모의투자 둘 다 지원(등록 폼에서 모드 선택)
130
130
  # ============================================================
131
131
  "broker_ls_overseas_futures": CredentialTypeSchema(
132
132
  type_id="broker_ls_overseas_futures",
@@ -42,6 +42,19 @@ class LLMModelNode(BaseNode):
42
42
  type: Literal["LLMModelNode"] = "LLMModelNode"
43
43
  category: NodeCategory = NodeCategory.AI
44
44
 
45
+ # 연결(자격증명) 선언 — 챗봇/편집기/검증기 공용. AI 모델 키는 실행에 반드시 필요하다.
46
+ # missing="ask" (오너 결정 2026-09-28): 키가 없으면 챗봇이 먼저 등록을 묻고, 사용자가
47
+ # "키 없이 진행" 이라고 명시할 때만 초안으로 저장한다("draft" 의 조용한 자동 초안과 구분).
48
+ _connection: ClassVar[Dict[str, Any]] = {
49
+ "purpose": "ai",
50
+ "need": "run",
51
+ "when": "always",
52
+ "label_key": "connection.LLMModelNode.label",
53
+ "presets": [],
54
+ "missing": "ask",
55
+ }
56
+
57
+
45
58
  # === LLM 설정 ===
46
59
  credential_id: Optional[str] = Field(
47
60
  default=None,
@@ -193,7 +206,7 @@ class LLMModelNode(BaseNode):
193
206
  },
194
207
  ]
195
208
  _node_guide: ClassVar[Dict[str, Any]] = {
196
- "input_handling": "credential_id is mandatory and fixed (no expressions). It must reference a registered credential of kind llm_openai, llm_anthropic, llm_deepseek or llm_google whose api_key the user entered themselves; until the user registers and selects such a key the workflow can be built and saved but not run. Set model to the exact model ID for that provider. No data input is needed — this node only establishes the API connection.",
209
+ "input_handling": "credential_id is mandatory and fixed (no expressions). It must reference a registered credential of kind llm_openai, llm_anthropic, llm_deepseek or llm_google whose api_key the user entered themselves; until the user registers and selects such a key the workflow can be built and saved but not run. Because the key is required to run, the chatbot asks the user to register the AI-model key before building (connection.missing='ask'); it only saves a key-free draft when the user explicitly says to proceed without it. Set model to the exact model ID for that provider. No data input is needed — this node only establishes the API connection.",
197
210
  "output_consumption": "The 'connection' output port uses edge type 'ai_model', NOT 'main'. Connect it to AIAgentNode's ai_model input. The connection object is not a data value — it is an internal LLM client handle passed to the agent.",
198
211
  "common_combinations": [
199
212
  "LLMModelNode → AIAgentNode (ai_model edge) — always paired",
@@ -223,9 +236,11 @@ class LLMModelNode(BaseNode):
223
236
  ),
224
237
  ]
225
238
 
226
- _version: ClassVar[str] = "1.0.0"
227
- _updated_at: ClassVar[str] = "2026-05-19"
228
- _change_note: ClassVar[Optional[str]] = None
239
+ _version: ClassVar[str] = "1.1.0"
240
+ _updated_at: ClassVar[str] = "2026-09-28"
241
+ _change_note: ClassVar[Optional[str]] = (
242
+ "connection.missing='ask' — chatbot asks the user to register the AI-model key before build"
243
+ )
229
244
 
230
245
  @classmethod
231
246
  def is_tool_enabled(cls) -> bool:
@@ -81,6 +81,19 @@ class OverseasStockBrokerNode(BaseBrokerNode):
81
81
  _product_scope: ClassVar[ProductScope] = ProductScope.STOCK
82
82
  _broker_provider: ClassVar[BrokerProvider] = BrokerProvider.LS
83
83
 
84
+ # 연결(자격증명) 선언 — 챗봇/편집기/검증기 공용. types 는 credential_id 필드의
85
+ # credential_types 에서 자동 추출된다. 이 계좌를 상속하는 시세/계좌/주문 노드는
86
+ # 자기 선언 없이 이 브로커 연결을 물려받는다.
87
+ _connection: ClassVar[Dict[str, Any]] = {
88
+ "purpose": "trading",
89
+ "need": "run",
90
+ "when": "always",
91
+ "label_key": "connection.OverseasStockBrokerNode.label",
92
+ "presets": [],
93
+ "missing": "draft",
94
+ }
95
+
96
+
84
97
  _usage: ClassVar[Dict[str, Any]] = {
85
98
  "when_to_use": [
86
99
  "Every workflow that reads overseas stock market data, account state, or places orders",
@@ -268,7 +281,10 @@ class OverseasFuturesBrokerNode(BaseBrokerNode):
268
281
  LS증권 OpenAPI를 통해 해외선물 거래를 위한 브로커 연결을 생성합니다.
269
282
 
270
283
  Note:
271
- - 해외선물은 모의투자 지원
284
+ - 해외선물은 **실거래와 모의투자 둘 다** 지원한다. 등록 폼에서 모드를 고르는 유일한
285
+ 상품이며, ``paper_trading`` 은 등록한 자격증명의 모드를 따른다. (모의투자를 자주
286
+ 쓰는 것은 증거금 비용 때문이지, 실거래가 안 되기 때문이 아니다.)
287
+ - 계좌가 여는 거래소(CME 등)는 **계좌마다 다르다** — 주문 가능 종목 조회로만 알 수 있다.
272
288
  - credential_types: broker_ls_overseas_futures
273
289
  """
274
290
 
@@ -280,6 +296,19 @@ class OverseasFuturesBrokerNode(BaseBrokerNode):
280
296
  _product_scope: ClassVar[ProductScope] = ProductScope.FUTURES
281
297
  _broker_provider: ClassVar[BrokerProvider] = BrokerProvider.LS
282
298
 
299
+ # 연결(자격증명) 선언 — 챗봇/편집기/검증기 공용. types 는 credential_id 필드의
300
+ # credential_types 에서 자동 추출된다. 이 계좌를 상속하는 시세/계좌/주문 노드는
301
+ # 자기 선언 없이 이 브로커 연결을 물려받는다.
302
+ _connection: ClassVar[Dict[str, Any]] = {
303
+ "purpose": "trading",
304
+ "need": "run",
305
+ "when": "always",
306
+ "label_key": "connection.OverseasFuturesBrokerNode.label",
307
+ "presets": [],
308
+ "missing": "draft",
309
+ }
310
+
311
+
283
312
  _usage: ClassVar[Dict[str, Any]] = {
284
313
  "when_to_use": [
285
314
  "Every overseas futures workflow — CME ES / NQ, SGX Nikkei, HKEX HSI mini, etc.",
@@ -442,6 +471,19 @@ class KoreaStockBrokerNode(BaseBrokerNode):
442
471
  _product_scope: ClassVar[ProductScope] = ProductScope.KOREA_STOCK
443
472
  _broker_provider: ClassVar[BrokerProvider] = BrokerProvider.LS
444
473
 
474
+ # 연결(자격증명) 선언 — 챗봇/편집기/검증기 공용. types 는 credential_id 필드의
475
+ # credential_types 에서 자동 추출된다. 이 계좌를 상속하는 시세/계좌/주문 노드는
476
+ # 자기 선언 없이 이 브로커 연결을 물려받는다.
477
+ _connection: ClassVar[Dict[str, Any]] = {
478
+ "purpose": "trading",
479
+ "need": "run",
480
+ "when": "always",
481
+ "label_key": "connection.KoreaStockBrokerNode.label",
482
+ "presets": [],
483
+ "missing": "draft",
484
+ }
485
+
486
+
445
487
  _usage: ClassVar[Dict[str, Any]] = {
446
488
  "when_to_use": [
447
489
  "Every KRX-listed (KOSPI / KOSDAQ / NXT) stock workflow",
@@ -13,7 +13,7 @@ MarketDataNode는 상품별 분리됨:
13
13
  계좌 조회는 account_stock/account_futures 참조
14
14
  """
15
15
 
16
- from typing import Optional, List, Literal, Dict, Any, ClassVar, TYPE_CHECKING
16
+ from typing import Optional, List, Literal, Dict, Any, ClassVar, Tuple, TYPE_CHECKING
17
17
  from pydantic import Field
18
18
 
19
19
  if TYPE_CHECKING:
@@ -47,8 +47,15 @@ class HTTPServerError(Exception):
47
47
 
48
48
 
49
49
  class HTTPRateLimitError(Exception):
50
- """HTTP 429 Rate Limit 에러 - 재시도 가능"""
51
- pass
50
+ """HTTP 429 Rate Limit 에러 - 재시도 가능.
51
+
52
+ `Retry-After` 헤더가 있으면 그 값을 `retry_after`(초, float)에 실어 RetryExecutor 가
53
+ 지수 백오프 대신(또는 그와 함께) 서버가 요구한 대기 시간을 존중하게 한다.
54
+ """
55
+
56
+ def __init__(self, message: str, retry_after: Optional[float] = None):
57
+ super().__init__(message)
58
+ self.retry_after = retry_after
52
59
 
53
60
 
54
61
  class HTTPNetworkError(Exception):
@@ -61,6 +68,72 @@ class HTTPTimeoutError(Exception):
61
68
  pass
62
69
 
63
70
 
71
+ class HTTPCredentialError(Exception):
72
+ """An authenticated HTTP node has no injected credential; no request was sent."""
73
+
74
+
75
+ # ── HTTP 외부 API 보호 (오너 우려 2026-09-28: 클라우드 공유 IP 로 종목마다 요청이 동시에
76
+ # 나가면 외부 API 사가 차단할 수 있다) ─────────────────────────────────────────────
77
+ # 호스트별 동시 요청 상한. auto-iterate 반복은 원래 순차 실행 + rate_limit_interval 간격이라
78
+ # 안전하지만, 실시간/병렬(fan-out) 경로에서 같은 호스트로 요청이 몰리는 것을 막는 프로세스
79
+ # 전역 가드다. 기본 1 = 한 호스트에 한 번에 한 요청. 상수는 여기 한 곳에서만 정의한다.
80
+ HTTP_MAX_CONCURRENCY_PER_HOST: int = 1
81
+
82
+ # (event-loop id, host) → Semaphore. asyncio.Semaphore 는 생성된 이벤트 루프에 묶이므로
83
+ # 루프별로 따로 만든다(워커는 보통 루프 하나지만, 다른 루프에서 재사용 시 RuntimeError 회피).
84
+ _HOST_SEMAPHORES: Dict[Tuple[int, str], "object"] = {}
85
+
86
+
87
+ def _host_semaphore(url: str):
88
+ """이 URL 호스트의 프로세스 전역 동시성 세마포어(현재 이벤트 루프 기준)."""
89
+ import asyncio
90
+ from urllib.parse import urlparse
91
+
92
+ try:
93
+ host = (urlparse(url).hostname or "").lower()
94
+ except Exception:
95
+ host = ""
96
+ try:
97
+ loop_id = id(asyncio.get_running_loop())
98
+ except RuntimeError:
99
+ loop_id = 0
100
+ key = (loop_id, host)
101
+ sem = _HOST_SEMAPHORES.get(key)
102
+ if sem is None:
103
+ sem = asyncio.Semaphore(HTTP_MAX_CONCURRENCY_PER_HOST)
104
+ _HOST_SEMAPHORES[key] = sem
105
+ return sem
106
+
107
+
108
+ def _parse_retry_after(value: Optional[str]) -> Optional[float]:
109
+ """HTTP `Retry-After` 헤더 → 대기 초. delta-seconds(정수) 또는 HTTP-date 지원.
110
+
111
+ 음수/파싱 실패는 None (그러면 RetryExecutor 가 순수 지수 백오프로 되돌아간다).
112
+ """
113
+ if not value:
114
+ return None
115
+ value = value.strip()
116
+ # delta-seconds (가장 흔한 형태)
117
+ try:
118
+ secs = float(value)
119
+ return secs if secs >= 0 else None
120
+ except (TypeError, ValueError):
121
+ pass
122
+ # HTTP-date (RFC 7231)
123
+ try:
124
+ from email.utils import parsedate_to_datetime
125
+ from datetime import datetime, timezone
126
+
127
+ dt = parsedate_to_datetime(value)
128
+ if dt is None:
129
+ return None
130
+ if dt.tzinfo is None:
131
+ dt = dt.replace(tzinfo=timezone.utc)
132
+ return max(0.0, (dt - datetime.now(timezone.utc)).total_seconds())
133
+ except Exception:
134
+ return None
135
+
136
+
64
137
  class SQLiteNode(BaseNode):
65
138
  """
66
139
  로컬 SQLite 데이터베이스 노드 (단순 DB)
@@ -552,6 +625,24 @@ class HTTPRequestNode(BaseNode):
552
625
  on_throttle="skip",
553
626
  )
554
627
 
628
+ # 연결(자격증명) 선언 — 챗봇/편집기/검증기 공용. HTTP 는 인증형 API 일 때만 키가
629
+ # 필요(when=auth_required, auth_required 설정 참조)하고, 실제 응답 검증에도 필요해
630
+ # need=validate 다. FMP/Finnhub 는 http_query 프리셋으로 param_name 을 잡아준다.
631
+ _connection: ClassVar[Dict[str, Any]] = {
632
+ "purpose": "data",
633
+ "need": "validate",
634
+ "when": "auth_required",
635
+ "label_key": "connection.HTTPRequestNode.label",
636
+ "presets": [
637
+ {"id": "fmp", "label": "FMP", "type": "http_query",
638
+ "fields": {"param_name": "apikey"}},
639
+ {"id": "finnhub", "label": "Finnhub", "type": "http_query",
640
+ "fields": {"param_name": "token"}},
641
+ ],
642
+ "missing": "draft",
643
+ }
644
+
645
+
555
646
  # === PARAMETERS: 핵심 HTTP 요청 설정 ===
556
647
  method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = Field(
557
648
  default="GET",
@@ -566,6 +657,10 @@ class HTTPRequestNode(BaseNode):
566
657
  default=None,
567
658
  description="Credential ID (credentials 섹션에서 참조)"
568
659
  )
660
+ credential_preset: Optional[Literal["fmp", "finnhub"]] = Field(
661
+ default=None,
662
+ description="Non-secret provider preset for the credential registration form; never selects a stored key.",
663
+ )
569
664
 
570
665
  # === Headers: UI에서 동적 추가 ===
571
666
  headers: Optional[Dict[str, str]] = Field(default=None, description="HTTP headers")
@@ -573,6 +668,15 @@ class HTTPRequestNode(BaseNode):
573
668
  # === SETTINGS: 부가 설정 ===
574
669
  timeout_seconds: int = Field(default=30, description="Request timeout (seconds)")
575
670
 
671
+ # 이 API 가 인증이 필요한가(비공개 키). None = 챗봇이 판단(기본) — 문맥으로 결정한다.
672
+ # True = 인증 필요(credential 없으면 검증/실행 불가), False = 공개 API(credential 불필요).
673
+ # 기본값을 False 로 두면 비공개 API 에 위험하므로 None(미지정)으로 둔다.
674
+ # `connection.when="auth_required"` 가 이 값을 참조한다.
675
+ auth_required: Optional[bool] = Field(
676
+ default=None,
677
+ description="Does this API require authentication? None = chatbot decides (default), True = auth required, False = public.",
678
+ )
679
+
576
680
  # === Resilience: 재시도/실패 처리 (H-21: HTTP 요청은 기본 재시도 활성화) ===
577
681
  resilience: ResilienceConfig = Field(
578
682
  default_factory=lambda: ResilienceConfig(
@@ -601,11 +705,13 @@ class HTTPRequestNode(BaseNode):
601
705
  }
602
706
  _features: ClassVar[List[str]] = [
603
707
  "Supports all HTTP methods: GET, POST, PUT, PATCH, DELETE with optional query params and request body",
604
- "Built-in resilience: retry on 5xx and 429 with exponential backoff (enabled by default, max_retries=3)",
708
+ "Built-in resilience: retry on 5xx and 429 with exponential backoff (enabled by default, max_retries=3). Retry-After is a minimum wait. When it exceeds max_delay or the 5-second validation wait budget, retrying stops with the original failure; the server's wait is never shortened. Existing fallback and per-item error handling apply.",
709
+ "Per-host concurrency cap (default 1): the engine lets only one request per host run at a time process-wide, so many parallel/fan-out requests to the same external API (shared cloud egress IP) queue instead of stampeding. Auto-iterate is already sequential and paced by rate_limit_interval.",
605
710
  "Credential integration for Bearer token, HTTP Basic, custom header, and query-param auth patterns",
606
711
  "Rate-limited: minimum 1-second interval and max 3 concurrent calls; real-time node connections blocked",
607
712
  "is_tool_enabled=True — AI Agent can invoke HTTPRequestNode as a tool to fetch live external data",
608
713
  "Outputs response (parsed JSON or raw text), status_code, success flag, and error string for downstream branching",
714
+ "Runs once per upstream list element when the config references {{ item… }}/{{ index }}/{{ total }} (e.g. one request per watchlist symbol); the per-item outcomes are exposed on the `results` array port while response/status_code/success/error hold the last item's values. A config without an item reference runs once (unchanged).",
609
715
  ]
610
716
  _anti_patterns: ClassVar[List[Dict[str, str]]] = [
611
717
  {
@@ -693,7 +799,7 @@ class HTTPRequestNode(BaseNode):
693
799
  ]
694
800
  _node_guide: ClassVar[Dict[str, Any]] = {
695
801
  "input_handling": "The 'url' field is the only required config. Use 'body' for POST/PUT/PATCH payloads (auto-serialized to JSON). Use 'query_params' for GET parameters. The 'data' input port can supply dynamic values from upstream nodes via expression.",
696
- "output_consumption": "Check 'success' (boolean) before consuming 'response'. On failure, 'error' contains the HTTP status string. 'response' is auto-parsed JSON when the Content-Type is application/json, otherwise raw text.",
802
+ "output_consumption": "Check 'success' (boolean) before consuming 'response'. On failure, 'error' contains the HTTP status string. 'response' is auto-parsed JSON when the Content-Type is application/json, otherwise raw text. When the config references {{ item… }} the node runs once per upstream item: read the 'results' array (one {item, response, status_code, success, error} entry per item, in order) for per-item outcomes; 'response'/'status_code'/'success'/'error' then reflect the LAST item, and top-level 'success' is the AND of all items while top-level 'error' is set only when every item failed.",
697
803
  "common_combinations": [
698
804
  "StartNode → HTTPRequestNode (GET) → FieldMappingNode → ConditionNode",
699
805
  "ConditionNode → HTTPRequestNode (POST webhook) → SummaryDisplayNode",
@@ -719,13 +825,31 @@ class HTTPRequestNode(BaseNode):
719
825
  OutputPort(name="status_code", type="number", description="i18n:ports.http_status_code"),
720
826
  OutputPort(name="success", type="boolean", description="i18n:ports.http_success"),
721
827
  OutputPort(name="error", type="string", description="i18n:ports.http_error"),
828
+ # Per-item results when the node auto-iterates over an upstream list
829
+ # (config references {{ item… }}/{{ index }}/{{ total }}). One entry per
830
+ # item, in order: {item, response, status_code, success, error}. A node
831
+ # that does not reference an iteration item runs once and this array holds
832
+ # that single execution. `response`/`status_code`/`success`/`error` above
833
+ # stay as the LAST item's scalar values (backward compatible with
834
+ # {{ nodes.x.response }}).
835
+ OutputPort(
836
+ name="results", type="array",
837
+ description="i18n:ports.http_results",
838
+ example=[
839
+ {"item": {"symbol": "AAPL", "exchange": "NASDAQ"},
840
+ "response": {"price": 189.2}, "status_code": 200,
841
+ "success": True, "error": None},
842
+ ],
843
+ ),
722
844
  ]
723
845
 
724
846
  _field_schema: ClassVar[Dict[str, "FieldSchema"]] = {}
725
847
 
726
- _version: ClassVar[str] = "1.0.0"
727
- _updated_at: ClassVar[str] = "2026-05-19"
728
- _change_note: ClassVar[Optional[str]] = None
848
+ _version: ClassVar[str] = "1.2.0"
849
+ _updated_at: ClassVar[str] = "2026-09-28"
850
+ _change_note: ClassVar[Optional[str]] = (
851
+ "429 honours Retry-After + per-host concurrency cap (1) for shared-IP safety"
852
+ )
729
853
 
730
854
  @classmethod
731
855
  def get_field_schema(cls) -> Dict[str, "FieldSchema"]:
@@ -785,6 +909,14 @@ class HTTPRequestNode(BaseNode):
785
909
  credential_types=["http_bearer", "http_header", "http_basic", "http_query"],
786
910
  ui_component=UIComponent.CUSTOM_CREDENTIAL_SELECT,
787
911
  ),
912
+ "credential_preset": FieldSchema(
913
+ name="credential_preset", type=FieldType.ENUM, required=False,
914
+ description="Non-secret provider preset for credential registration",
915
+ category=FieldCategory.PARAMETERS,
916
+ expression_mode=ExpressionMode.FIXED_ONLY,
917
+ enum_values=[p["id"] for p in cls._connection["presets"]],
918
+ group="advanced",
919
+ ),
788
920
  "headers": FieldSchema(
789
921
  name="headers", type=FieldType.KEY_VALUE_PAIRS, required=False,
790
922
  description="i18n:fields.HTTPRequestNode.headers",
@@ -812,6 +944,16 @@ class HTTPRequestNode(BaseNode):
812
944
  max_value=300,
813
945
  group="advanced",
814
946
  ),
947
+ "auth_required": FieldSchema(
948
+ name="auth_required", type=FieldType.BOOLEAN, required=False,
949
+ description="i18n:fields.HTTPRequestNode.auth_required",
950
+ category=FieldCategory.SETTINGS,
951
+ expression_mode=ExpressionMode.FIXED_ONLY,
952
+ example=True,
953
+ expected_type="bool",
954
+ group="advanced",
955
+ ),
956
+
815
957
  # === RESILIENCE: 재시도/실패 처리 (단순화된 UI) ===
816
958
  "resilience": FieldSchema(
817
959
  name="resilience", type=FieldType.OBJECT, required=False,
@@ -876,6 +1018,16 @@ class HTTPRequestNode(BaseNode):
876
1018
  - 5xx 서버 에러, 네트워크 에러, 타임아웃 → Exception raise → 재시도
877
1019
  - 4xx 클라이언트 에러 → 재시도 불가, 결과 반환
878
1020
  """
1021
+ if self.auth_required is True and not (
1022
+ getattr(self, "token", None)
1023
+ or (getattr(self, "header_name", None) and getattr(self, "header_value", None))
1024
+ or (getattr(self, "username", None) and getattr(self, "password", None))
1025
+ or (getattr(self, "param_name", None) and getattr(self, "param_value", None))
1026
+ ):
1027
+ raise HTTPCredentialError(
1028
+ "CREDENTIAL_REQUIRED_TO_RUN: register and bind this HTTP node's API credential"
1029
+ )
1030
+
879
1031
  import aiohttp
880
1032
  import asyncio
881
1033
  import json
@@ -929,41 +1081,49 @@ class HTTPRequestNode(BaseNode):
929
1081
  else:
930
1082
  request_kwargs["data"] = self.body
931
1083
 
932
- async with session.request(**request_kwargs) as resp:
933
- status_code = resp.status
934
-
935
- # 응답 파싱 (JSON 시도 → 실패하면 text)
936
- try:
937
- data = await resp.json()
938
- except Exception:
939
- data = await resp.text()
940
-
941
- # 5xx 서버 에러 → Exception raise (RetryExecutor가 재시도)
942
- if status_code >= 500:
943
- error_msg = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)[:200]
944
- raise HTTPServerError(f"HTTP {status_code}: {error_msg}")
945
-
946
- # 429 Rate Limit → Exception raise (RetryExecutor가 재시도)
947
- if status_code == 429:
948
- raise HTTPRateLimitError(f"HTTP 429: Rate limit exceeded")
949
-
950
- # 4xx 클라이언트 에러 → 재시도 불가, 결과 반환
951
- if status_code >= 400:
952
- return {
953
- "response": data,
954
- "status_code": status_code,
955
- "success": False,
956
- "error": f"HTTP {status_code}",
957
- }
958
-
959
- # 성공 (2xx, 3xx)
1084
+ # 호스트별 동시성 상한(기본 1) — 같은 호스트로 요청이 병렬로 몰리는 것을
1085
+ # 프로세스 전역에서 막는다(공유 IP 보호). 반복은 어차피 순차라 무영향.
1086
+ async with _host_semaphore(self.url):
1087
+ async with session.request(**request_kwargs) as resp:
1088
+ status_code = resp.status
1089
+ # Retry-After 는 응답 컨텍스트를 벗어나기 전에 읽어둔다.
1090
+ retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
1091
+
1092
+ # 응답 파싱 (JSON 시도 → 실패하면 text)
1093
+ try:
1094
+ data = await resp.json()
1095
+ except Exception:
1096
+ data = await resp.text()
1097
+
1098
+ # 5xx 서버 에러 → Exception raise (RetryExecutor가 재시도)
1099
+ if status_code >= 500:
1100
+ error_msg = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)[:200]
1101
+ raise HTTPServerError(f"HTTP {status_code}: {error_msg}")
1102
+
1103
+ # 429 Rate Limit → Exception raise (RetryExecutor가 지수 백오프 재시도).
1104
+ # Retry-After 헤더가 있으면 그 대기 시간을 예외에 실어 존중하게 한다.
1105
+ if status_code == 429:
1106
+ raise HTTPRateLimitError(
1107
+ "HTTP 429: Rate limit exceeded", retry_after=retry_after
1108
+ )
1109
+
1110
+ # 4xx 클라이언트 에러 → 재시도 불가, 결과 반환
1111
+ if status_code >= 400:
960
1112
  return {
961
1113
  "response": data,
962
1114
  "status_code": status_code,
963
- "success": True,
964
- "error": None,
1115
+ "success": False,
1116
+ "error": f"HTTP {status_code}",
965
1117
  }
966
1118
 
1119
+ # 성공 (2xx, 3xx)
1120
+ return {
1121
+ "response": data,
1122
+ "status_code": status_code,
1123
+ "success": True,
1124
+ "error": None,
1125
+ }
1126
+
967
1127
  except aiohttp.ClientError as e:
968
1128
  # 네트워크 에러 → RetryExecutor가 재시도
969
1129
  raise HTTPNetworkError(f"Network error: {e}")
@@ -74,6 +74,22 @@ class ScheduleNode(BaseNode):
74
74
  "investor named a fixed number of cycles (>= 1)."
75
75
  ),
76
76
  )
77
+ # Owner concern 2026-09-28: many workflows sharing one cron instant (e.g.
78
+ # "0 9 * * *") all fire at the same second and stampede a shared cloud
79
+ # egress IP. jitter_seconds staggers the ACTUAL fire by a random 0..jitter
80
+ # seconds past each cron instant. 0 (default) = fire exactly on the instant
81
+ # (unchanged). Max 300s. dry_run never applies jitter (it emits one tick and
82
+ # exits), so it does not affect the build budget or replay semantics.
83
+ jitter_seconds: int = Field(
84
+ default=0,
85
+ ge=0,
86
+ le=300,
87
+ description=(
88
+ "Random 0..N second delay applied to each fire past the cron instant, "
89
+ "to spread many same-cron workflows across a shared egress IP. "
90
+ "0 (default) fires exactly on the cron instant; max 300."
91
+ ),
92
+ )
77
93
 
78
94
  _inputs: List[InputPort] = []
79
95
  _outputs: List[OutputPort] = [
@@ -106,6 +122,7 @@ class ScheduleNode(BaseNode):
106
122
  "Standard 5-field cron expression — minute / hour / day / month / weekday",
107
123
  "Timezone-aware (IANA names) — 'America/New_York', 'Asia/Seoul', 'UTC'",
108
124
  "max_duration_hours and count are optional bounds: omit both and the schedule runs until the workflow is stopped; set one and the scheduler exits cleanly at that limit",
125
+ "jitter_seconds (optional, 0..300, default 0) delays each fire by a random 0..N seconds past the cron instant, so many workflows sharing one cron ('0 9 * * *') do not all hit a shared egress IP at the same second; the cron cadence anchor is unchanged and the logical replay instant is not affected. Keep jitter well below the interval (do not set 300s jitter on an every-minute cron).",
109
126
  "enabled=False freezes the trigger without removing the node from the DAG",
110
127
  "A tick re-executes the whole main flow: the ScheduleNode returns {trigger: true} without re-registering, so every node downstream of it runs again on each tick",
111
128
  "Startup account and open-order snapshots are not retained across schedule ticks; they are retained only across realtime events",
@@ -189,7 +206,7 @@ class ScheduleNode(BaseNode):
189
206
  },
190
207
  ]
191
208
  _node_guide: ClassVar[Dict[str, Any]] = {
192
- "input_handling": "No data inputs. All behavior is configured via `cron`, `timezone`, `enabled`, and the optional bounds `max_duration_hours` / `count` (omit both to run until the workflow is stopped).",
209
+ "input_handling": "No data inputs. All behavior is configured via `cron`, `timezone`, `enabled`, the optional bounds `max_duration_hours` / `count` (omit both to run until the workflow is stopped), and the optional `jitter_seconds` (0..300, default 0) which staggers each fire a random 0..N seconds past the cron instant to avoid a shared-IP stampede.",
193
210
  "output_consumption": "`trigger` output carries `{fired_at, cycle_index}`. Downstream nodes usually just need an incoming edge; explicit binding is optional.",
194
211
  "common_combinations": [
195
212
  "StartNode → ScheduleNode → trading body (plain cron workflow)",
@@ -203,9 +220,11 @@ class ScheduleNode(BaseNode):
203
220
  ],
204
221
  }
205
222
 
206
- _version: ClassVar[str] = "1.0.0"
207
- _updated_at: ClassVar[str] = "2026-05-19"
208
- _change_note: ClassVar[Optional[str]] = None
223
+ _version: ClassVar[str] = "1.1.0"
224
+ _updated_at: ClassVar[str] = "2026-09-28"
225
+ _change_note: ClassVar[Optional[str]] = (
226
+ "Added optional jitter_seconds (0..300) to stagger fires past the cron instant"
227
+ )
209
228
 
210
229
  @classmethod
211
230
  def get_field_schema(cls) -> Dict[str, "FieldSchema"]:
@@ -271,6 +290,18 @@ class ScheduleNode(BaseNode):
271
290
  expected_type="int",
272
291
  example=1000,
273
292
  ),
293
+ "jitter_seconds": FieldSchema(
294
+ name="jitter_seconds",
295
+ type=FieldType.INTEGER,
296
+ description="Random 0..N second delay applied to each fire past the cron instant, to spread many same-cron workflows across a shared egress IP. 0 (default) fires exactly on the cron instant; max 300.",
297
+ default=0,
298
+ min_value=0,
299
+ max_value=300,
300
+ expression_mode=ExpressionMode.FIXED_ONLY,
301
+ category=FieldCategory.SETTINGS,
302
+ expected_type="int",
303
+ example=30,
304
+ ),
274
305
  }
275
306
 
276
307
 
@@ -9,7 +9,7 @@ from typing import Optional, List, Dict, Any, Type
9
9
  from pydantic import BaseModel, Field
10
10
 
11
11
  from programgarden_core.nodes.base import BaseNode, NodeCategory, InputPort, OutputPort, ProductScope, BrokerProvider
12
- from programgarden_core.i18n import translate_schema, translate_category
12
+ from programgarden_core.i18n import translate_schema, translate_category, t
13
13
 
14
14
 
15
15
  class NodeTypeSchema(BaseModel):
@@ -112,6 +112,29 @@ class NodeTypeSchema(BaseModel):
112
112
  "time_rules, venue."
113
113
  ),
114
114
  )
115
+ connection: Optional[Dict[str, Any]] = Field(
116
+ default=None,
117
+ description=(
118
+ "Credential/connection declaration — one place the chatbot, editor "
119
+ "and validator read a node's credential need. Built by the core "
120
+ "registry from the node's `_connection` ClassVar + its credential "
121
+ "field's `credential_types` (locale-independent: both label_ko and "
122
+ "label_en are always present). None when the node needs no credential "
123
+ "of its own — broker-connected nodes (market data / account / order) "
124
+ "inherit their upstream broker's connection and declare nothing. "
125
+ "Keys (all present when set): types (== credential_types), purpose "
126
+ "(trading|data|ai|notify), need (run|validate|optional; run = needed "
127
+ "only to execute, validate = also needed for real-response "
128
+ "validation), when (always|auth_required|never; auth_required = only "
129
+ "when the node config marks the API authenticated), label_ko, "
130
+ "label_en (plain user-facing names), presets (HTTPRequestNode only; "
131
+ "[] elsewhere), missing (draft = may be saved without it and the run "
132
+ "gate blocks; block = must exist before build; ask = the chatbot "
133
+ "must ask the user to register the credential and only drafts "
134
+ "without it when the user explicitly says to proceed without it — "
135
+ "owner decision 2026-09-28 for AI-model keys and the Telegram bot)."
136
+ ),
137
+ )
115
138
 
116
139
  # === Version metadata (UI change detection) ===
117
140
  version: str = Field(
@@ -388,6 +411,14 @@ class NodeTypeRegistry:
388
411
 
389
412
  config_schema = self._build_config_schema(node_class, type_name)
390
413
 
414
+ # 연결(자격증명) 선언 직렬화 (_connection ClassVar → 챗봇/편집기/검증기 공용 dict).
415
+ # types 는 노드의 credential 필드가 export 한 credential_types 에서 그대로 뽑아
416
+ # 항상 동기화한다(별도 선언 안 함). label_ko/label_en 은 i18n 키를 양 언어로
417
+ # 즉시 해석해 locale-독립적으로 둘 다 싣는다(포트/설명과 달리 get_schema(locale)
418
+ # 재번역에 의존하지 않음). credential 필드가 없거나 _connection 미선언이면 None
419
+ # (브로커 연결을 상속하는 시세/계좌/주문 노드는 자기 선언이 없다).
420
+ connection = self._build_connection(node_class, config_schema)
421
+
391
422
  # Display 노드의 런타임 데이터 스키마
392
423
  display_data_schema = getattr(node_class, '_display_data_schema', None)
393
424
 
@@ -423,6 +454,7 @@ class NodeTypeRegistry:
423
454
  outputs=outputs,
424
455
  config_schema=config_schema,
425
456
  display_data_schema=display_data_schema,
457
+ connection=connection,
426
458
  connection_rules=serialized_connection_rules,
427
459
  rate_limit=serialized_rate_limit,
428
460
  usage=getattr(node_class, "_usage", None),
@@ -463,6 +495,45 @@ class NodeTypeRegistry:
463
495
 
464
496
  return result
465
497
 
498
+ # 닫힌 값 집합 — 잘못된 선언을 조기에 잡는다(테스트에서도 이 집합으로 검증).
499
+ _CONNECTION_PURPOSES = ("trading", "data", "ai", "notify")
500
+ _CONNECTION_NEEDS = ("run", "validate", "optional")
501
+ _CONNECTION_WHENS = ("always", "auth_required", "never")
502
+ _CONNECTION_MISSINGS = ("draft", "block", "ask")
503
+
504
+ def _build_connection(
505
+ self, node_class: Type[BaseNode], config_schema: Dict[str, Any],
506
+ ) -> Optional[Dict[str, Any]]:
507
+ """`_connection` ClassVar + credential_types → 공용 connection 선언.
508
+
509
+ 모든 키가 항상 존재한다. `types` 는 config_schema 안 credential 필드가 export 한
510
+ `credential_types` 의 합집합이라 선언과 어긋나지 않는다. `label_ko`/`label_en` 은
511
+ `label_key` i18n 키를 양 언어로 해석해 둘 다 싣는다(locale-독립).
512
+ """
513
+ decl = getattr(node_class, "_connection", None)
514
+ if not decl:
515
+ return None
516
+
517
+ cred_types: List[str] = []
518
+ for field_cfg in config_schema.values():
519
+ if not isinstance(field_cfg, dict):
520
+ continue
521
+ for ct in field_cfg.get("credential_types") or []:
522
+ if ct not in cred_types:
523
+ cred_types.append(ct)
524
+
525
+ label_key = decl.get("label_key", "")
526
+ return {
527
+ "types": cred_types,
528
+ "purpose": decl["purpose"],
529
+ "need": decl["need"],
530
+ "when": decl["when"],
531
+ "label_ko": t(label_key, "ko") if label_key else "",
532
+ "label_en": t(label_key, "en") if label_key else "",
533
+ "presets": [dict(p) for p in decl.get("presets", [])],
534
+ "missing": decl["missing"],
535
+ }
536
+
466
537
  def _build_config_schema(self, node_class: Type[BaseNode], node_type: str) -> Dict[str, Any]:
467
538
  """
468
539
  노드의 필드 스키마를 단순한 config_schema 형식으로 변환합니다.
@@ -516,6 +587,20 @@ class NodeTypeRegistry:
516
587
  return NodeTypeSchema(**translated)
517
588
  return schema
518
589
 
590
+ def connection_declaration(self, node_type: str) -> Optional[Dict[str, Any]]:
591
+ """이 노드가 필요로 하는 자격증명/연결 선언(챗봇·편집기·검증기 공용).
592
+
593
+ credential 이 필요 없는 노드(브로커 연결을 상속하는 시세/계좌/주문 등)는 None.
594
+ 반환 dict 는 복사본이라 호출부가 수정해도 캐시에 영향 없다.
595
+ """
596
+ schema = self._schemas.get(node_type)
597
+ if schema is None or schema.connection is None:
598
+ return None
599
+ conn = dict(schema.connection)
600
+ conn["types"] = list(conn.get("types", []))
601
+ conn["presets"] = [dict(p) for p in conn.get("presets", [])]
602
+ return conn
603
+
519
604
  def list_types(self, category: Optional[str] = None) -> List[str]:
520
605
  """List registered node types"""
521
606
  if category:
@@ -34,6 +34,10 @@ if TYPE_CHECKING:
34
34
 
35
35
  logger = logging.getLogger("programgarden.retry_executor")
36
36
 
37
+ # Validation has a bounded wait budget. If Retry-After exceeds it, stop this
38
+ # retry sequence and preserve the failure instead of contacting the API early.
39
+ DRY_RUN_MAX_RETRY_WAIT_SEC: float = 5.0
40
+
37
41
 
38
42
  class RetryExecutor:
39
43
  """
@@ -106,12 +110,23 @@ class RetryExecutor:
106
110
  )
107
111
  break
108
112
 
109
- # 대기 시간 계산 (exponential backoff with jitter)
110
- delay = self._calculate_delay(config.retry, attempt)
113
+ # A server-directed wait is a floor, never a delay to truncate.
114
+ retry_after = getattr(e, "retry_after", None)
115
+ dry_run = bool(getattr(context, "is_dry_run", False))
116
+ delay = self._calculate_delay(
117
+ config.retry, attempt, retry_after=retry_after, dry_run=dry_run
118
+ )
119
+ if delay is None:
120
+ logger.warning(
121
+ "[%s] Retry-After exceeds the retry wait budget; no further attempt",
122
+ node.id,
123
+ )
124
+ break
111
125
 
112
126
  logger.info(
113
127
  f"[{node.id}] {error_type.value} 발생, "
114
128
  f"재시도 {attempt}/{config.retry.max_retries}... {delay:.1f}초 후"
129
+ + (f" (Retry-After={retry_after:.0f}s)" if retry_after else "")
115
130
  )
116
131
 
117
132
  # 재시도 이벤트 발송 (context.notify_retry 사용)
@@ -143,13 +158,14 @@ class RetryExecutor:
143
158
  category=NotificationCategory.RETRY_EXHAUSTED,
144
159
  severity=NotificationSeverity.WARNING,
145
160
  title=f"Retry exhausted: {node.id}",
146
- message=f"{node.__class__.__name__} failed after {config.retry.max_retries} retries: {last_error}",
161
+ message=f"{node.__class__.__name__} failed after {attempt} attempts: {last_error}",
147
162
  node_id=node.id,
148
163
  node_type=node.__class__.__name__,
149
164
  data={
150
165
  "node_id": node.id,
151
166
  "node_type": node.__class__.__name__,
152
167
  "max_retries": config.retry.max_retries,
168
+ "attempts": attempt,
153
169
  "last_error": str(last_error),
154
170
  },
155
171
  )
@@ -159,16 +175,19 @@ class RetryExecutor:
159
175
  # Fallback 처리
160
176
  return self._handle_fallback(node, last_error, config.fallback)
161
177
 
162
- def _calculate_delay(self, config: RetryConfig, attempt: int) -> float:
178
+ def _calculate_delay(
179
+ self,
180
+ config: RetryConfig,
181
+ attempt: int,
182
+ retry_after: Optional[float] = None,
183
+ dry_run: bool = False,
184
+ ) -> Optional[float]:
163
185
  """
164
- 대기 시간 계산 (exponential backoff with jitter).
165
-
166
- Args:
167
- config: RetryConfig
168
- attempt: 현재 시도 횟수 (1부터 시작)
186
+ Return bounded exponential backoff with the server's minimum wait.
169
187
 
170
- Returns:
171
- 대기 시간 (초)
188
+ None means Retry-After cannot fit the configured wait budget: callers
189
+ must end the retry sequence, retaining the original failure. Ordinary
190
+ backoff remains capped when no server-directed floor prevents a retry.
172
191
  """
173
192
  if config.exponential_backoff:
174
193
  # 2^(attempt-1) * base_delay
@@ -183,8 +202,10 @@ class RetryExecutor:
183
202
  jitter = delay * 0.25 * (random.random() * 2 - 1)
184
203
  delay = delay + jitter
185
204
 
186
- # max_delay 제한
187
- return min(delay, config.max_delay)
205
+ limit = min(config.max_delay, DRY_RUN_MAX_RETRY_WAIT_SEC) if dry_run else config.max_delay
206
+ if retry_after is not None and retry_after > limit:
207
+ return None
208
+ return max(min(delay, limit), retry_after or 0.0)
188
209
 
189
210
  def _handle_fallback(
190
211
  self,
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden-core"
8
- version = "2.4.0"
8
+ version = "2.5.1"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의"
11
11
  readme = "README.md"