programgarden-core 1.18.0__tar.gz → 1.20.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 (84) hide show
  1. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/PKG-INFO +1 -1
  2. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/account_futures.py +2 -2
  3. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/account_korea_stock.py +2 -2
  4. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/account_stock.py +2 -2
  5. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/ai.py +5 -0
  6. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/base.py +224 -27
  7. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/data_futures.py +9 -7
  8. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/data_korea_stock.py +11 -6
  9. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/data_stock.py +15 -10
  10. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/display.py +14 -4
  11. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/infra.py +6 -1
  12. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/realtime_futures.py +10 -7
  13. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/realtime_korea_stock.py +10 -7
  14. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/realtime_stock.py +10 -7
  15. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/symbol.py +4 -2
  16. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/symbol_futures.py +2 -1
  17. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/symbol_korea_stock.py +2 -2
  18. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/symbol_stock.py +2 -2
  19. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/pyproject.toml +1 -1
  20. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/README.md +0 -0
  21. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/__init__.py +0 -0
  22. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/bases/__init__.py +0 -0
  23. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/bases/client.py +0 -0
  24. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/bases/components.py +0 -0
  25. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/bases/listener.py +0 -0
  26. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/bases/mixins.py +0 -0
  27. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/bases/products.py +0 -0
  28. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/bases/sql.py +0 -0
  29. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/bases/storage.py +0 -0
  30. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/code_node.py +0 -0
  31. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/exceptions.py +0 -0
  32. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/expression/__init__.py +0 -0
  33. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/expression/evaluator.py +0 -0
  34. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/i18n/__init__.py +0 -0
  35. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/i18n/locales/en.json +0 -0
  36. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/i18n/locales/ko.json +0 -0
  37. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/i18n/translator.py +0 -0
  38. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/korea_alias.py +0 -0
  39. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/__init__.py +0 -0
  40. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/connection_rule.py +0 -0
  41. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/credential.py +0 -0
  42. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/edge.py +0 -0
  43. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/event.py +0 -0
  44. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/exchange.py +0 -0
  45. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/field_binding.py +0 -0
  46. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/job.py +0 -0
  47. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/order_diagnostics.py +0 -0
  48. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/plugin_resource.py +0 -0
  49. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/resilience.py +0 -0
  50. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/resource.py +0 -0
  51. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/validation.py +0 -0
  52. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/models/workflow.py +0 -0
  53. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/__init__.py +0 -0
  54. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/backtest.py +0 -0
  55. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/backtest_futures.py +0 -0
  56. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/backtest_korea_stock.py +0 -0
  57. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/backtest_stock.py +0 -0
  58. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/broker.py +0 -0
  59. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/calculation.py +0 -0
  60. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/code.py +0 -0
  61. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/condition.py +0 -0
  62. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/data.py +0 -0
  63. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/event.py +0 -0
  64. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/fundamental_korea_stock.py +0 -0
  65. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/fundamental_stock.py +0 -0
  66. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/market_external.py +0 -0
  67. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/market_status.py +0 -0
  68. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/open_orders_futures.py +0 -0
  69. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/open_orders_korea_stock.py +0 -0
  70. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/open_orders_stock.py +0 -0
  71. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/order.py +0 -0
  72. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/portfolio.py +0 -0
  73. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/risk.py +0 -0
  74. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/nodes/trigger.py +0 -0
  75. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/presets/__init__.py +0 -0
  76. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/presets/news_analyst.json +0 -0
  77. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/presets/risk_manager.json +0 -0
  78. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/presets/strategist.json +0 -0
  79. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/presets/technical_analyst.json +0 -0
  80. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/registry/__init__.py +0 -0
  81. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/registry/credential_registry.py +0 -0
  82. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/registry/node_registry.py +0 -0
  83. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/registry/plugin_registry.py +0 -0
  84. {programgarden_core-1.18.0 → programgarden_core-1.20.0}/programgarden_core/retry_executor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden-core
3
- Version: 1.18.0
3
+ Version: 1.20.0
4
4
  Summary: ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -19,7 +19,7 @@ from programgarden_core.nodes.base import (
19
19
  ProductScope,
20
20
  BrokerProvider,
21
21
  OVERSEAS_FUTURES_BALANCE_FIELDS,
22
- POSITION_FIELDS,
22
+ OVERSEAS_FUTURES_POSITION_FIELDS,
23
23
  SYMBOL_LIST_FIELDS,
24
24
  )
25
25
 
@@ -171,7 +171,7 @@ class OverseasFuturesAccountNode(BaseNode):
171
171
  _outputs: List[OutputPort] = [
172
172
  OutputPort(name="held_symbols", type="symbol_list", description="i18n:ports.held_symbols", fields=SYMBOL_LIST_FIELDS),
173
173
  OutputPort(name="balance", type="balance_data", description="i18n:ports.balance", fields=OVERSEAS_FUTURES_BALANCE_FIELDS),
174
- OutputPort(name="positions", type="position_data", description="i18n:ports.positions", fields=POSITION_FIELDS),
174
+ OutputPort(name="positions", type="position_data", description="i18n:ports.positions", fields=OVERSEAS_FUTURES_POSITION_FIELDS),
175
175
  ]
176
176
 
177
177
  _version: ClassVar[str] = "1.0.0"
@@ -18,7 +18,7 @@ from programgarden_core.nodes.base import (
18
18
  ProductScope,
19
19
  BrokerProvider,
20
20
  KOREA_STOCK_BALANCE_FIELDS,
21
- POSITION_FIELDS,
21
+ KOREA_STOCK_POSITION_FIELDS,
22
22
  SYMBOL_LIST_FIELDS,
23
23
  )
24
24
 
@@ -170,7 +170,7 @@ class KoreaStockAccountNode(BaseNode):
170
170
  _outputs: List[OutputPort] = [
171
171
  OutputPort(name="held_symbols", type="symbol_list", description="i18n:ports.held_symbols", fields=SYMBOL_LIST_FIELDS),
172
172
  OutputPort(name="balance", type="balance_data", description="i18n:ports.balance", fields=KOREA_STOCK_BALANCE_FIELDS),
173
- OutputPort(name="positions", type="position_data", description="i18n:ports.positions", fields=POSITION_FIELDS),
173
+ OutputPort(name="positions", type="position_data", description="i18n:ports.positions", fields=KOREA_STOCK_POSITION_FIELDS),
174
174
  ]
175
175
 
176
176
  _version: ClassVar[str] = "1.0.0"
@@ -19,7 +19,7 @@ from programgarden_core.nodes.base import (
19
19
  ProductScope,
20
20
  BrokerProvider,
21
21
  OVERSEAS_STOCK_BALANCE_FIELDS,
22
- POSITION_FIELDS,
22
+ OVERSEAS_STOCK_POSITION_FIELDS,
23
23
  SYMBOL_LIST_FIELDS,
24
24
  )
25
25
 
@@ -200,7 +200,7 @@ class OverseasStockAccountNode(BaseNode):
200
200
  name="positions",
201
201
  type="position_data",
202
202
  description="i18n:ports.positions",
203
- fields=POSITION_FIELDS,
203
+ fields=OVERSEAS_STOCK_POSITION_FIELDS,
204
204
  example=[
205
205
  {"symbol": "AAPL", "exchange": "NASDAQ", "quantity": 10, "avg_price": 175.20, "pnl_rate": 6.99},
206
206
  {"symbol": "TSLA", "exchange": "NASDAQ", "quantity": 5, "avg_price": 240.00, "pnl_rate": -2.50},
@@ -360,6 +360,11 @@ class AIAgentNode(BaseNode):
360
360
  "max_total_tokens (default 100,000) caps total token spend per execution cycle",
361
361
  ]
362
362
  _anti_patterns: ClassVar[List[Dict[str, str]]] = [
363
+ {
364
+ "pattern": "Adding an AIAgentNode without connecting an LLMModelNode via an 'ai_model' edge",
365
+ "reason": "AIAgentNode CANNOT run without an LLM — the ai_model edge injects the LLM connection. With no LLMModelNode wired via an ai_model edge, the node fails at runtime (raises). The workflow validator now blocks this with an ERROR at save time.",
366
+ "alternative": "Always add an LLMModelNode and wire it to the AIAgentNode with an ai_model edge: {\"from\": \"<llm_node_id>\", \"to\": \"<agent_id>\", \"type\": \"ai_model\"}. A plain 'main' edge does NOT inject the LLM.",
367
+ },
363
368
  {
364
369
  "pattern": "Connecting a real-time node (RealMarketDataNode) directly to AIAgentNode without ThrottleNode",
365
370
  "reason": "Real-time nodes fire on every tick. Without throttling, AIAgentNode would call the LLM API thousands of times per minute, causing massive cost and rate limit errors. The workflow validator blocks this with an ERROR.",
@@ -112,11 +112,81 @@ class OutputPort(BaseModel):
112
112
 
113
113
 
114
114
  # === OutputPort.fields 공통 상수 ===
115
+ #
116
+ # 🔴 **상수 공유는 노드가 같은 키를 내보낼 때만 한다.**
117
+ # resolver 는 이 선언으로 `nodes.<id>.<port>.<field>` 의 필드 존재를 **정적 검증**한다.
118
+ # 그래서 선언이 런타임과 어긋나면 검증이 양방향으로 다 틀린다:
119
+ # 실제 있는 필드를 바인딩 → INVALID_EXPRESSION_REF 로 **부당 거부** (동작하는 워크플로우를 막음)
120
+ # 실제 없는 필드를 바인딩 → **통과** 시킨 뒤 런타임에 조용히 None (표에 '-' 만 찍힘)
121
+ # 노드 하나를 맞추려고 공유 상수에 필드를 더하면 **다른 노드가 반대 방향으로 거짓말**한다.
122
+ # → 런타임 키가 다르면 **노드별 상수로 쪼갠다.** 증명은 `tests/test_output_schema_contract.py`
123
+ # (executor 의 런타임 dict 리터럴을 AST 로 뽑아 선언과 대조).
124
+
125
+ # WatchlistNode / SymbolFilterNode / ExclusionListNode / 계좌 노드의 held_symbols —
126
+ # 정말로 exchange+symbol 둘만 내보내는 포트 전용.
115
127
  SYMBOL_LIST_FIELDS: List[Dict[str, str]] = [
116
128
  {"name": "exchange", "type": "string", "description": "거래소 코드 (NASDAQ, NYSE, CME 등)"},
117
129
  {"name": "symbol", "type": "string", "description": "종목코드"},
118
130
  ]
119
131
 
132
+ # ── ScreenerNode.symbols (조건 검색 결과) ──
133
+ # LS(g3101 enrich) 분기와 yfinance 분기가 **같은 키 집합**을 내보낸다.
134
+ SCREENER_SYMBOL_FIELDS: List[Dict[str, str]] = [
135
+ {"name": "exchange", "type": "string", "description": "거래소 코드"},
136
+ {"name": "symbol", "type": "string", "description": "종목코드"},
137
+ {"name": "name", "type": "string", "description": "종목명 (상류 종목 리스트가 줬거나 yfinance 조회로 채움)"},
138
+ {"name": "market", "type": "string", "description": "시장 구분 (상류가 준 값 통과 — 예: KOSPI/KOSDAQ)"},
139
+ {"name": "price", "type": "number", "description": "현재가 (필터 시점)"},
140
+ {"name": "market_cap", "type": "number", "description": "시가총액"},
141
+ {"name": "volume", "type": "number", "description": "거래량"},
142
+ {"name": "sector", "type": "string", "description": "섹터 (yfinance 분기에서만 채워짐 — LS 분기는 빈 문자열)"},
143
+ ]
144
+
145
+ # ── MarketUniverseNode.symbols (지수 구성종목) ──
146
+ MARKET_UNIVERSE_SYMBOL_FIELDS: List[Dict[str, str]] = [
147
+ {"name": "exchange", "type": "string", "description": "거래소 코드"},
148
+ {"name": "symbol", "type": "string", "description": "종목코드"},
149
+ {"name": "name", "type": "string", "description": "종목명"},
150
+ ]
151
+
152
+ # ── OverseasStockSymbolQueryNode.symbols (g3190 마스터) ──
153
+ OVERSEAS_STOCK_SYMBOL_QUERY_FIELDS: List[Dict[str, str]] = [
154
+ {"name": "exchange", "type": "string", "description": "거래소 이름 (NASDAQ, NYSE 등)"},
155
+ {"name": "exchange_code", "type": "string", "description": "LS 거래소 코드"},
156
+ {"name": "symbol", "type": "string", "description": "종목코드"},
157
+ {"name": "name", "type": "string", "description": "종목명 (한글 우선, 없으면 영문)"},
158
+ {"name": "isin", "type": "string", "description": "ISIN 코드"},
159
+ {"name": "price", "type": "number", "description": "전일 종가"},
160
+ {"name": "market_cap", "type": "number", "description": "시가총액"},
161
+ {"name": "shares_outstanding", "type": "number", "description": "상장 주식 수"},
162
+ {"name": "currency", "type": "string", "description": "통화 코드"},
163
+ {"name": "suspend", "type": "string", "description": "거래정지 여부 (Y/N)"},
164
+ {"name": "sellonly", "type": "string", "description": "매도전용 여부 (Y/N)"},
165
+ ]
166
+
167
+ # ── KoreaStockSymbolQueryNode.symbols (t9945 마스터) ──
168
+ KOREA_STOCK_SYMBOL_QUERY_FIELDS: List[Dict[str, str]] = [
169
+ {"name": "exchange", "type": "string", "description": "거래소 코드 (항상 KRX)"},
170
+ {"name": "market", "type": "string", "description": "시장 구분 (KOSPI / KOSDAQ)"},
171
+ {"name": "symbol", "type": "string", "description": "종목코드 (6자리)"},
172
+ {"name": "name", "type": "string", "description": "종목명"},
173
+ {"name": "is_etf", "type": "boolean", "description": "ETF 여부"},
174
+ {"name": "product", "type": "string", "description": "상품 구분 (항상 korea_stock)"},
175
+ ]
176
+
177
+ # ── OverseasFuturesSymbolQueryNode.symbols (o3101 마스터) ──
178
+ OVERSEAS_FUTURES_SYMBOL_QUERY_FIELDS: List[Dict[str, str]] = [
179
+ {"name": "exchange", "type": "string", "description": "거래소 코드 (CME, HKEX 등 — 주문에 쓰는 값)"},
180
+ {"name": "exchange_code", "type": "string", "description": "LS 거래소 코드 (exchange 와 동일)"},
181
+ {"name": "exchange_name", "type": "string", "description": "거래소 한글명 (표시용 — 주문에 쓰면 안 된다)"},
182
+ {"name": "symbol", "type": "string", "description": "종목코드 (월물 포함)"},
183
+ {"name": "name", "type": "string", "description": "종목명"},
184
+ {"name": "base_product", "type": "string", "description": "기초상품 코드 (예: ES)"},
185
+ {"name": "base_product_name", "type": "string", "description": "기초상품명"},
186
+ {"name": "currency", "type": "string", "description": "통화 코드"},
187
+ {"name": "contract_month", "type": "string", "description": "월물 (YYYYMM)"},
188
+ ]
189
+
120
190
  # ── Partial-failure metadata (internal) ──
121
191
  # Balance dicts may include these underscore-prefixed keys when the
122
192
  # upstream API call partially fails. Consumers (PositionSizingNode,
@@ -187,16 +257,22 @@ KOREA_STOCK_REAL_BALANCE_FIELDS: List[Dict[str, str]] = [
187
257
  ]
188
258
 
189
259
  # ── 국내주식 시세 전용 (t1102 기반) ──
260
+ # 런타임 정본: executor.py `MarketDataNodeExecutor._fetch_korea_stock`
261
+ # (해외와 같은 이름 규칙을 쓴다 — 예전 선언의 current_price/open_price/market_cap 은 런타임에 없었다.)
190
262
  KOREA_STOCK_PRICE_DATA_FIELDS: List[Dict[str, str]] = [
191
263
  {"name": "symbol", "type": "string", "description": "종목코드 (6자리)"},
264
+ {"name": "exchange", "type": "string", "description": "거래소 코드 (KRX)"},
192
265
  {"name": "name", "type": "string", "description": "종목명"},
193
- {"name": "current_price", "type": "number", "description": "현재가"},
266
+ {"name": "price", "type": "number", "description": "현재가 (원)"},
267
+ {"name": "change", "type": "number", "description": "전일대비 (하락이면 음수)"},
268
+ {"name": "change_pct", "type": "number", "description": "등락률 (%)"},
194
269
  {"name": "volume", "type": "number", "description": "거래량"},
195
- {"name": "change_percent", "type": "number", "description": "등락률 (%)"},
196
- {"name": "open_price", "type": "number", "description": "시가"},
197
- {"name": "high_price", "type": "number", "description": "고가"},
198
- {"name": "low_price", "type": "number", "description": "저가"},
199
- {"name": "market_cap", "type": "number", "description": "시가총액 (억원)"},
270
+ {"name": "open", "type": "number", "description": "시가"},
271
+ {"name": "high", "type": "number", "description": "고가"},
272
+ {"name": "low", "type": "number", "description": "저가"},
273
+ {"name": "close", "type": "number", "description": "종가 (현재가와 동일)"},
274
+ {"name": "per", "type": "number", "description": "PER"},
275
+ {"name": "pbr", "type": "number", "description": "PBR"},
200
276
  ]
201
277
 
202
278
  # ── 국내주식 펀더멘털 전용 ──
@@ -216,14 +292,114 @@ KOREA_STOCK_FUNDAMENTAL_FIELDS: List[Dict[str, str]] = [
216
292
  {"name": "industry", "type": "string", "description": "업종명"},
217
293
  ]
218
294
 
219
- POSITION_FIELDS: List[Dict[str, str]] = [
295
+ # ── 보유 포지션 (positions 포트) ──
296
+ # 🔴 옛 공유 상수 `POSITION_FIELDS` 는 `pnl` / `pnl_percent` 를 선언했으나 **런타임에 그런 키는 없다**
297
+ # (실제는 `pnl_amount` / `pnl_rate`). 챗봇이 카탈로그를 보고 `pnl` 을 바인딩하면 정적 검증은
298
+ # 통과하고 런타임엔 조용히 None 이 됐다. 상품별로 키가 다르므로 노드별로 쪼갠다.
299
+
300
+ # 해외주식 REST 계좌 (COSOQ00201 block4)
301
+ OVERSEAS_STOCK_POSITION_FIELDS: List[Dict[str, str]] = [
220
302
  {"name": "exchange", "type": "string", "description": "거래소 코드"},
221
303
  {"name": "symbol", "type": "string", "description": "종목코드"},
304
+ {"name": "name", "type": "string", "description": "종목명"},
305
+ {"name": "qty", "type": "number", "description": "보유 수량"},
306
+ {"name": "quantity", "type": "number", "description": "보유 수량 (주문 노드 호환 별칭)"},
307
+ {"name": "direction", "type": "string", "description": "포지션 방향 (주식은 항상 long)"},
308
+ {"name": "close_side", "type": "string", "description": "청산 주문 방향 (주식은 항상 sell)"},
309
+ {"name": "avg_price", "type": "number", "description": "평균 매입가"},
310
+ {"name": "current_price", "type": "number", "description": "현재가"},
311
+ {"name": "pnl_amount", "type": "number", "description": "평가 손익 (금액)"},
312
+ {"name": "pnl_rate", "type": "number", "description": "수익률 (%)"},
313
+ {"name": "eval_amount", "type": "number", "description": "평가 금액"},
314
+ {"name": "purchase_amount", "type": "number", "description": "매입 금액"},
315
+ {"name": "currency", "type": "string", "description": "통화 코드"},
316
+ {"name": "market", "type": "string", "description": "시장 구분명"},
317
+ ]
318
+
319
+ # 국내주식 REST 계좌 (t0424)
320
+ KOREA_STOCK_POSITION_FIELDS: List[Dict[str, str]] = [
321
+ {"name": "exchange", "type": "string", "description": "거래소 코드 (항상 KRX)"},
322
+ {"name": "symbol", "type": "string", "description": "종목코드"},
323
+ {"name": "name", "type": "string", "description": "종목명"},
222
324
  {"name": "quantity", "type": "number", "description": "보유 수량"},
325
+ {"name": "price", "type": "number", "description": "현재가 (주문 노드 호환 별칭)"},
223
326
  {"name": "avg_price", "type": "number", "description": "평균 매입가"},
224
327
  {"name": "current_price", "type": "number", "description": "현재가"},
225
- {"name": "pnl", "type": "number", "description": "평가 손익"},
226
- {"name": "pnl_percent", "type": "number", "description": "수익률 (%)"},
328
+ {"name": "pnl_amount", "type": "number", "description": "평가 손익 (금액)"},
329
+ {"name": "pnl_rate", "type": "number", "description": "수익률 (%)"},
330
+ {"name": "sellable_qty", "type": "number", "description": "매도 가능 수량"},
331
+ {"name": "eval_amount", "type": "number", "description": "평가 금액"},
332
+ {"name": "product", "type": "string", "description": "상품 구분 (항상 korea_stock)"},
333
+ ]
334
+
335
+ # 해외선물 REST 계좌 (CIDBQ01500 block2)
336
+ # 주의: 이 TR 응답에는 수익률(pnl_rate)이 없다 — 손익 금액(pnl_amount)만 온다.
337
+ OVERSEAS_FUTURES_POSITION_FIELDS: List[Dict[str, str]] = [
338
+ {"name": "exchange", "type": "string", "description": "거래소 코드"},
339
+ {"name": "symbol", "type": "string", "description": "종목코드 (월물 포함)"},
340
+ {"name": "name", "type": "string", "description": "종목명"},
341
+ {"name": "direction", "type": "string", "description": "포지션 방향 (long / short)"},
342
+ {"name": "close_side", "type": "string", "description": "청산 주문 방향 (sell / buy)"},
343
+ {"name": "quantity", "type": "number", "description": "보유 계약 수"},
344
+ {"name": "price", "type": "number", "description": "현재가 (주문 노드 호환 별칭)"},
345
+ {"name": "entry_price", "type": "number", "description": "진입 가격"},
346
+ {"name": "current_price", "type": "number", "description": "현재가"},
347
+ {"name": "pnl_amount", "type": "number", "description": "평가 손익 (금액)"},
348
+ {"name": "currency", "type": "string", "description": "통화 코드"},
349
+ ]
350
+
351
+ # ── 실시간 계좌 포지션 (RealAccountNode.positions) ──
352
+ # 실시간 노드는 두 갈래로 positions 를 내보낸다: ① REST 스냅샷 직렬화 ② WebSocket tracker.
353
+ # 둘의 키가 다르면 **같은 포트인데 순간마다 다른 필드**가 나온다 → 두 갈래를 같은 키로 통일했다.
354
+ OVERSEAS_STOCK_REAL_POSITION_FIELDS: List[Dict[str, str]] = [
355
+ {"name": "exchange", "type": "string", "description": "거래소 코드"},
356
+ {"name": "market_code", "type": "string", "description": "LS 시장 코드"},
357
+ {"name": "symbol", "type": "string", "description": "종목코드"},
358
+ {"name": "name", "type": "string", "description": "종목명"},
359
+ {"name": "qty", "type": "number", "description": "보유 수량"},
360
+ {"name": "quantity", "type": "number", "description": "보유 수량 (주문 노드 호환 별칭)"},
361
+ {"name": "price", "type": "number", "description": "현재가 (주문 노드 호환 별칭)"},
362
+ {"name": "avg_price", "type": "number", "description": "평균 매입가"},
363
+ {"name": "current_price", "type": "number", "description": "현재가"},
364
+ {"name": "pnl_amount", "type": "number", "description": "평가 손익 (금액)"},
365
+ {"name": "pnl_rate", "type": "number", "description": "수익률 (%)"},
366
+ {"name": "eval_amount", "type": "number", "description": "평가 금액"},
367
+ {"name": "currency", "type": "string", "description": "통화 코드"},
368
+ {"name": "product", "type": "string", "description": "상품 구분 (항상 overseas_stock)"},
369
+ ]
370
+
371
+ KOREA_STOCK_REAL_POSITION_FIELDS: List[Dict[str, str]] = [
372
+ {"name": "exchange", "type": "string", "description": "거래소 코드 (항상 KRX)"},
373
+ {"name": "market_code", "type": "string", "description": "시장 구분 코드"},
374
+ {"name": "symbol", "type": "string", "description": "종목코드"},
375
+ {"name": "name", "type": "string", "description": "종목명"},
376
+ {"name": "qty", "type": "number", "description": "보유 수량"},
377
+ {"name": "quantity", "type": "number", "description": "보유 수량 (주문 노드 호환 별칭)"},
378
+ {"name": "price", "type": "number", "description": "현재가 (주문 노드 호환 별칭)"},
379
+ {"name": "avg_price", "type": "number", "description": "평균 매입가"},
380
+ {"name": "current_price", "type": "number", "description": "현재가"},
381
+ {"name": "pnl_amount", "type": "number", "description": "평가 손익 (금액)"},
382
+ {"name": "pnl_rate", "type": "number", "description": "수익률 (%)"},
383
+ {"name": "eval_amount", "type": "number", "description": "평가 금액"},
384
+ {"name": "currency", "type": "string", "description": "통화 코드 (항상 KRW)"},
385
+ {"name": "product", "type": "string", "description": "상품 구분 (항상 korea_stock)"},
386
+ ]
387
+
388
+ OVERSEAS_FUTURES_REAL_POSITION_FIELDS: List[Dict[str, str]] = [
389
+ {"name": "exchange", "type": "string", "description": "거래소 코드"},
390
+ {"name": "symbol", "type": "string", "description": "종목코드 (월물 포함)"},
391
+ {"name": "name", "type": "string", "description": "종목명"},
392
+ {"name": "direction", "type": "string", "description": "포지션 방향 (long / short)"},
393
+ {"name": "close_side", "type": "string", "description": "청산 주문 방향 (sell / buy)"},
394
+ {"name": "qty", "type": "number", "description": "보유 계약 수"},
395
+ {"name": "quantity", "type": "number", "description": "보유 계약 수 (주문 노드 호환 별칭)"},
396
+ {"name": "price", "type": "number", "description": "현재가 (주문 노드 호환 별칭)"},
397
+ {"name": "entry_price", "type": "number", "description": "진입 가격"},
398
+ {"name": "current_price", "type": "number", "description": "현재가"},
399
+ {"name": "pnl_amount", "type": "number", "description": "평가 손익 (금액)"},
400
+ {"name": "pnl_rate", "type": "number", "description": "수익률 (%)"},
401
+ {"name": "currency", "type": "string", "description": "통화 코드"},
402
+ {"name": "product", "type": "string", "description": "상품 구분 (항상 overseas_futures)"},
227
403
  ]
228
404
 
229
405
  ORDER_RESULT_FIELDS: List[Dict[str, str]] = [
@@ -236,16 +412,44 @@ ORDER_RESULT_FIELDS: List[Dict[str, str]] = [
236
412
  {"name": "status", "type": "string", "description": "주문 상태"},
237
413
  ]
238
414
 
415
+ # ⚠️ 이 선언은 **런타임이 실제로 내보내는 키와 정확히 같아야 한다.**
416
+ # resolver 가 이 이름들로 필드 존재를 정적 검증하므로, 어긋나면 두 방향으로 다 틀린다:
417
+ # 실제 있는 필드를 바인딩 → INVALID_EXPRESSION_REF 로 **거부**
418
+ # 실제 없는 필드를 바인딩 → **통과**시킨 뒤 런타임에 조용히 None
419
+ # (2026-07-13 실측: 선언이 current_price/change_percent 였으나 런타임은 price/change_pct 였다.)
420
+ # 런타임 정본: executor.py `MarketDataNodeExecutor._fetch_overseas_stock`
239
421
  PRICE_DATA_FIELDS: List[Dict[str, str]] = [
240
- {"name": "exchange", "type": "string", "description": "거래소 코드"},
241
422
  {"name": "symbol", "type": "string", "description": "종목코드"},
242
- {"name": "current_price", "type": "number", "description": "현재가"},
423
+ {"name": "exchange", "type": "string", "description": "거래소 코드"},
424
+ {"name": "price", "type": "number", "description": "현재가"},
425
+ {"name": "change", "type": "number", "description": "전일대비 (하락이면 음수)"},
426
+ {"name": "change_pct", "type": "number", "description": "등락률 (%)"},
243
427
  {"name": "volume", "type": "number", "description": "거래량"},
244
- {"name": "change_percent", "type": "number", "description": "등락률 (%)"},
428
+ {"name": "open", "type": "number", "description": "시가"},
429
+ {"name": "high", "type": "number", "description": "고가"},
430
+ {"name": "low", "type": "number", "description": "저가"},
431
+ {"name": "close", "type": "number", "description": "종가 (현재가와 동일)"},
245
432
  {"name": "per", "type": "number", "description": "PER (주가수익비율)"},
246
433
  {"name": "eps", "type": "number", "description": "EPS (주당순이익)"},
247
434
  ]
248
435
 
436
+ # ── 해외선물 시세 전용 (o3105 기반) ──
437
+ # 해외주식과 모양이 다르다(symbol_name 있음 / per·eps 없음). 상수를 공유하면 드리프트가 난다.
438
+ # 런타임 정본: executor.py `MarketDataNodeExecutor._fetch_overseas_futures`
439
+ OVERSEAS_FUTURES_PRICE_DATA_FIELDS: List[Dict[str, str]] = [
440
+ {"name": "symbol", "type": "string", "description": "종목코드 (월물 심볼)"},
441
+ {"name": "exchange", "type": "string", "description": "거래소 코드"},
442
+ {"name": "symbol_name", "type": "string", "description": "종목명"},
443
+ {"name": "price", "type": "number", "description": "현재가"},
444
+ {"name": "change", "type": "number", "description": "전일대비 (하락이면 음수)"},
445
+ {"name": "change_pct", "type": "number", "description": "등락률 (%)"},
446
+ {"name": "volume", "type": "number", "description": "거래량"},
447
+ {"name": "open", "type": "number", "description": "시가"},
448
+ {"name": "high", "type": "number", "description": "고가"},
449
+ {"name": "low", "type": "number", "description": "저가"},
450
+ {"name": "close", "type": "number", "description": "종가 (현재가와 동일)"},
451
+ ]
452
+
249
453
  FUNDAMENTAL_DATA_FIELDS: List[Dict[str, str]] = [
250
454
  {"name": "exchange", "type": "string", "description": "거래소 코드"},
251
455
  {"name": "symbol", "type": "string", "description": "종목코드"},
@@ -324,26 +528,19 @@ ORDER_EVENT_FIELDS: List[Dict[str, str]] = [
324
528
  {"name": "timestamp", "type": "string", "description": "이벤트 시각"},
325
529
  ]
326
530
 
531
+ # ── 실시간 시세 노드의 봉(bar) ──
532
+ # 🔴 실시간 노드의 `ohlcv_data` / `data` 는 **같은 객체**다 (`data` 는 별칭).
533
+ # 값의 모양은 `{"AAPL": [bar, ...]}` — 종목코드로 키를 잡은 dict 이고, bar 가 아래 필드다.
534
+ # 옛 선언은 `exchange` / `timestamp` 를 약속했지만 런타임 bar 에 그런 키는 없고(실제는 `date`),
535
+ # `data` 포트는 아예 `current_price` / `bid_price` / `ask_price` / `change_percent` 를 약속했는데
536
+ # **그 값을 만드는 코드(`_build_full_data`)는 아무도 호출하지 않는 죽은 코드였다.**
327
537
  OHLCV_DATA_FIELDS: List[Dict[str, str]] = [
328
- {"name": "exchange", "type": "string", "description": "거래소 코드"},
329
- {"name": "symbol", "type": "string", "description": "종목코드"},
538
+ {"name": "date", "type": "string", "description": " 일자 (YYYYMMDD)"},
330
539
  {"name": "open", "type": "number", "description": "시가"},
331
540
  {"name": "high", "type": "number", "description": "고가"},
332
541
  {"name": "low", "type": "number", "description": "저가"},
333
- {"name": "close", "type": "number", "description": "종가"},
334
- {"name": "volume", "type": "number", "description": "거래량"},
335
- {"name": "timestamp", "type": "string", "description": "캔들 시각"},
336
- ]
337
-
338
- MARKET_DATA_FULL_FIELDS: List[Dict[str, str]] = [
339
- {"name": "exchange", "type": "string", "description": "거래소 코드"},
340
- {"name": "symbol", "type": "string", "description": "종목코드"},
341
- {"name": "current_price", "type": "number", "description": "현재가"},
342
- {"name": "bid_price", "type": "number", "description": "매수호가 (해외주식 실시간에서는 미제공)"},
343
- {"name": "ask_price", "type": "number", "description": "매도호가 (해외주식 실시간에서는 미제공)"},
542
+ {"name": "close", "type": "number", "description": "종가 (= 최근 체결가)"},
344
543
  {"name": "volume", "type": "number", "description": "거래량"},
345
- {"name": "change_percent", "type": "number", "description": "등락률 (%)"},
346
- {"name": "timestamp", "type": "string", "description": "시세 시각"},
347
544
  ]
348
545
 
349
546
  CONDITION_RESULT_FIELDS: List[Dict[str, str]] = [
@@ -22,7 +22,7 @@ from programgarden_core.nodes.base import (
22
22
  OutputPort,
23
23
  ProductScope,
24
24
  BrokerProvider,
25
- PRICE_DATA_FIELDS,
25
+ OVERSEAS_FUTURES_PRICE_DATA_FIELDS,
26
26
  )
27
27
 
28
28
 
@@ -74,7 +74,7 @@ class OverseasFuturesMarketDataNode(BaseNode):
74
74
  ],
75
75
  }
76
76
  _features: ClassVar[List[str]] = [
77
- "Returns a single futures contract's snapshot: current_price, volume, open_interest, change_percent, bid, ask",
77
+ "Returns a single futures contract's snapshot: symbol, exchange, symbol_name, price, change, change_pct, volume, open, high, low, close",
78
78
  "Item-based execution: pair with SplitNode to query multiple contracts in sequence",
79
79
  "is_tool_enabled=True — AI Agent can call this node to look up live futures prices autonomously",
80
80
  "Supported exchanges: CME, EUREX, SGX, HKEX — symbol format includes contract month code (e.g., ESH26)",
@@ -121,7 +121,7 @@ class OverseasFuturesMarketDataNode(BaseNode):
121
121
  }
122
122
  ],
123
123
  },
124
- "expected_output": "values port: array of {symbol, exchange, current_price, volume, open_interest, change_percent, bid, ask}.",
124
+ "expected_output": "values port: array of {symbol, exchange, symbol_name, price, change, change_pct, volume, open, high, low, close}.",
125
125
  },
126
126
  {
127
127
  "title": "Multi-contract scan — compare HKEX futures prices",
@@ -161,9 +161,9 @@ class OverseasFuturesMarketDataNode(BaseNode):
161
161
  "Broker connection auto-injected from OverseasFuturesBrokerNode."
162
162
  ),
163
163
  "output_consumption": (
164
- "Consume the `values` port ONLY — an array of {symbol, exchange, current_price, volume, open_interest, change_percent, bid, ask}. "
164
+ "Consume the `values` port ONLY — an array of {symbol, exchange, symbol_name, price, change, change_pct, volume, open, high, low, close}. "
165
165
  "⚠️ The `value` (singular) port is NOT populated at runtime — the executor emits `values` only; "
166
- "binding `{{ nodes.market.value }}` (or `.value.current_price`) silently resolves to None. "
166
+ "binding `{{ nodes.market.value }}` (or `.value.price`) silently resolves to None. "
167
167
  "TableDisplayNode.data ← `{{ nodes.market.values }}`; PositionSizingNode.market_data ← `{{ nodes.market.values }}`."
168
168
  ),
169
169
  "common_combinations": [
@@ -183,11 +183,13 @@ class OverseasFuturesMarketDataNode(BaseNode):
183
183
  InputPort(name="trigger", type="signal", description="i18n:ports.trigger", required=False),
184
184
  ]
185
185
  _outputs: List[OutputPort] = [
186
- OutputPort(name="value", type="market_data", description="i18n:ports.market_data_value", fields=PRICE_DATA_FIELDS),
186
+ OutputPort(name="value", type="market_data", description="i18n:ports.market_data_value",
187
+ fields=OVERSEAS_FUTURES_PRICE_DATA_FIELDS),
187
188
  OutputPort(
188
189
  name="values",
189
190
  type="array",
190
- description="Array of per-contract market quotes — [{symbol, exchange, current_price, ...}, ...]",
191
+ description="Array of per-contract market quotes — [{symbol, exchange, price, change, change_pct, ...}, ...]",
192
+ fields=OVERSEAS_FUTURES_PRICE_DATA_FIELDS,
191
193
  ),
192
194
  ]
193
195
 
@@ -74,7 +74,7 @@ class KoreaStockMarketDataNode(BaseNode):
74
74
  ],
75
75
  }
76
76
  _features: ClassVar[List[str]] = [
77
- "Returns a single domestic stock's snapshot: current_price, volume, change_percent, bid, ask, per, eps, 52w_high, 52w_low",
77
+ "Returns a single domestic stock's snapshot: symbol, exchange, name, price, change, change_pct, volume, open, high, low, close, per, pbr",
78
78
  "Symbol format is a 6-digit KRX code (e.g., '005930') — no exchange field required (domestic market only)",
79
79
  "Item-based execution: pair with SplitNode to query multiple domestic stocks in sequence",
80
80
  "is_tool_enabled=True — AI Agent can call this node to look up KRX stock prices autonomously",
@@ -121,7 +121,7 @@ class KoreaStockMarketDataNode(BaseNode):
121
121
  }
122
122
  ],
123
123
  },
124
- "expected_output": "values port: array of {symbol, current_price, volume, change_percent, bid, ask, per, eps} per domestic stock.",
124
+ "expected_output": "values port: array of {symbol, exchange, name, price, change, change_pct, volume, open, high, low, close, per, pbr} per domestic stock.",
125
125
  },
126
126
  {
127
127
  "title": "Price lookup for domestic PositionSizingNode",
@@ -165,11 +165,11 @@ class KoreaStockMarketDataNode(BaseNode):
165
165
  ),
166
166
  "output_consumption": (
167
167
  "Consume the `values` port ONLY — an array of per-symbol snapshots "
168
- "[{symbol, current_price, volume, change_percent, bid, ask, per, eps, 52w_high, 52w_low}, ...]. "
168
+ "[{symbol, exchange, name, price, change, change_pct, volume, open, high, low, close, per, pbr}, ...]. "
169
169
  "⚠️ The `value` (singular) port is NOT populated at runtime — the executor emits `values` only; "
170
- "binding `{{ nodes.market.value }}` (or `.value.current_price`) silently resolves to None. "
170
+ "binding `{{ nodes.market.value }}` (or `.value.price`) silently resolves to None. "
171
171
  "TableDisplayNode.data ← `{{ nodes.market.values }}`; PositionSizingNode.market_data ← `{{ nodes.market.values }}` "
172
- "(the array carries each symbol's current_price, which PositionSizingNode resolves internally)."
172
+ "(the array carries each symbol's price, which PositionSizingNode resolves internally)."
173
173
  ),
174
174
  "common_combinations": [
175
175
  "KoreaStockMarketDataNode.values → TableDisplayNode.data (KRX price monitor)",
@@ -189,7 +189,12 @@ class KoreaStockMarketDataNode(BaseNode):
189
189
  ]
190
190
  _outputs: List[OutputPort] = [
191
191
  OutputPort(name="value", type="market_data", description="i18n:ports.market_data_value", fields=KOREA_STOCK_PRICE_DATA_FIELDS),
192
- OutputPort(name="values", type="array", description="Array of per-symbol market quotes"),
192
+ OutputPort(
193
+ name="values",
194
+ type="array",
195
+ description="Array of per-symbol market quotes — [{symbol, exchange, name, price, change, change_pct, ...}, ...]",
196
+ fields=KOREA_STOCK_PRICE_DATA_FIELDS,
197
+ ),
193
198
  ]
194
199
 
195
200
  _version: ClassVar[str] = "1.0.0"
@@ -74,7 +74,7 @@ class OverseasStockMarketDataNode(BaseNode):
74
74
  ],
75
75
  }
76
76
  _features: ClassVar[List[str]] = [
77
- "Returns a single symbol's snapshot: current_price, volume, change_percent, bid, ask, per, eps, 52w_high, 52w_low",
77
+ "Returns a single symbol's snapshot: symbol, exchange, price, change, change_pct, volume, open, high, low, close, per, eps",
78
78
  "Item-based execution: receives one {exchange, symbol} dict per call and emits one value dict — pair with SplitNode for multi-symbol queries",
79
79
  "is_tool_enabled=True — AI Agent can call this node to look up live prices autonomously",
80
80
  "Broker connection is auto-injected via DAG traversal — no explicit binding needed",
@@ -121,7 +121,7 @@ class OverseasStockMarketDataNode(BaseNode):
121
121
  }
122
122
  ],
123
123
  },
124
- "expected_output": "values port: array of {symbol, exchange, current_price, volume, change_percent, bid, ask, per, eps} for each symbol.",
124
+ "expected_output": "values port: array of {symbol, exchange, price, change, change_pct, volume, open, high, low, close, per, eps} for each symbol.",
125
125
  },
126
126
  {
127
127
  "title": "Price lookup feeding PositionSizingNode",
@@ -164,12 +164,11 @@ class OverseasStockMarketDataNode(BaseNode):
164
164
  "The node auto-receives the broker connection via DAG traversal from OverseasStockBrokerNode."
165
165
  ),
166
166
  "output_consumption": (
167
- "Consume the `values` port ONLY — an array of {symbol, exchange, current_price, volume, change_percent, "
168
- "bid, ask, per, eps, 52w_high, 52w_low}. "
167
+ "Consume the `values` port ONLY — an array of {symbol, exchange, price, change, change_pct, volume, open, high, low, close, per, eps}. "
169
168
  "⚠️ The `value` (singular) port is NOT populated at runtime — the executor emits `values` only; "
170
- "binding `{{ nodes.market.value }}` (or `.value.current_price`) silently resolves to None. "
169
+ "binding `{{ nodes.market.value }}` (or `.value.price`) silently resolves to None. "
171
170
  "TableDisplayNode.data ← `{{ nodes.market.values }}`; PositionSizingNode.market_data ← `{{ nodes.market.values }}` "
172
- "(the array carries each symbol's current_price, resolved internally)."
171
+ "(the array carries each symbol's price, resolved internally)."
173
172
  ),
174
173
  "common_combinations": [
175
174
  "WatchlistNode/SymbolQueryNode → OverseasStockMarketDataNode.symbols (multi-symbol price fetch)",
@@ -194,11 +193,16 @@ class OverseasStockMarketDataNode(BaseNode):
194
193
  description="i18n:ports.market_data_value",
195
194
  fields=PRICE_DATA_FIELDS,
196
195
  example={
197
- "exchange": "NASDAQ",
198
196
  "symbol": "AAPL",
199
- "current_price": 187.45,
197
+ "exchange": "NASDAQ",
198
+ "price": 187.45,
199
+ "change": -2.34,
200
+ "change_pct": -1.23,
200
201
  "volume": 12_345_678,
201
- "change_percent": -1.23,
202
+ "open": 189.10,
203
+ "high": 190.02,
204
+ "low": 186.90,
205
+ "close": 187.45,
202
206
  "per": 28.5,
203
207
  "eps": 6.57,
204
208
  },
@@ -206,7 +210,8 @@ class OverseasStockMarketDataNode(BaseNode):
206
210
  OutputPort(
207
211
  name="values",
208
212
  type="array",
209
- description="Array of per-symbol market quotes — [{symbol, exchange, current_price, ...}, ...]",
213
+ description="Array of per-symbol market quotes — [{symbol, exchange, price, change, change_pct, ...}, ...]",
214
+ fields=PRICE_DATA_FIELDS,
210
215
  ),
211
216
  ]
212
217
 
@@ -10,7 +10,7 @@ ProgramGarden Core - Display Nodes
10
10
  - SummaryDisplayNode: JSON/요약 데이터 표시
11
11
  """
12
12
 
13
- from typing import Optional, List, Literal, Dict, Any, ClassVar, TYPE_CHECKING
13
+ from typing import Optional, List, Literal, Dict, Any, Union, ClassVar, TYPE_CHECKING
14
14
  from pydantic import Field
15
15
 
16
16
  if TYPE_CHECKING:
@@ -304,9 +304,16 @@ class TableDisplayNode(BaseDisplayNode):
304
304
  _updated_at: ClassVar[str] = "2026-05-19"
305
305
  _change_note: ClassVar[Optional[str]] = None
306
306
 
307
- columns: Optional[List[str]] = Field(
307
+ # columns 형태를 모두 받는다(스키마=코드=카탈로그 정합; 렌더러 _normalize_columns):
308
+ # 1) 필드명 문자열 리스트 — ["symbol", "close"]
309
+ # 2) {key, label} 객체 리스트 — [{"key": "close", "label": "종가"}] (현지화 헤더용)
310
+ # 옛 선언은 List[str] 뿐이라 객체형을 코드만 몰래 허용하는 드리프트였다.
311
+ columns: Optional[List[Union[str, Dict[str, Any]]]] = Field(
308
312
  default=None,
309
- description="표시할 컬럼 목록 (미지정시 전체)",
313
+ description=(
314
+ "표시할 컬럼 (미지정시 전체). 필드명 문자열 리스트 [\"symbol\",\"close\"] 또는 "
315
+ "현지화 헤더용 객체 리스트 [{\"key\":\"close\",\"label\":\"종가\"}] 둘 다 허용."
316
+ ),
310
317
  )
311
318
 
312
319
  limit: Optional[int] = Field(
@@ -336,7 +343,10 @@ class TableDisplayNode(BaseDisplayNode):
336
343
  "columns": FieldSchema(
337
344
  name="columns",
338
345
  type=FieldType.ARRAY,
339
- description="표시할 컬럼 목록",
346
+ description=(
347
+ "표시할 컬럼. 필드명 문자열 리스트 [\"symbol\",\"close\"] 또는 "
348
+ "{key,label} 객체 리스트 [{\"key\":\"close\",\"label\":\"종가\"}](현지화 헤더) 허용"
349
+ ),
340
350
  category=FieldCategory.SETTINGS,
341
351
  expression_mode=ExpressionMode.FIXED_ONLY,
342
352
  ui_options={"multiple": True},
@@ -457,6 +457,11 @@ class SplitNode(BaseNode):
457
457
  "reason": "Auto-iterate is implicit and handled by the executor — adding SplitNode duplicates the iteration and doubles the workload.",
458
458
  "alternative": "Bind `{{ item }}` directly on the downstream node and delete SplitNode.",
459
459
  },
460
+ {
461
+ "pattern": "Adding SplitNode without wiring its `array` input to an upstream list producer",
462
+ "reason": "SplitNode with no array source (no WatchlistNode/MarketUniverseNode/AccountNode/ConditionNode feeding a list, and no `array` binding) emits zero items — downstream `{{ nodes.split.item }}` resolves empty and the workflow produces silent empty results.",
463
+ "alternative": "Wire an upstream node that outputs an array into SplitNode, or — for a single known symbol — delete SplitNode and bind that symbol directly on the downstream node.",
464
+ },
460
465
  {
461
466
  "pattern": "parallel=True for order-placement loops",
462
467
  "reason": "Placing orders concurrently without delay_ms will trip LS-Sec TR rate limits and cause spurious cancellations.",
@@ -530,7 +535,7 @@ class SplitNode(BaseNode):
530
535
  },
531
536
  ]
532
537
  _node_guide: ClassVar[Dict[str, Any]] = {
533
- "input_handling": "`array` input typically bound implicitly by the upstream array output. No explicit config needed; SplitNode infers the iteration from the DAG.",
538
+ "input_handling": "`array` input is REQUIRED and MUST have a source: either an incoming edge from an upstream node that outputs an ARRAY (WatchlistNode/MarketUniverseNode → symbols, AccountNode → positions/held_symbols, ConditionNode → values, etc.), or an explicit `array` config binding. SplitNode has NOTHING to split if no upstream produces a list — it emits zero items and every downstream `{{ nodes.split.item }}` silently resolves empty. If you already know a single concrete symbol (e.g. 'AAPL'), do NOT add SplitNode — bind that one symbol directly on the downstream node.",
534
539
  "output_consumption": "Downstream binds `{{ nodes.split.item }}` for the current element, `{{ nodes.split.index }}` for the 0-based position, and `{{ nodes.split.total }}` for the count.",
535
540
  "common_combinations": [
536
541
  "WatchlistNode → SplitNode → OverseasStockFundamentalNode",
@@ -21,11 +21,10 @@ from programgarden_core.nodes.base import (
21
21
  ProductScope,
22
22
  BrokerProvider,
23
23
  OVERSEAS_FUTURES_REAL_BALANCE_FIELDS,
24
- MARKET_DATA_FULL_FIELDS,
25
24
  OHLCV_DATA_FIELDS,
26
25
  ORDER_EVENT_FIELDS,
27
26
  ORDER_LIST_FIELDS,
28
- POSITION_FIELDS,
27
+ OVERSEAS_FUTURES_REAL_POSITION_FIELDS,
29
28
  SYMBOL_LIST_FIELDS,
30
29
  PRICE_DATA_FIELDS,
31
30
  )
@@ -65,7 +64,7 @@ class OverseasFuturesRealMarketDataNode(BaseNode):
65
64
  ],
66
65
  }
67
66
  _features: ClassVar[List[str]] = [
68
- "Streams real-time trade events from LS Securities WebSocket — ohlcv_data port emits aggregated candles; data port emits raw tick dicts",
67
+ "Streams real-time trade events from LS Securities WebSocket — ohlcv_data 포트가 집계된 봉을 내보낸다 (data 포트는 같은 객체의 별칭)",
69
68
  "stay_connected=True keeps the WebSocket subscription live between cycles (recommended for realtime strategies)",
70
69
  "Supports CME, EUREX, SGX, HKEX contracts — symbol must include contract month code (e.g., ESH26)",
71
70
  "Item-based execution: one subscription per node; use multiple nodes to watch multiple contracts",
@@ -179,8 +178,11 @@ class OverseasFuturesRealMarketDataNode(BaseNode):
179
178
  "Use OverseasFuturesBrokerNode as the upstream broker."
180
179
  ),
181
180
  "output_consumption": (
182
- "The `ohlcv_data` port emits aggregated candle dicts: {symbol, exchange, open, high, low, close, volume}. "
183
- "The `data` port emits the raw full tick dict. "
181
+ "`ohlcv_data` `data` **같은 객체**다 `data` 별칭이다. "
182
+ "(예전 문서는 `data` bid/ask 담은 raw tick dict 이라고 했지만, 그 값을 만드는 코드는 "
183
+ "아무도 호출하지 않는 죽은 코드였다. 런타임에 bid/ask/current_price/change_percent 는 없다.) "
184
+ "값의 모양: {\"AAPL\": [{date, open, high, low, close, volume}, ...]} — 종목코드로 키를 잡은 dict 이고, "
185
+ "봉 한 줄에는 exchange/symbol/timestamp 가 없다. "
184
186
  "ALWAYS insert ThrottleNode before any order or AI node downstream."
185
187
  ),
186
188
  "common_combinations": [
@@ -210,7 +212,8 @@ class OverseasFuturesRealMarketDataNode(BaseNode):
210
212
  ]
211
213
  _outputs: List[OutputPort] = [
212
214
  OutputPort(name="ohlcv_data", type="ohlcv_data", description="i18n:ports.ohlcv_data", fields=OHLCV_DATA_FIELDS),
213
- OutputPort(name="data", type="market_data_full", description="i18n:ports.market_data_full", fields=MARKET_DATA_FULL_FIELDS),
215
+ OutputPort(name="data", type="ohlcv_data", description="i18n:ports.market_data_full", fields=OHLCV_DATA_FIELDS),
216
+ OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=SYMBOL_LIST_FIELDS),
214
217
  ]
215
218
 
216
219
  _version: ClassVar[str] = "1.0.0"
@@ -401,7 +404,7 @@ class OverseasFuturesRealAccountNode(BaseNode):
401
404
  OutputPort(name="held_symbols", type="symbol_list", description="i18n:ports.held_symbols", fields=SYMBOL_LIST_FIELDS),
402
405
  OutputPort(name="balance", type="balance_data", description="i18n:ports.balance", fields=OVERSEAS_FUTURES_REAL_BALANCE_FIELDS),
403
406
  OutputPort(name="open_orders", type="order_list", description="i18n:ports.open_orders", fields=ORDER_LIST_FIELDS),
404
- OutputPort(name="positions", type="position_data", description="i18n:ports.positions", fields=POSITION_FIELDS),
407
+ OutputPort(name="positions", type="position_data", description="i18n:ports.positions", fields=OVERSEAS_FUTURES_REAL_POSITION_FIELDS),
405
408
  ]
406
409
 
407
410
  _version: ClassVar[str] = "1.0.0"
@@ -21,11 +21,10 @@ from programgarden_core.nodes.base import (
21
21
  ProductScope,
22
22
  BrokerProvider,
23
23
  KOREA_STOCK_REAL_BALANCE_FIELDS,
24
- MARKET_DATA_FULL_FIELDS,
25
24
  OHLCV_DATA_FIELDS,
26
25
  ORDER_EVENT_FIELDS,
27
26
  ORDER_LIST_FIELDS,
28
- POSITION_FIELDS,
27
+ KOREA_STOCK_REAL_POSITION_FIELDS,
29
28
  SYMBOL_LIST_FIELDS,
30
29
  )
31
30
 
@@ -64,7 +63,7 @@ class KoreaStockRealMarketDataNode(BaseNode):
64
63
  ],
65
64
  }
66
65
  _features: ClassVar[List[str]] = [
67
- "Streams real-time trade events from LS Securities WebSocket for KRX stocks — ohlcv_data port emits aggregated candles; data port emits raw tick dicts",
66
+ "Streams real-time trade events from LS Securities WebSocket for KRX stocks — ohlcv_data 포트가 집계된 봉을 내보낸다 (data 포트는 같은 객체의 별칭)",
68
67
  "stay_connected=True keeps the WebSocket subscription live between cycles (recommended for realtime strategies)",
69
68
  "Symbol format: 6-digit KRX code (e.g., '005930') without exchange field — domestic market implied",
70
69
  "Item-based execution: one subscription per node; use multiple nodes to watch multiple domestic stocks",
@@ -178,8 +177,11 @@ class KoreaStockRealMarketDataNode(BaseNode):
178
177
  "Use KoreaStockBrokerNode as the upstream broker."
179
178
  ),
180
179
  "output_consumption": (
181
- "The `ohlcv_data` port emits aggregated candle dicts: {symbol, open, high, low, close, volume}. "
182
- "The `data` port emits the raw full tick dict including bid/ask. "
180
+ "`ohlcv_data` `data` **같은 객체**다 `data` 별칭이다. "
181
+ "(예전 문서는 `data` bid/ask 담은 raw tick dict 이라고 했지만, 그 값을 만드는 코드는 "
182
+ "아무도 호출하지 않는 죽은 코드였다. 런타임에 bid/ask/current_price/change_percent 는 없다.) "
183
+ "값의 모양: {\"AAPL\": [{date, open, high, low, close, volume}, ...]} — 종목코드로 키를 잡은 dict 이고, "
184
+ "봉 한 줄에는 exchange/symbol/timestamp 가 없다. "
183
185
  "ALWAYS insert ThrottleNode before any order or AI node downstream."
184
186
  ),
185
187
  "common_combinations": [
@@ -209,7 +211,8 @@ class KoreaStockRealMarketDataNode(BaseNode):
209
211
  ]
210
212
  _outputs: List[OutputPort] = [
211
213
  OutputPort(name="ohlcv_data", type="ohlcv_data", description="i18n:ports.ohlcv_data", fields=OHLCV_DATA_FIELDS),
212
- OutputPort(name="data", type="market_data_full", description="i18n:ports.market_data_full", fields=MARKET_DATA_FULL_FIELDS),
214
+ OutputPort(name="data", type="ohlcv_data", description="i18n:ports.market_data_full", fields=OHLCV_DATA_FIELDS),
215
+ OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=SYMBOL_LIST_FIELDS),
213
216
  ]
214
217
 
215
218
  _version: ClassVar[str] = "1.0.0"
@@ -404,7 +407,7 @@ class KoreaStockRealAccountNode(BaseNode):
404
407
  OutputPort(name="held_symbols", type="symbol_list", description="i18n:ports.held_symbols", fields=SYMBOL_LIST_FIELDS),
405
408
  OutputPort(name="balance", type="balance_data", description="i18n:ports.balance", fields=KOREA_STOCK_REAL_BALANCE_FIELDS),
406
409
  OutputPort(name="open_orders", type="order_list", description="i18n:ports.open_orders", fields=ORDER_LIST_FIELDS),
407
- OutputPort(name="positions", type="position_data", description="i18n:ports.positions", fields=POSITION_FIELDS),
410
+ OutputPort(name="positions", type="position_data", description="i18n:ports.positions", fields=KOREA_STOCK_REAL_POSITION_FIELDS),
408
411
  ]
409
412
 
410
413
  _version: ClassVar[str] = "1.0.0"
@@ -21,11 +21,10 @@ from programgarden_core.nodes.base import (
21
21
  ProductScope,
22
22
  BrokerProvider,
23
23
  OVERSEAS_STOCK_REAL_BALANCE_FIELDS,
24
- MARKET_DATA_FULL_FIELDS,
25
24
  OHLCV_DATA_FIELDS,
26
25
  ORDER_EVENT_FIELDS,
27
26
  ORDER_LIST_FIELDS,
28
- POSITION_FIELDS,
27
+ OVERSEAS_STOCK_REAL_POSITION_FIELDS,
29
28
  SYMBOL_LIST_FIELDS,
30
29
  PRICE_DATA_FIELDS,
31
30
  )
@@ -66,7 +65,7 @@ class OverseasStockRealMarketDataNode(BaseNode):
66
65
  ],
67
66
  }
68
67
  _features: ClassVar[List[str]] = [
69
- "Streams GSC (trade/tick) events from LS Securities WebSocket — ohlcv_data port emits aggregated candles; data port emits raw tick dicts",
68
+ "Streams GSC (trade/tick) events from LS Securities WebSocket — ohlcv_data 포트가 집계된 봉을 내보낸다 (data 포트는 같은 객체의 별칭)",
70
69
  "stay_connected=True keeps the WebSocket subscription live between cycles (recommended for realtime strategies)",
71
70
  "Item-based execution: one subscription per node; use multiple nodes or SplitNode to watch several symbols",
72
71
  "Automatically re-subscribes after WebSocket reconnection events",
@@ -180,8 +179,11 @@ class OverseasStockRealMarketDataNode(BaseNode):
180
179
  "Set stay_connected=True (default) to avoid reconnection overhead on every cycle."
181
180
  ),
182
181
  "output_consumption": (
183
- "The `ohlcv_data` port emits aggregated candle dicts: {symbol, exchange, open, high, low, close, volume}. "
184
- "The `data` port emits the raw full tick dict including bid/ask fields. "
182
+ "`ohlcv_data` `data` **같은 객체**다 `data` 별칭이다. "
183
+ "(예전 문서는 `data` bid/ask 담은 raw tick dict 이라고 했지만, 값을 만드는 코드는 "
184
+ "아무도 호출하지 않는 죽은 코드였다. 런타임에 bid/ask/current_price/change_percent 는 없다.) "
185
+ "값의 모양: {\"AAPL\": [{date, open, high, low, close, volume}, ...]} — 종목코드로 키를 잡은 dict 이고, "
186
+ "봉 한 줄에는 exchange/symbol/timestamp 가 없다. "
185
187
  "ALWAYS insert ThrottleNode before any order or AI node downstream."
186
188
  ),
187
189
  "common_combinations": [
@@ -211,7 +213,8 @@ class OverseasStockRealMarketDataNode(BaseNode):
211
213
  ]
212
214
  _outputs: List[OutputPort] = [
213
215
  OutputPort(name="ohlcv_data", type="ohlcv_data", description="i18n:ports.ohlcv_data", fields=OHLCV_DATA_FIELDS),
214
- OutputPort(name="data", type="market_data_full", description="i18n:ports.market_data_full", fields=MARKET_DATA_FULL_FIELDS),
216
+ OutputPort(name="data", type="ohlcv_data", description="i18n:ports.market_data_full", fields=OHLCV_DATA_FIELDS),
217
+ OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=SYMBOL_LIST_FIELDS),
215
218
  ]
216
219
 
217
220
  _version: ClassVar[str] = "1.0.0"
@@ -406,7 +409,7 @@ class OverseasStockRealAccountNode(BaseNode):
406
409
  OutputPort(name="held_symbols", type="symbol_list", description="i18n:ports.held_symbols", fields=SYMBOL_LIST_FIELDS),
407
410
  OutputPort(name="balance", type="balance_data", description="i18n:ports.balance", fields=OVERSEAS_STOCK_REAL_BALANCE_FIELDS),
408
411
  OutputPort(name="open_orders", type="order_list", description="i18n:ports.open_orders", fields=ORDER_LIST_FIELDS),
409
- OutputPort(name="positions", type="position_data", description="i18n:ports.positions", fields=POSITION_FIELDS),
412
+ OutputPort(name="positions", type="position_data", description="i18n:ports.positions", fields=OVERSEAS_STOCK_REAL_POSITION_FIELDS),
410
413
  ]
411
414
 
412
415
  _version: ClassVar[str] = "1.0.0"
@@ -24,6 +24,8 @@ from programgarden_core.nodes.base import (
24
24
  InputPort,
25
25
  OutputPort,
26
26
  SYMBOL_LIST_FIELDS,
27
+ SCREENER_SYMBOL_FIELDS,
28
+ MARKET_UNIVERSE_SYMBOL_FIELDS,
27
29
  )
28
30
  from programgarden_core.models.exchange import SymbolEntry, ProductType
29
31
 
@@ -251,7 +253,7 @@ class MarketUniverseNode(BaseNode):
251
253
 
252
254
  _inputs: List[InputPort] = []
253
255
  _outputs: List[OutputPort] = [
254
- OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=SYMBOL_LIST_FIELDS),
256
+ OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=MARKET_UNIVERSE_SYMBOL_FIELDS),
255
257
  OutputPort(name="count", type="integer", description="종목 수"),
256
258
  ]
257
259
 
@@ -453,7 +455,7 @@ class ScreenerNode(BaseNode):
453
455
  ),
454
456
  ]
455
457
  _outputs: List[OutputPort] = [
456
- OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=SYMBOL_LIST_FIELDS),
458
+ OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=SCREENER_SYMBOL_FIELDS),
457
459
  OutputPort(name="count", type="integer", description="결과 종목 수"),
458
460
  ]
459
461
 
@@ -20,6 +20,7 @@ from programgarden_core.nodes.base import (
20
20
  ProductScope,
21
21
  BrokerProvider,
22
22
  SYMBOL_LIST_FIELDS,
23
+ OVERSEAS_FUTURES_SYMBOL_QUERY_FIELDS,
23
24
  )
24
25
 
25
26
 
@@ -182,7 +183,7 @@ class OverseasFuturesSymbolQueryNode(BaseNode):
182
183
 
183
184
  _inputs: List[InputPort] = []
184
185
  _outputs: List[OutputPort] = [
185
- OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=SYMBOL_LIST_FIELDS),
186
+ OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=OVERSEAS_FUTURES_SYMBOL_QUERY_FIELDS),
186
187
  OutputPort(name="count", type="integer", description="Total symbol count"),
187
188
  ]
188
189
 
@@ -18,7 +18,7 @@ from programgarden_core.nodes.base import (
18
18
  OutputPort,
19
19
  ProductScope,
20
20
  BrokerProvider,
21
- SYMBOL_LIST_FIELDS,
21
+ KOREA_STOCK_SYMBOL_QUERY_FIELDS,
22
22
  )
23
23
 
24
24
 
@@ -169,7 +169,7 @@ class KoreaStockSymbolQueryNode(BaseNode):
169
169
 
170
170
  _inputs: List[InputPort] = []
171
171
  _outputs: List[OutputPort] = [
172
- OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=SYMBOL_LIST_FIELDS),
172
+ OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=KOREA_STOCK_SYMBOL_QUERY_FIELDS),
173
173
  OutputPort(name="count", type="integer", description="Total symbol count"),
174
174
  ]
175
175
 
@@ -18,7 +18,7 @@ from programgarden_core.nodes.base import (
18
18
  OutputPort,
19
19
  ProductScope,
20
20
  BrokerProvider,
21
- SYMBOL_LIST_FIELDS,
21
+ OVERSEAS_STOCK_SYMBOL_QUERY_FIELDS,
22
22
  )
23
23
 
24
24
 
@@ -177,7 +177,7 @@ class OverseasStockSymbolQueryNode(BaseNode):
177
177
 
178
178
  _inputs: List[InputPort] = []
179
179
  _outputs: List[OutputPort] = [
180
- OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=SYMBOL_LIST_FIELDS),
180
+ OutputPort(name="symbols", type="symbol_list", description="i18n:ports.symbols", fields=OVERSEAS_STOCK_SYMBOL_QUERY_FIELDS),
181
181
  OutputPort(name="count", type="integer", description="Total symbol count"),
182
182
  ]
183
183
 
@@ -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.18.0"
8
+ version = "1.20.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의"
11
11
  readme = "README.md"