programgarden 1.4.0__tar.gz → 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {programgarden-1.4.0 → programgarden-1.5.0}/PKG-INFO +4 -4
  2. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/executor.py +142 -1
  3. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/resolver.py +3 -0
  4. {programgarden-1.4.0 → programgarden-1.5.0}/pyproject.toml +4 -4
  5. {programgarden-1.4.0 → programgarden-1.5.0}/README.md +0 -0
  6. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/__init__.py +0 -0
  7. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/binding_validator.py +0 -0
  8. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/client.py +0 -0
  9. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/context.py +0 -0
  10. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/database/__init__.py +0 -0
  11. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/database/query_builder.py +0 -0
  12. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/database/workflow_position_tracker.py +0 -0
  13. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  14. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/plugin/__init__.py +0 -0
  15. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/plugin/sandbox.py +0 -0
  16. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/providers/__init__.py +0 -0
  17. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/providers/llm_errors.py +0 -0
  18. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/providers/llm_provider.py +0 -0
  19. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/reconnect_handler.py +0 -0
  20. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/resource/__init__.py +0 -0
  21. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/resource/context.py +0 -0
  22. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/resource/limiter.py +0 -0
  23. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/resource/monitor.py +0 -0
  24. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/resource/throttle.py +0 -0
  25. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/tools/__init__.py +0 -0
  26. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/tools/credential_tools.py +0 -0
  27. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/tools/definition_tools.py +0 -0
  28. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/tools/event_tools.py +0 -0
  29. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/tools/job_tools.py +0 -0
  30. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/tools/registry_tools.py +0 -0
  31. {programgarden-1.4.0 → programgarden-1.5.0}/programgarden/tools/sqlite_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: programgarden
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  Author: 프로그램동산
6
6
  Author-email: coding@programgarden.com
@@ -13,9 +13,9 @@ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
13
13
  Requires-Dist: croniter (>=6.0.0,<7.0.0)
14
14
  Requires-Dist: litellm (>=1.40.0)
15
15
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
16
- Requires-Dist: programgarden-community (>=1.4.0,<2.0.0)
17
- Requires-Dist: programgarden-core (>=1.2.0,<2.0.0)
18
- Requires-Dist: programgarden-finance (>=1.2.0,<2.0.0)
16
+ Requires-Dist: programgarden-community (>=1.5.0,<2.0.0)
17
+ Requires-Dist: programgarden-core (>=1.3.0,<2.0.0)
18
+ Requires-Dist: programgarden-finance (>=1.3.0,<2.0.0)
19
19
  Requires-Dist: psutil (>=6.0.0,<7.0.0)
20
20
  Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
21
21
  Requires-Dist: pydantic (>=2.0.0,<3.0.0)
@@ -6181,6 +6181,68 @@ class LogicNodeExecutor(NodeExecutorBase):
6181
6181
  return final_result, final_passed
6182
6182
 
6183
6183
 
6184
+ class IfNodeExecutor(NodeExecutorBase):
6185
+ """
6186
+ IfNode executor - 조건 분기
6187
+
6188
+ left op right를 평가하여 true/false 포트로 흐름 분기.
6189
+ - true 포트: 조건 충족 시 upstream 데이터 pass-through
6190
+ - false 포트: 조건 미충족 시 upstream 데이터 pass-through
6191
+ - result 포트: 조건 평가 결과 (boolean)
6192
+ """
6193
+
6194
+ async def execute(
6195
+ self,
6196
+ node_id: str,
6197
+ node_type: str,
6198
+ config: Dict[str, Any],
6199
+ context: ExecutionContext,
6200
+ **kwargs,
6201
+ ) -> Dict[str, Any]:
6202
+ config = evaluate_all_bindings(config, context, node_id)
6203
+
6204
+ left = config.get("left")
6205
+ operator = config.get("operator", "==")
6206
+ right = config.get("right")
6207
+
6208
+ result = self._evaluate(left, operator, right)
6209
+ context.log(
6210
+ "info",
6211
+ f"IfNode: {left!r} {operator} {right!r} → {result}",
6212
+ node_id,
6213
+ )
6214
+
6215
+ # upstream에서 전달된 데이터 (pass-through)
6216
+ passthrough = kwargs.get("input_data") or {}
6217
+
6218
+ return {
6219
+ "true": passthrough if result else None,
6220
+ "false": passthrough if not result else None,
6221
+ "result": result,
6222
+ "_if_branch": "true" if result else "false",
6223
+ }
6224
+
6225
+ @staticmethod
6226
+ def _evaluate(left: Any, operator: str, right: Any) -> bool:
6227
+ """비교 연산 수행"""
6228
+ try:
6229
+ if operator == "==": return left == right
6230
+ if operator == "!=": return left != right
6231
+ if operator == ">": return float(left) > float(right)
6232
+ if operator == ">=": return float(left) >= float(right)
6233
+ if operator == "<": return float(left) < float(right)
6234
+ if operator == "<=": return float(left) <= float(right)
6235
+ if operator == "in": return left in right
6236
+ if operator == "not_in": return left not in right
6237
+ if operator == "contains": return right in left
6238
+ if operator == "not_contains": return right not in left
6239
+ if operator == "is_empty": return not left
6240
+ if operator == "is_not_empty": return bool(left)
6241
+ except (TypeError, ValueError):
6242
+ return False
6243
+ return False
6244
+
6245
+
6184
6246
  class MarketDataNodeExecutor(NodeExecutorBase):
6185
6247
  """
6186
6248
  MarketDataNode executor - REST API 현재가 조회 (당일 데이터만)
@@ -11511,6 +11573,7 @@ class WorkflowExecutor:
11511
11573
  "DisplayNode": DisplayNodeExecutor(),
11512
11574
  "ConditionNode": ConditionNodeExecutor(),
11513
11575
  "LogicNode": LogicNodeExecutor(), # 조건 조합
11576
+ "IfNode": IfNodeExecutor(), # 조건 분기
11514
11577
  # Backtest nodes
11515
11578
  "HistoricalDataNode": HistoricalDataNodeExecutor(),
11516
11579
  "OverseasStockHistoricalDataNode": HistoricalDataNodeExecutor(),
@@ -12052,6 +12115,9 @@ class WorkflowJob:
12052
12115
  # Track which SplitNodes have been processed
12053
12116
  processed_splits: Dict[str, bool] = {}
12054
12117
 
12118
+ # IfNode: 비활성 브랜치 스킵 집합 (IfNode 실행 후 동적 갱신)
12119
+ if_skipped_nodes: Set[str] = set()
12120
+
12055
12121
  # 🆕 실행 시작 전 모든 노드 상태를 pending으로 리셋 (UI 깜빡임 효과)
12056
12122
  for node_id in self.workflow.execution_order:
12057
12123
  node = self.workflow.nodes.get(node_id)
@@ -12090,6 +12156,16 @@ class WorkflowJob:
12090
12156
  print(f" ⏭ Skipping AggregateNode: {node_id} (already executed by SplitNode)")
12091
12157
  continue
12092
12158
 
12159
+ # === IfNode: 비활성 브랜치 스킵 ===
12160
+ if node_id in if_skipped_nodes:
12161
+ print(f" ⏭ Skipping node: {node_id} (IfNode branch not taken)")
12162
+ await self.context.notify_node_state(
12163
+ node_id=node_id,
12164
+ node_type=node.node_type,
12165
+ state=NodeState.COMPLETED,
12166
+ )
12167
+ continue
12168
+
12093
12169
  print(f" ▶ Executing node: {node_id} ({node.node_type})")
12094
12170
 
12095
12171
  # 🆕 노드 실행 시작 알림
@@ -12216,6 +12292,14 @@ class WorkflowJob:
12216
12292
  workflow=self.workflow,
12217
12293
  )
12218
12294
 
12295
+ # IfNode: 스킵 노드 계산 후 내부 키 제거
12296
+ if node.node_type == "IfNode" and outputs:
12297
+ taken = outputs.pop("_if_branch", "true")
12298
+ new_skips = self._compute_if_skip_nodes(node_id, taken)
12299
+ if_skipped_nodes.update(new_skips)
12300
+ if new_skips:
12301
+ print(f" 🔀 IfNode {node_id}: branch={taken}, skipping {new_skips}")
12302
+
12219
12303
  # Store outputs
12220
12304
  for out_port_name, value in outputs.items():
12221
12305
  self.context.set_output(node_id, out_port_name, value)
@@ -12252,7 +12336,7 @@ class WorkflowJob:
12252
12336
  # 자동 반복 실행에서 제외할 노드 타입 (배열을 그대로 처리하는 노드)
12253
12337
  NO_AUTO_ITERATE_NODE_TYPES = {
12254
12338
  "SplitNode", "AggregateNode", # 명시적 반복 제어 노드
12255
- "StartNode", "ThrottleNode", # 인프라 노드
12339
+ "StartNode", "ThrottleNode", "IfNode", # 인프라 노드
12256
12340
  "TableDisplayNode", "LineChartNode", "MultiLineChartNode", # 디스플레이 노드 (배열 표시)
12257
12341
  "CandlestickChartNode", "BarChartNode", "SummaryDisplayNode",
12258
12342
  "WatchlistNode", "MarketUniverseNode", "ScreenerNode", # 배열 생성 노드
@@ -12386,6 +12470,63 @@ class WorkflowJob:
12386
12470
 
12387
12471
  return merged
12388
12472
 
12473
+ def _compute_if_skip_nodes(self, if_node_id: str, taken_branch: str) -> Set[str]:
12474
+ """IfNode 실행 결과에 따라 스킵할 노드 집합 계산 (캐스케이딩)
12475
+
12476
+ 비활성 브랜치(from_port)의 하위 노드를 BFS로 탐색하고,
12477
+ 다른 활성 경로가 없는 노드만 스킵합니다.
12478
+
12479
+ Args:
12480
+ if_node_id: IfNode ID
12481
+ taken_branch: 선택된 브랜치 ("true" or "false")
12482
+
12483
+ Returns:
12484
+ 스킵할 노드 ID 집합
12485
+ """
12486
+ skipped_port = "false" if taken_branch == "true" else "true"
12487
+
12488
+ # 1. 비활성 브랜치의 직접 하위 노드 찾기
12489
+ initial_skip = set()
12490
+ for edge in self.workflow.edges:
12491
+ if (edge.from_node_id == if_node_id
12492
+ and edge.is_dag_edge
12493
+ and edge.from_port == skipped_port):
12494
+ initial_skip.add(edge.to_node_id)
12495
+
12496
+ # 2. BFS로 캐스케이딩 스킵 전파
12497
+ skip_nodes: Set[str] = set()
12498
+ queue = list(initial_skip)
12499
+
12500
+ while queue:
12501
+ current = queue.pop(0)
12502
+ if current in skip_nodes:
12503
+ continue
12504
+
12505
+ # 이 노드의 모든 incoming main edge 확인
12506
+ incoming = [e for e in self.workflow.edges
12507
+ if e.to_node_id == current and e.is_dag_edge]
12508
+
12509
+ all_inactive = True
12510
+ for edge in incoming:
12511
+ if edge.from_node_id == if_node_id:
12512
+ # IfNode에서 오는 edge: 활성 브랜치면 스킵 안 함
12513
+ if edge.from_port == taken_branch or edge.from_port is None:
12514
+ all_inactive = False
12515
+ break
12516
+ elif edge.from_node_id not in skip_nodes:
12517
+ # 다른 활성 노드에서 오는 edge가 있으면 스킵 안 함
12518
+ all_inactive = False
12519
+ break
12520
+
12521
+ if all_inactive:
12522
+ skip_nodes.add(current)
12523
+ # 하위 노드도 확인 대상에 추가
12524
+ for edge in self.workflow.edges:
12525
+ if edge.from_node_id == current and edge.is_dag_edge:
12526
+ queue.append(edge.to_node_id)
12527
+
12528
+ return skip_nodes
12529
+
12389
12530
  def _find_split_aggregate_pairs(self) -> Dict[str, str]:
12390
12531
  """Find pairs of SplitNode → AggregateNode in the workflow
12391
12532
 
@@ -64,10 +64,12 @@ class ResolvedEdge:
64
64
  from_node_id: str,
65
65
  to_node_id: str,
66
66
  edge_type: str = "main",
67
+ from_port: str = None,
67
68
  ):
68
69
  self.from_node_id = from_node_id
69
70
  self.to_node_id = to_node_id
70
71
  self.edge_type = edge_type
72
+ self.from_port = from_port
71
73
 
72
74
  @property
73
75
  def is_dag_edge(self) -> bool:
@@ -512,6 +514,7 @@ class WorkflowResolver:
512
514
  from_node_id=edge.from_node_id,
513
515
  to_node_id=edge.to_node_id,
514
516
  edge_type=edge_type_str,
517
+ from_port=getattr(edge, 'from_port', None),
515
518
  )
516
519
  if edge.is_dag_edge:
517
520
  dag_edges.append(resolved_edge)
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.4.0"
8
+ version = "1.5.0"
9
9
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
10
10
  readme = "README.md"
11
11
 
@@ -28,9 +28,9 @@ lxml = "^6.0.2"
28
28
  pytickersymbols = {version = ">=1.17.5", python = ">=3.12,<4.0"}
29
29
  aiosqlite = "^0.20.0"
30
30
  litellm = ">=1.40.0"
31
- programgarden-core = "^1.2.0"
32
- programgarden-finance = "^1.2.0"
33
- programgarden-community = "^1.4.0"
31
+ programgarden-core = "^1.3.0"
32
+ programgarden-finance = "^1.3.0"
33
+ programgarden-community = "^1.5.0"
34
34
 
35
35
  [tool.poetry.group.dev.dependencies]
36
36
  pytest = "^8.0.0"
File without changes