programgarden-core 1.12.2__tar.gz → 1.12.4__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 (82) hide show
  1. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/PKG-INFO +1 -1
  2. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/__init__.py +15 -1
  3. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/__init__.py +29 -0
  4. programgarden_core-1.12.4/programgarden_core/models/validation.py +329 -0
  5. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/workflow.py +89 -22
  6. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/backtest_futures.py +2 -1
  7. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/backtest_korea_stock.py +2 -1
  8. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/backtest_stock.py +22 -1
  9. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/base.py +12 -0
  10. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/condition.py +43 -23
  11. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/data.py +6 -0
  12. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/data_korea_stock.py +1 -0
  13. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/data_stock.py +5 -0
  14. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/fundamental_korea_stock.py +1 -0
  15. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/fundamental_stock.py +1 -0
  16. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/infra.py +5 -0
  17. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/portfolio.py +6 -0
  18. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/symbol.py +54 -1
  19. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/pyproject.toml +1 -1
  20. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/README.md +0 -0
  21. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/bases/__init__.py +0 -0
  22. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/bases/client.py +0 -0
  23. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/bases/components.py +0 -0
  24. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/bases/listener.py +0 -0
  25. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/bases/mixins.py +0 -0
  26. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/bases/products.py +0 -0
  27. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/bases/sql.py +0 -0
  28. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/bases/storage.py +0 -0
  29. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/exceptions.py +0 -0
  30. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/expression/__init__.py +0 -0
  31. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/expression/evaluator.py +0 -0
  32. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/i18n/__init__.py +0 -0
  33. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/i18n/locales/en.json +0 -0
  34. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/i18n/locales/ko.json +0 -0
  35. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/i18n/translator.py +0 -0
  36. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/korea_alias.py +0 -0
  37. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/connection_rule.py +0 -0
  38. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/credential.py +0 -0
  39. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/edge.py +0 -0
  40. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/event.py +0 -0
  41. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/exchange.py +0 -0
  42. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/field_binding.py +0 -0
  43. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/job.py +0 -0
  44. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/plugin_resource.py +0 -0
  45. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/resilience.py +0 -0
  46. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/models/resource.py +0 -0
  47. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/__init__.py +0 -0
  48. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/account_futures.py +0 -0
  49. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/account_korea_stock.py +0 -0
  50. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/account_stock.py +0 -0
  51. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/ai.py +0 -0
  52. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/backtest.py +0 -0
  53. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/broker.py +0 -0
  54. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/calculation.py +0 -0
  55. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/data_futures.py +0 -0
  56. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/display.py +0 -0
  57. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/event.py +0 -0
  58. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/market_external.py +0 -0
  59. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/market_status.py +0 -0
  60. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/open_orders_futures.py +0 -0
  61. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/open_orders_korea_stock.py +0 -0
  62. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/open_orders_stock.py +0 -0
  63. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/order.py +0 -0
  64. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/realtime_futures.py +0 -0
  65. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/realtime_korea_stock.py +0 -0
  66. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/realtime_stock.py +0 -0
  67. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/risk.py +0 -0
  68. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/symbol_futures.py +0 -0
  69. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/symbol_korea_stock.py +0 -0
  70. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/symbol_stock.py +0 -0
  71. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/nodes/trigger.py +0 -0
  72. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/presets/__init__.py +0 -0
  73. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/presets/news_analyst.json +0 -0
  74. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/presets/risk_manager.json +0 -0
  75. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/presets/strategist.json +0 -0
  76. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/presets/technical_analyst.json +0 -0
  77. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/registry/__init__.py +0 -0
  78. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/registry/credential_registry.py +0 -0
  79. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/registry/dynamic_node_registry.py +0 -0
  80. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/registry/node_registry.py +0 -0
  81. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/registry/plugin_registry.py +0 -0
  82. {programgarden_core-1.12.2 → programgarden_core-1.12.4}/programgarden_core/retry_executor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: programgarden-core
3
- Version: 1.12.2
3
+ Version: 1.12.4
4
4
  Summary: ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의
5
5
  Author: 프로그램동산
6
6
  Author-email: coding@programgarden.com
@@ -24,7 +24,7 @@ from programgarden_core.exceptions import *
24
24
  from programgarden_core import korea_alias
25
25
  from programgarden_core import bases
26
26
 
27
- __version__ = "2.0.0"
27
+ __version__ = "1.12.4"
28
28
  __all__ = [
29
29
  # Nodes - Base
30
30
  "BaseNode",
@@ -96,6 +96,20 @@ __all__ = [
96
96
  "JobState",
97
97
  "BrokerCredential",
98
98
  "Event",
99
+ # Validation (structured errors + recommendations)
100
+ "ErrorSeverity",
101
+ "ErrorCode",
102
+ "ErrorLocation",
103
+ "ErrorInfo",
104
+ "RecommendationCategory",
105
+ "Recommendation",
106
+ "ValidationLimits",
107
+ "ResultSummary",
108
+ "ValidationResult",
109
+ "CASCADE_SUPPRESSION_RULES",
110
+ "default_severity_for",
111
+ "suggest_close_match",
112
+ "build_error",
99
113
  # Registry
100
114
  "NodeTypeRegistry",
101
115
  "PluginRegistry",
@@ -78,6 +78,21 @@ from programgarden_core.models.connection_rule import (
78
78
  ConnectionRule,
79
79
  RateLimitConfig,
80
80
  )
81
+ from programgarden_core.models.validation import (
82
+ ErrorSeverity,
83
+ ErrorCode,
84
+ ErrorLocation,
85
+ ErrorInfo,
86
+ RecommendationCategory,
87
+ Recommendation,
88
+ ValidationLimits,
89
+ ResultSummary,
90
+ ValidationResult,
91
+ CASCADE_SUPPRESSION_RULES,
92
+ default_severity_for,
93
+ suggest_close_match,
94
+ build_error,
95
+ )
81
96
 
82
97
  __all__ = [
83
98
  # Edge
@@ -150,4 +165,18 @@ __all__ = [
150
165
  "ConnectionSeverity",
151
166
  "ConnectionRule",
152
167
  "RateLimitConfig",
168
+ # Validation
169
+ "ErrorSeverity",
170
+ "ErrorCode",
171
+ "ErrorLocation",
172
+ "ErrorInfo",
173
+ "RecommendationCategory",
174
+ "Recommendation",
175
+ "ValidationLimits",
176
+ "ResultSummary",
177
+ "ValidationResult",
178
+ "CASCADE_SUPPRESSION_RULES",
179
+ "default_severity_for",
180
+ "suggest_close_match",
181
+ "build_error",
153
182
  ]
@@ -0,0 +1,329 @@
1
+ """Structured validation errors for AI-chatbot-friendly workflow validation.
2
+
3
+ ErrorInfo objects let downstream consumers (AI chatbots, IDEs, dashboards)
4
+ make deterministic self-correction decisions based on error codes and
5
+ structured location metadata instead of parsing free-form strings.
6
+
7
+ All user-facing strings in this module (and all consumers) must be English.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from difflib import get_close_matches
12
+ from enum import Enum
13
+ from typing import Any, Dict, Iterable, List, Optional
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+
18
+ class ErrorSeverity(str, Enum):
19
+ ERROR = "error"
20
+ WARNING = "warning"
21
+
22
+
23
+ class ErrorCode(str, Enum):
24
+ # Definition / structure
25
+ DEFINITION_PARSE_ERROR = "DEFINITION_PARSE_ERROR"
26
+ DUPLICATE_NODE_ID = "DUPLICATE_NODE_ID"
27
+ RESERVED_NODE_ID = "RESERVED_NODE_ID"
28
+ MISSING_START_NODE = "MISSING_START_NODE"
29
+ MULTIPLE_START_NODES = "MULTIPLE_START_NODES"
30
+ CYCLE_DETECTED = "CYCLE_DETECTED"
31
+
32
+ # Node / plugin registry
33
+ UNKNOWN_NODE_TYPE = "UNKNOWN_NODE_TYPE"
34
+ UNKNOWN_DYNAMIC_NODE_SCHEMA = "UNKNOWN_DYNAMIC_NODE_SCHEMA"
35
+ DYNAMIC_NODE_CREDENTIAL_FORBIDDEN = "DYNAMIC_NODE_CREDENTIAL_FORBIDDEN"
36
+ DYNAMIC_NODE_CLASS_NOT_INJECTED = "DYNAMIC_NODE_CLASS_NOT_INJECTED"
37
+ MISSING_PLUGIN = "MISSING_PLUGIN"
38
+ UNKNOWN_PLUGIN = "UNKNOWN_PLUGIN"
39
+
40
+ # Edges
41
+ INVALID_EDGE_REF = "INVALID_EDGE_REF"
42
+ INVALID_EDGE_PORT = "INVALID_EDGE_PORT"
43
+ INVALID_AI_MODEL_EDGE = "INVALID_AI_MODEL_EDGE"
44
+ INVALID_TOOL_EDGE = "INVALID_TOOL_EDGE"
45
+
46
+ # Expressions / fields
47
+ INVALID_EXPRESSION_REF = "INVALID_EXPRESSION_REF"
48
+ INVALID_EXPRESSION_SYNTAX = "INVALID_EXPRESSION_SYNTAX"
49
+ MISSING_REQUIRED_FIELD = "MISSING_REQUIRED_FIELD"
50
+ INVALID_FIELD_TYPE = "INVALID_FIELD_TYPE"
51
+ INVALID_FIELD_ENUM = "INVALID_FIELD_ENUM"
52
+
53
+ # Credentials
54
+ UNKNOWN_CREDENTIAL = "UNKNOWN_CREDENTIAL"
55
+
56
+ # Broker / connection rules
57
+ DUPLICATE_BROKER_NODE = "DUPLICATE_BROKER_NODE"
58
+ MISSING_REQUIRED_BROKER = "MISSING_REQUIRED_BROKER"
59
+ INCOMPATIBLE_BROKER_PROVIDER = "INCOMPATIBLE_BROKER_PROVIDER"
60
+ CONNECTION_RULE_VIOLATION = "CONNECTION_RULE_VIOLATION"
61
+
62
+ # Runtime (dry_run)
63
+ DRY_RUN_RUNTIME_ERROR = "DRY_RUN_RUNTIME_ERROR"
64
+ DRY_RUN_CREDENTIAL_MISSING = "DRY_RUN_CREDENTIAL_MISSING"
65
+ DRY_RUN_DEPENDENCY_FAILURE = "DRY_RUN_DEPENDENCY_FAILURE"
66
+
67
+
68
+ _DEFAULT_SEVERITY: Dict[ErrorCode, ErrorSeverity] = {
69
+ ErrorCode.UNKNOWN_PLUGIN: ErrorSeverity.WARNING,
70
+ }
71
+
72
+
73
+ def default_severity_for(code: ErrorCode) -> ErrorSeverity:
74
+ return _DEFAULT_SEVERITY.get(code, ErrorSeverity.ERROR)
75
+
76
+
77
+ class ErrorLocation(BaseModel):
78
+ """Where in the workflow definition the error occurred. All fields optional."""
79
+
80
+ node_id: Optional[str] = Field(default=None, description="Node id from definition.nodes[].id")
81
+ node_type: Optional[str] = Field(default=None, description="Node type (e.g. 'OverseasStockNewOrderNode')")
82
+ field_path: Optional[str] = Field(default=None, description="Dot/bracket notation: 'fields.period' or 'symbols[0].symbol'")
83
+ edge_index: Optional[int] = Field(default=None, description="Index into definition.edges[]")
84
+ edge_from: Optional[str] = Field(default=None, description="Edge.from raw value (may include dot port)")
85
+ edge_to: Optional[str] = Field(default=None, description="Edge.to raw value")
86
+ credential_id: Optional[str] = Field(default=None, description="Credential id from definition.credentials[]")
87
+ plugin_id: Optional[str] = Field(default=None, description="Plugin id referenced by ConditionNode etc.")
88
+ expression: Optional[str] = Field(default=None, description="Raw expression string when INVALID_EXPRESSION_*")
89
+ output_port: Optional[str] = Field(default=None, description="Output port name on source node")
90
+
91
+ model_config = ConfigDict(extra="forbid")
92
+
93
+
94
+ class RecommendationCategory(str, Enum):
95
+ DATA_FLOW = "data_flow"
96
+ RESILIENCE = "resilience"
97
+ PERFORMANCE = "performance"
98
+ SAFETY = "safety"
99
+ READABILITY = "readability"
100
+
101
+
102
+ class Recommendation(BaseModel):
103
+ """Non-blocking quality-improvement hint for AI chatbots and users.
104
+
105
+ Recommendations are deterministic, rule-based suggestions — never prescribe
106
+ a single fixed value. Always present options with rationale so the
107
+ consumer (chatbot or human) can pick the right one for their context.
108
+
109
+ `example_snippet` is a partial workflow fragment (one or two
110
+ nodes/edges) showing one applied option — not a full workflow JSON. The
111
+ chatbot is expected to merge it into the user's existing workflow.
112
+ """
113
+ title: str = Field(description="Short one-line hint (English). Use 'Consider X' tone.")
114
+ rationale: str = Field(description="Why this may improve the workflow — 1~2 sentences.")
115
+ category: RecommendationCategory
116
+ location: Optional[ErrorLocation] = Field(default=None, description="Optional anchor point in the workflow")
117
+ options: List[str] = Field(default_factory=list, description="2+ alternative approaches the user/AI may pick from")
118
+ example_snippet: Optional[Dict[str, Any]] = Field(
119
+ default=None,
120
+ description="Partial workflow JSON showing one applied option (node/edge fragment only — not a full workflow)",
121
+ )
122
+ rule_id: str = Field(description="Deterministic rule identifier for testing / suppression (e.g. 'REC_REALTIME_THROTTLE')")
123
+
124
+ model_config = ConfigDict(use_enum_values=True)
125
+
126
+ def short(self) -> str:
127
+ return f"[{self.rule_id}] {self.title} — {self.rationale}"
128
+
129
+
130
+ class ErrorInfo(BaseModel):
131
+ code: ErrorCode
132
+ severity: ErrorSeverity = Field(default=ErrorSeverity.ERROR)
133
+ location: ErrorLocation = Field(default_factory=ErrorLocation)
134
+ message: str = Field(description="Human-readable English message. One sentence preferred.")
135
+ suggestion: Optional[str] = Field(default=None, description="Single-line English remediation hint.")
136
+ available_values: Optional[List[str]] = Field(
137
+ default=None,
138
+ description="Closest valid candidates for enum/registry-style errors (suggest_close_match result).",
139
+ )
140
+ docs_url: Optional[str] = Field(default=None, description="Optional anchor link to documentation.")
141
+ details: Dict[str, Any] = Field(default_factory=dict, description="Free-form structured context.")
142
+ recommendations: List[Recommendation] = Field(
143
+ default_factory=list,
144
+ description="Optional improvement hints worth considering while fixing this error.",
145
+ )
146
+
147
+ model_config = ConfigDict(use_enum_values=True)
148
+
149
+ def short(self) -> str:
150
+ loc_parts: List[str] = []
151
+ if self.location.node_id:
152
+ loc_parts.append(f"node={self.location.node_id}")
153
+ if self.location.field_path:
154
+ loc_parts.append(f"field={self.location.field_path}")
155
+ if self.location.edge_index is not None:
156
+ loc_parts.append(f"edge[{self.location.edge_index}]")
157
+ suffix = f" ({', '.join(loc_parts)})" if loc_parts else ""
158
+ return f"[{self.code}] {self.message}{suffix}"
159
+
160
+
161
+ class ValidationLimits(BaseModel):
162
+ """Output volume limits to prevent overwhelming LLM context."""
163
+
164
+ max_errors: int = Field(default=20, ge=1)
165
+ max_warnings: int = Field(default=10, ge=1)
166
+ max_recommendations_per_channel: int = Field(default=10, ge=1)
167
+ max_per_node: int = Field(default=3, ge=1)
168
+
169
+ model_config = ConfigDict(extra="forbid")
170
+
171
+
172
+ class ResultSummary(BaseModel):
173
+ """Top-level summary for fast LLM triage of a ValidationResult."""
174
+
175
+ is_valid: bool
176
+ error_count: int = 0
177
+ warning_count: int = 0
178
+ static_recommendation_count: int = 0
179
+ runtime_recommendation_count: int = 0
180
+ critical_codes: List[ErrorCode] = Field(
181
+ default_factory=list,
182
+ description="Up to 3 most-impactful codes (cascade roots first, then frequency).",
183
+ )
184
+ root_cause_node_ids: List[str] = Field(
185
+ default_factory=list,
186
+ description="Node ids that triggered cascade suppression (fix these first).",
187
+ )
188
+ next_action_hint: Optional[str] = Field(
189
+ default=None,
190
+ description="Deterministic English single-line hint for the consumer's next step.",
191
+ )
192
+ truncated: bool = Field(
193
+ default=False,
194
+ description="True if any channel hit ValidationLimits and entries were dropped.",
195
+ )
196
+
197
+ model_config = ConfigDict(use_enum_values=True)
198
+
199
+
200
+ class ValidationResult(BaseModel):
201
+ """Structured validation outcome for a workflow definition.
202
+
203
+ Two recommendation channels exist:
204
+ - `static_recommendations`: filled by `executor.validate()` (topology
205
+ analysis only)
206
+ - `runtime_recommendations`: filled by `executor.execute(dry_run=True)`
207
+ based on actual node mock outputs (e.g. REC_EMPTY_SYMBOL_LIST)
208
+ """
209
+
210
+ errors: List[ErrorInfo] = Field(default_factory=list)
211
+ warnings: List[ErrorInfo] = Field(default_factory=list)
212
+ static_recommendations: List[Recommendation] = Field(default_factory=list)
213
+ runtime_recommendations: List[Recommendation] = Field(default_factory=list)
214
+ summary: Optional[ResultSummary] = Field(
215
+ default=None,
216
+ description="Populated after post-processing (cascade suppression + capping). None until finalize() runs.",
217
+ )
218
+ truncated: Dict[str, int] = Field(
219
+ default_factory=dict,
220
+ description="Per-channel count of entries dropped by ValidationLimits (e.g. {'errors': 5}).",
221
+ )
222
+
223
+ model_config = ConfigDict(use_enum_values=True)
224
+
225
+ @property
226
+ def is_valid(self) -> bool:
227
+ return not self.errors
228
+
229
+ def add(self, info: ErrorInfo) -> None:
230
+ """Append an ErrorInfo into errors/warnings by severity."""
231
+ if info.severity == ErrorSeverity.WARNING or info.severity == ErrorSeverity.WARNING.value:
232
+ self.warnings.append(info)
233
+ else:
234
+ self.errors.append(info)
235
+
236
+ def add_static_recommendation(self, rec: Recommendation) -> None:
237
+ self.static_recommendations.append(rec)
238
+
239
+ def add_runtime_recommendation(self, rec: Recommendation) -> None:
240
+ self.runtime_recommendations.append(rec)
241
+
242
+ def all_recommendations(self) -> List[Recommendation]:
243
+ """Merge static + runtime channels (read-only helper for consumers that don't distinguish stages)."""
244
+ return [*self.static_recommendations, *self.runtime_recommendations]
245
+
246
+ def as_strings(self) -> List[str]:
247
+ """Human-readable one-line per entry. Not a legacy compat shim — English-only."""
248
+ lines: List[str] = []
249
+ lines.extend(e.short() for e in self.errors)
250
+ lines.extend(w.short() for w in self.warnings)
251
+ lines.extend(r.short() for r in self.all_recommendations())
252
+ return lines
253
+
254
+
255
+ def suggest_close_match(
256
+ value: str,
257
+ candidates: Iterable[str],
258
+ cutoff: float = 0.6,
259
+ n: int = 3,
260
+ ) -> Optional[List[str]]:
261
+ """Return up to `n` closest matches above `cutoff`, or None if there are no matches.
262
+
263
+ Thin wrapper over `difflib.get_close_matches` with standardised defaults
264
+ for ErrorInfo.available_values.
265
+ """
266
+ matches = get_close_matches(value, list(candidates), n=n, cutoff=cutoff)
267
+ return matches or None
268
+
269
+
270
+ def build_error(
271
+ code: ErrorCode,
272
+ message: str,
273
+ *,
274
+ location: Optional[ErrorLocation] = None,
275
+ suggestion: Optional[str] = None,
276
+ available_values: Optional[List[str]] = None,
277
+ details: Optional[Dict[str, Any]] = None,
278
+ severity: Optional[ErrorSeverity] = None,
279
+ recommendations: Optional[List[Recommendation]] = None,
280
+ ) -> ErrorInfo:
281
+ """DRY helper for resolver.py and dry_run capture sites."""
282
+ return ErrorInfo(
283
+ code=code,
284
+ severity=severity or default_severity_for(code),
285
+ location=location or ErrorLocation(),
286
+ message=message,
287
+ suggestion=suggestion,
288
+ available_values=available_values,
289
+ details=details or {},
290
+ recommendations=recommendations or [],
291
+ )
292
+
293
+
294
+ # Cascade suppression rule data — actual application lives in Phase 4 post-processing.
295
+ # Each entry documents which subordinate error codes a given root cause hides.
296
+ CASCADE_SUPPRESSION_RULES: Dict[ErrorCode, str] = {
297
+ ErrorCode.UNKNOWN_NODE_TYPE: (
298
+ "Suppresses INVALID_EXPRESSION_REF / INVALID_EDGE_REF / INVALID_EDGE_PORT "
299
+ "where the source/target is the unknown node id."
300
+ ),
301
+ ErrorCode.MISSING_REQUIRED_BROKER: (
302
+ "Suppresses additional MISSING_REQUIRED_BROKER entries with the same "
303
+ "product_scope (keeps only the first)."
304
+ ),
305
+ ErrorCode.CYCLE_DETECTED: (
306
+ "Suppresses any follow-on validation errors on nodes inside the cycle."
307
+ ),
308
+ ErrorCode.DUPLICATE_NODE_ID: (
309
+ "Suppresses INVALID_EDGE_REF / INVALID_EXPRESSION_REF that reference the "
310
+ "duplicated id (since id resolution is ambiguous)."
311
+ ),
312
+ }
313
+
314
+
315
+ __all__ = [
316
+ "ErrorSeverity",
317
+ "ErrorCode",
318
+ "ErrorLocation",
319
+ "ErrorInfo",
320
+ "RecommendationCategory",
321
+ "Recommendation",
322
+ "ValidationLimits",
323
+ "ResultSummary",
324
+ "ValidationResult",
325
+ "CASCADE_SUPPRESSION_RULES",
326
+ "default_severity_for",
327
+ "suggest_close_match",
328
+ "build_error",
329
+ ]
@@ -206,42 +206,109 @@ class WorkflowDefinition(BaseModel):
206
206
  if node.get("id") not in nodes_with_input
207
207
  ]
208
208
 
209
- def validate_structure(self) -> List[str]:
210
- """
211
- 워크플로우 구조 검증
209
+ def validate_structure(self) -> List["ErrorInfo"]:
210
+ """Validate the workflow's structural invariants.
212
211
 
213
- Returns:
214
- 검증 오류 메시지 목록 (빈 리스트면 유효)
212
+ Returns a list of `ErrorInfo` objects. An empty list means no
213
+ structural problems were found. Callers should append the result
214
+ to a `ValidationResult` via `result.add(...)` for each entry.
215
215
  """
216
- errors = []
216
+ from programgarden_core.models.validation import (
217
+ ErrorCode,
218
+ ErrorInfo,
219
+ ErrorLocation,
220
+ build_error,
221
+ )
217
222
 
218
- # 1. Duplicate node ID check
223
+ errors: List[ErrorInfo] = []
224
+
225
+ # 1. Duplicate node IDs
219
226
  node_ids = self.get_node_ids()
220
227
  if len(node_ids) != len(set(node_ids)):
221
- seen = set()
222
- dupes = [nid for nid in node_ids if nid in seen or seen.add(nid)]
223
- errors.append(f"Duplicate node IDs found: {', '.join(dupes)}")
224
-
225
- # 2. Edge node reference check
228
+ seen: set = set()
229
+ dupes: List[str] = []
230
+ for nid in node_ids:
231
+ if nid in seen and nid not in dupes:
232
+ dupes.append(nid)
233
+ seen.add(nid)
234
+ for dup_id in dupes:
235
+ errors.append(
236
+ build_error(
237
+ ErrorCode.DUPLICATE_NODE_ID,
238
+ f"Duplicate node id '{dup_id}'",
239
+ location=ErrorLocation(node_id=dup_id),
240
+ suggestion="Rename one of the duplicated nodes so each id is unique.",
241
+ )
242
+ )
243
+
244
+ # 2. Edge node references
226
245
  node_id_set = set(node_ids)
227
- for edge in self.edges:
246
+ for idx, edge in enumerate(self.edges):
228
247
  if edge.from_node_id not in node_id_set:
229
- errors.append(f"Edge 'from' references non-existent node: {edge.from_node_id}")
248
+ errors.append(
249
+ build_error(
250
+ ErrorCode.INVALID_EDGE_REF,
251
+ f"Edge 'from' references non-existent node '{edge.from_node_id}'",
252
+ location=ErrorLocation(
253
+ edge_index=idx,
254
+ edge_from=edge.from_node_id,
255
+ edge_to=edge.to_node_id,
256
+ ),
257
+ available_values=sorted(node_id_set),
258
+ )
259
+ )
230
260
  if edge.to_node_id not in node_id_set:
231
- errors.append(f"Edge 'to' references non-existent node: {edge.to_node_id}")
232
-
233
- # 3. StartNode check
261
+ errors.append(
262
+ build_error(
263
+ ErrorCode.INVALID_EDGE_REF,
264
+ f"Edge 'to' references non-existent node '{edge.to_node_id}'",
265
+ location=ErrorLocation(
266
+ edge_index=idx,
267
+ edge_from=edge.from_node_id,
268
+ edge_to=edge.to_node_id,
269
+ ),
270
+ available_values=sorted(node_id_set),
271
+ )
272
+ )
273
+
274
+ # 3. StartNode invariants
234
275
  start_nodes = [n for n in self.nodes if n.get("type") == "StartNode"]
235
276
  if not start_nodes:
236
- errors.append("StartNode is required (exactly 1 per workflow)")
277
+ errors.append(
278
+ build_error(
279
+ ErrorCode.MISSING_START_NODE,
280
+ "Workflow must contain exactly one StartNode",
281
+ suggestion="Add a StartNode at the entry of the main flow.",
282
+ )
283
+ )
237
284
  elif len(start_nodes) > 1:
238
- errors.append("Multiple StartNodes found (only 1 allowed per workflow)")
239
-
240
- # 4. Cycle detection (DAG validation)
285
+ offending_ids = [n.get("id") for n in start_nodes if n.get("id")]
286
+ errors.append(
287
+ build_error(
288
+ ErrorCode.MULTIPLE_START_NODES,
289
+ f"Workflow has {len(start_nodes)} StartNodes; only one is allowed",
290
+ location=ErrorLocation(
291
+ node_id=offending_ids[0] if offending_ids else None,
292
+ node_type="StartNode",
293
+ ),
294
+ suggestion="Keep one StartNode and remove the others (use ScheduleNode for cron triggers).",
295
+ details={"start_node_ids": offending_ids},
296
+ )
297
+ )
298
+
299
+ # 4. Cycle detection
241
300
  cycle = self._detect_cycle()
242
301
  if cycle:
243
302
  cycle_path = " -> ".join(cycle)
244
- errors.append(f"Circular reference detected: {cycle_path}")
303
+ errors.append(
304
+ build_error(
305
+ ErrorCode.CYCLE_DETECTED,
306
+ f"Cycle detected in the workflow DAG: {cycle_path}",
307
+ location=ErrorLocation(node_id=cycle[0] if cycle else None),
308
+ suggestion="Remove one of the edges in the cycle so execution order can be resolved.",
309
+ details={"cycle_path": cycle},
310
+ )
311
+ )
245
312
 
246
313
  return errors
247
314
 
@@ -23,6 +23,7 @@ from programgarden_core.nodes.base import (
23
23
  ProductScope,
24
24
  BrokerProvider,
25
25
  HISTORICAL_DATA_FIELDS,
26
+ HISTORICAL_VALUE_FIELDS,
26
27
  )
27
28
 
28
29
 
@@ -203,7 +204,7 @@ class OverseasFuturesHistoricalDataNode(BaseNode):
203
204
  InputPort(name="symbol", type="symbol", description="i18n:ports.symbol"),
204
205
  ]
205
206
  _outputs: List[OutputPort] = [
206
- OutputPort(name="value", type="ohlcv_data", description="i18n:ports.ohlcv_value", fields=HISTORICAL_DATA_FIELDS),
207
+ OutputPort(name="value", type="ohlcv_data", description="i18n:ports.ohlcv_value", fields=HISTORICAL_VALUE_FIELDS),
207
208
  ]
208
209
 
209
210
  @classmethod
@@ -23,6 +23,7 @@ from programgarden_core.nodes.base import (
23
23
  ProductScope,
24
24
  BrokerProvider,
25
25
  HISTORICAL_DATA_FIELDS,
26
+ HISTORICAL_VALUE_FIELDS,
26
27
  )
27
28
 
28
29
 
@@ -202,7 +203,7 @@ class KoreaStockHistoricalDataNode(BaseNode):
202
203
  InputPort(name="symbol", type="symbol", description="i18n:ports.symbol"),
203
204
  ]
204
205
  _outputs: List[OutputPort] = [
205
- OutputPort(name="value", type="ohlcv_data", description="i18n:ports.ohlcv_value", fields=HISTORICAL_DATA_FIELDS),
206
+ OutputPort(name="value", type="ohlcv_data", description="i18n:ports.ohlcv_value", fields=HISTORICAL_VALUE_FIELDS),
206
207
  ]
207
208
 
208
209
  @classmethod
@@ -23,6 +23,7 @@ from programgarden_core.nodes.base import (
23
23
  ProductScope,
24
24
  BrokerProvider,
25
25
  HISTORICAL_DATA_FIELDS,
26
+ HISTORICAL_VALUE_FIELDS,
26
27
  )
27
28
 
28
29
 
@@ -209,7 +210,7 @@ class OverseasStockHistoricalDataNode(BaseNode):
209
210
  name="value",
210
211
  type="ohlcv_data",
211
212
  description="i18n:ports.ohlcv_value",
212
- fields=HISTORICAL_DATA_FIELDS,
213
+ fields=HISTORICAL_VALUE_FIELDS,
213
214
  example=[
214
215
  {
215
216
  "symbol": "AAPL",
@@ -221,6 +222,26 @@ class OverseasStockHistoricalDataNode(BaseNode):
221
222
  },
222
223
  ],
223
224
  ),
225
+ OutputPort(
226
+ name="values",
227
+ type="array",
228
+ description="Array form of all fetched series — [{symbol, exchange, time_series: [...]}, ...]",
229
+ ),
230
+ OutputPort(
231
+ name="symbols",
232
+ type="array",
233
+ description="Flat list of fetched symbol strings",
234
+ ),
235
+ OutputPort(
236
+ name="period",
237
+ type="string",
238
+ description="Date range string formatted as 'YYYYMMDD~YYYYMMDD'",
239
+ ),
240
+ OutputPort(
241
+ name="interval",
242
+ type="string",
243
+ description="Candle interval (D / W / M)",
244
+ ),
224
245
  ]
225
246
 
226
247
  @classmethod
@@ -259,6 +259,18 @@ HISTORICAL_DATA_FIELDS: List[Dict[str, str]] = [
259
259
  {"name": "volume", "type": "number", "description": "거래량"},
260
260
  ]
261
261
 
262
+ # Outer shape of HistoricalDataNode's `value` port. The runtime payload
263
+ # is {symbol, exchange, time_series: [HISTORICAL_DATA_FIELDS, ...]}.
264
+ # Previously the schema attached HISTORICAL_DATA_FIELDS directly to the
265
+ # `value` port, which (correctly) declared the bar shape but (incorrectly)
266
+ # made expressions like `{{ nodes.historical.value.time_series }}`
267
+ # look like field typos against the schema.
268
+ HISTORICAL_VALUE_FIELDS: List[Dict[str, str]] = [
269
+ {"name": "symbol", "type": "string", "description": "종목코드"},
270
+ {"name": "exchange", "type": "string", "description": "거래소 코드"},
271
+ {"name": "time_series", "type": "array", "description": "OHLCV 바 배열"},
272
+ ]
273
+
262
274
  ORDER_LIST_FIELDS: List[Dict[str, str]] = [
263
275
  {"name": "order_id", "type": "string", "description": "주문번호"},
264
276
  {"name": "exchange", "type": "string", "description": "거래소 코드"},
@@ -86,18 +86,41 @@ class ConditionNode(PluginNode):
86
86
  _outputs: List[OutputPort] = [
87
87
  OutputPort(
88
88
  name="result",
89
- type="condition_result",
90
- description="i18n:ports.condition_result",
91
- fields=CONDITION_RESULT_FIELDS,
92
- example={
93
- "is_condition_met": True,
94
- "passed_symbols": [
95
- {"exchange": "NASDAQ", "symbol": "AAPL"},
96
- ],
97
- "details": [
98
- {"symbol": "AAPL", "exchange": "NASDAQ", "passed": True, "value": 28.5, "threshold": 30, "direction": "below"},
99
- ],
100
- },
89
+ type="boolean",
90
+ description="True if any symbol passed the condition (len(passed_symbols) > 0)",
91
+ ),
92
+ OutputPort(
93
+ name="is_condition_met",
94
+ type="boolean",
95
+ description="Alias of result — convenience for IfNode/LogicNode bindings",
96
+ ),
97
+ OutputPort(
98
+ name="symbols",
99
+ type="symbol_list",
100
+ description="Input symbols normalized to [{exchange, symbol}, ...]",
101
+ fields=SYMBOL_LIST_FIELDS,
102
+ ),
103
+ OutputPort(
104
+ name="passed_symbols",
105
+ type="symbol_list",
106
+ description="Symbols that satisfied the condition — [{exchange, symbol}, ...]",
107
+ fields=SYMBOL_LIST_FIELDS,
108
+ ),
109
+ OutputPort(
110
+ name="failed_symbols",
111
+ type="symbol_list",
112
+ description="Symbols that did not satisfy the condition — [{exchange, symbol}, ...]",
113
+ fields=SYMBOL_LIST_FIELDS,
114
+ ),
115
+ OutputPort(
116
+ name="symbol_results",
117
+ type="array",
118
+ description="Per-symbol breakdown — [{symbol, exchange, passed, value, ...plugin-specific fields}, ...]",
119
+ ),
120
+ OutputPort(
121
+ name="values",
122
+ type="array",
123
+ description="Per-symbol time series — [{symbol, exchange, time_series: [...]}, ...] (empty for position plugins)",
101
124
  ),
102
125
  ]
103
126
 
@@ -378,22 +401,19 @@ class LogicNode(BaseNode):
378
401
  _outputs: List[OutputPort] = [
379
402
  OutputPort(
380
403
  name="result",
381
- type="condition_result",
382
- description="i18n:ports.result",
383
- fields=CONDITION_RESULT_FIELDS,
384
- example={
385
- "is_condition_met": True,
386
- "passed_symbols": [{"exchange": "NASDAQ", "symbol": "AAPL"}],
387
- },
404
+ type="boolean",
405
+ description="Combined boolean result (true when the configured operator is satisfied)",
388
406
  ),
389
407
  OutputPort(
390
408
  name="passed_symbols",
391
409
  type="symbol_list",
392
- description="i18n:ports.passed_symbols",
410
+ description="Symbols satisfying the combined condition (intersection/union depending on operator)",
393
411
  fields=SYMBOL_LIST_FIELDS,
394
- example=[
395
- {"exchange": "NASDAQ", "symbol": "AAPL"},
396
- ],
412
+ ),
413
+ OutputPort(
414
+ name="details",
415
+ type="array",
416
+ description="Per-condition breakdown — [{is_condition_met, passed_symbols, weight?, ...}, ...]",
397
417
  ),
398
418
  ]
399
419
 
@@ -1196,6 +1196,11 @@ class FieldMappingNode(BaseNode):
1196
1196
  type="any",
1197
1197
  description="i18n:ports.mapped_data",
1198
1198
  ),
1199
+ OutputPort(
1200
+ name="data",
1201
+ type="any",
1202
+ description="Alias of mapped_data — convenient when chaining into nodes that expect 'data'",
1203
+ ),
1199
1204
  OutputPort(
1200
1205
  name="original_fields",
1201
1206
  type="array",
@@ -1335,6 +1340,7 @@ class FieldMappingNode(BaseNode):
1335
1340
 
1336
1341
  return {
1337
1342
  "mapped_data": mapped_data,
1343
+ "data": mapped_data, # alias declared in _outputs — downstream nodes that expect 'data'
1338
1344
  "original_fields": sorted(list(original_fields)),
1339
1345
  "mapped_fields": sorted(list(mapped_fields)),
1340
1346
  }
@@ -191,6 +191,7 @@ class KoreaStockMarketDataNode(BaseNode):
191
191
  ]
192
192
  _outputs: List[OutputPort] = [
193
193
  OutputPort(name="value", type="market_data", description="i18n:ports.market_data_value", fields=KOREA_STOCK_PRICE_DATA_FIELDS),
194
+ OutputPort(name="values", type="array", description="Array of per-symbol market quotes"),
194
195
  ]
195
196
 
196
197
  @classmethod
@@ -206,6 +206,11 @@ class OverseasStockMarketDataNode(BaseNode):
206
206
  "eps": 6.57,
207
207
  },
208
208
  ),
209
+ OutputPort(
210
+ name="values",
211
+ type="array",
212
+ description="Array of per-symbol market quotes — [{symbol, exchange, current_price, ...}, ...]",
213
+ ),
209
214
  ]
210
215
 
211
216
  @classmethod
@@ -188,6 +188,7 @@ class KoreaStockFundamentalNode(BaseNode):
188
188
  ]
189
189
  _outputs: List[OutputPort] = [
190
190
  OutputPort(name="value", type="fundamental_data", description="i18n:ports.fundamental_data_value", fields=KOREA_STOCK_FUNDAMENTAL_FIELDS),
191
+ OutputPort(name="values", type="array", description="Array of per-symbol fundamental records"),
191
192
  ]
192
193
 
193
194
  @classmethod
@@ -187,6 +187,7 @@ class OverseasStockFundamentalNode(BaseNode):
187
187
  ]
188
188
  _outputs: List[OutputPort] = [
189
189
  OutputPort(name="value", type="fundamental_data", description="i18n:ports.fundamental_data_value", fields=FUNDAMENTAL_DATA_FIELDS),
190
+ OutputPort(name="values", type="array", description="Array of per-symbol fundamental records"),
190
191
  ]
191
192
 
192
193
  @classmethod
@@ -414,6 +414,11 @@ class SplitNode(BaseNode):
414
414
  description="i18n:ports.split_total",
415
415
  example=3,
416
416
  ),
417
+ OutputPort(
418
+ name="items",
419
+ type="array",
420
+ description="Full input array (same as the upstream array) — convenient for downstream binding",
421
+ ),
417
422
  ]
418
423
 
419
424
  _usage: ClassVar[Dict[str, Any]] = {
@@ -157,6 +157,12 @@ class PortfolioNode(BaseNode):
157
157
  description="i18n:ports.allocated_capital",
158
158
  fields=ALLOCATED_CAPITAL_FIELDS,
159
159
  ),
160
+ # 위험 감시 단축
161
+ OutputPort(
162
+ name="drawdown_percent",
163
+ type="number",
164
+ description="Current drawdown as percentage (shortcut to combined_metrics.max_drawdown for IfNode kill switches)",
165
+ ),
160
166
  ]
161
167
 
162
168
  _usage: ClassVar[Dict[str, Any]] = {
@@ -400,6 +400,14 @@ class ScreenerNode(BaseNode):
400
400
  default=None,
401
401
  description="최소 평균 거래량 (주). 예: 1000000 = 100만주",
402
402
  )
403
+ price_min: Optional[float] = Field(
404
+ default=None,
405
+ description="최소 주가 ($). 예: 1.0 = $1 이상. 동전주 발굴 등 가격대 필터링에 사용",
406
+ )
407
+ price_max: Optional[float] = Field(
408
+ default=None,
409
+ description="최대 주가 ($). 예: 5.0 = $5 이하. 동전주 발굴 등 가격대 필터링에 사용",
410
+ )
403
411
  sector: Optional[str] = Field(
404
412
  default=None,
405
413
  description="섹터 필터 (Technology, Healthcare, Finance 등)",
@@ -409,10 +417,15 @@ class ScreenerNode(BaseNode):
409
417
  description="거래소 필터 (NASDAQ, NYSE, AMEX)",
410
418
  )
411
419
  max_results: int = Field(
412
- default=100,
420
+ default=100,
413
421
  description="최대 결과 수"
414
422
  )
415
423
 
424
+ data_source: Literal["auto", "ls", "yfinance"] = Field(
425
+ default="auto",
426
+ description="자료 가져올 곳. auto=브로커 연결 시 LS증권, 아니면 Yahoo Finance. ls=LS증권 강제. yfinance=Yahoo Finance 강제.",
427
+ )
428
+
416
429
  _inputs: List[InputPort] = [
417
430
  InputPort(
418
431
  name="symbols",
@@ -582,6 +595,29 @@ class ScreenerNode(BaseNode):
582
595
  placeholder="예: 1000000 (100만주)",
583
596
  expected_type="int",
584
597
  ),
598
+ # === PARAMETERS: 가격 필터 ===
599
+ "price_min": FieldSchema(
600
+ name="price_min",
601
+ type=FieldType.NUMBER,
602
+ description="최소 주가 (달러). 동전주 발굴이나 저가주 필터링에 사용하세요. 예: 1.0 = $1 이상.",
603
+ required=False,
604
+ category=FieldCategory.PARAMETERS,
605
+ expression_mode=ExpressionMode.FIXED_ONLY,
606
+ example=1.0,
607
+ placeholder="예: 1.0 ($1 이상)",
608
+ expected_type="float",
609
+ ),
610
+ "price_max": FieldSchema(
611
+ name="price_max",
612
+ type=FieldType.NUMBER,
613
+ description="최대 주가 (달러). 고가주 제외나 동전주 상한선 설정에 사용하세요. 예: 5.0 = $5 이하.",
614
+ required=False,
615
+ category=FieldCategory.PARAMETERS,
616
+ expression_mode=ExpressionMode.FIXED_ONLY,
617
+ example=5.0,
618
+ placeholder="예: 5.0 ($5 이하)",
619
+ expected_type="float",
620
+ ),
585
621
  # === PARAMETERS: 섹터/거래소 필터 ===
586
622
  "sector": FieldSchema(
587
623
  name="sector",
@@ -625,6 +661,23 @@ class ScreenerNode(BaseNode):
625
661
  example="NASDAQ",
626
662
  expected_type="str",
627
663
  ),
664
+ # === PARAMETERS: 자료 가져올 곳 ===
665
+ "data_source": FieldSchema(
666
+ name="data_source",
667
+ type=FieldType.ENUM,
668
+ description="가격/시가총액/거래량 정보를 어디에서 받아올지 선택하세요. '자동'을 권장합니다.",
669
+ default="auto",
670
+ enum_values=["auto", "ls", "yfinance"],
671
+ enum_labels={
672
+ "auto": "자동 (브로커 연결 시 LS증권 사용, 없으면 Yahoo Finance)",
673
+ "ls": "LS증권 (빠름, 브로커 연결 필요)",
674
+ "yfinance": "Yahoo Finance (외부 API)",
675
+ },
676
+ category=FieldCategory.PARAMETERS,
677
+ expression_mode=ExpressionMode.FIXED_ONLY,
678
+ example="auto",
679
+ expected_type="str",
680
+ ),
628
681
  # === SETTINGS: 결과 제한 ===
629
682
  "max_results": FieldSchema(
630
683
  name="max_results",
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden-core"
8
- version = "1.12.2"
8
+ version = "1.12.4"
9
9
  description = "ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의"
10
10
  readme = "README.md"
11
11