programgarden 1.27.0__tar.gz → 1.28.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {programgarden-1.27.0 → programgarden-1.28.0}/PKG-INFO +2 -2
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/__init__.py +1 -1
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/context.py +29 -9
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/executor.py +169 -69
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resolver.py +121 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/pyproject.toml +2 -2
- {programgarden-1.27.0 → programgarden-1.28.0}/README.md +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/binding_validator.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/client.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/code_worker.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/__init__.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/node_runner.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resource/context.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.27.0 → programgarden-1.28.0}/programgarden/validation_recommender.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: programgarden
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.28.0
|
|
4
4
|
Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
|
|
5
5
|
License-Expression: AGPL-3.0-or-later
|
|
6
6
|
Author: 프로그램동산
|
|
@@ -16,7 +16,7 @@ Requires-Dist: croniter (>=6.0.0,<7.0.0)
|
|
|
16
16
|
Requires-Dist: litellm (>=1.40.0)
|
|
17
17
|
Requires-Dist: lxml (>=6.0.2,<7.0.0)
|
|
18
18
|
Requires-Dist: programgarden-community (>=1.13.11,<2.0.0)
|
|
19
|
-
Requires-Dist: programgarden-core (>=1.
|
|
19
|
+
Requires-Dist: programgarden-core (>=1.20.0,<2.0.0)
|
|
20
20
|
Requires-Dist: programgarden-finance (>=1.6.14,<2.0.0)
|
|
21
21
|
Requires-Dist: psutil (>=6.0.0,<7.0.0)
|
|
22
22
|
Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
|
|
@@ -2626,26 +2626,46 @@ class ExecutionContext:
|
|
|
2626
2626
|
logger.warning(f"Failed to sync fills from history: {e}")
|
|
2627
2627
|
return 0
|
|
2628
2628
|
|
|
2629
|
+
# 노드 출력에 실려 외부로 나가면 안 되는 키 (부분일치, 소문자 비교).
|
|
2630
|
+
# BrokerNode 는 `connection.appkey/appsecret` 을 하류 노드에 넘기기 위해 **출력에 싣는다**
|
|
2631
|
+
# (내부 전송 경로라 지울 수 없다). 따라서 외부로 나가는 모든 경계에서 가려야 한다.
|
|
2632
|
+
SENSITIVE_OUTPUT_KEYS = frozenset(
|
|
2633
|
+
{"appkey", "appsecret", "secret", "password", "token", "api_key", "apikey", "private_key"}
|
|
2634
|
+
)
|
|
2635
|
+
|
|
2636
|
+
def _sanitize_value(self, value: Any) -> Any:
|
|
2637
|
+
"""dict/list 를 재귀적으로 훑어 민감 키를 가린다."""
|
|
2638
|
+
if isinstance(value, dict):
|
|
2639
|
+
return self._sanitize_outputs(value)
|
|
2640
|
+
if isinstance(value, (list, tuple)):
|
|
2641
|
+
# 리스트 안의 dict 도 가려야 한다 (예: credentials[] 배열).
|
|
2642
|
+
sanitized_items = [self._sanitize_value(item) for item in value]
|
|
2643
|
+
return type(value)(sanitized_items) if isinstance(value, tuple) else sanitized_items
|
|
2644
|
+
return value
|
|
2645
|
+
|
|
2629
2646
|
def _sanitize_outputs(self, outputs: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
2630
2647
|
"""Remove sensitive information from outputs."""
|
|
2631
2648
|
# None은 None으로 반환, 빈 딕셔너리 {}는 그대로 유지
|
|
2632
2649
|
if outputs is None:
|
|
2633
2650
|
return None
|
|
2634
|
-
|
|
2635
|
-
# Filter sensitive keywords
|
|
2636
|
-
sensitive_keys = {"appkey", "appsecret", "secret", "password", "token", "api_key"}
|
|
2651
|
+
|
|
2637
2652
|
sanitized = {}
|
|
2638
|
-
|
|
2639
2653
|
for k, v in outputs.items():
|
|
2640
|
-
if any(sk in k.lower() for sk in
|
|
2654
|
+
if any(sk in str(k).lower() for sk in self.SENSITIVE_OUTPUT_KEYS):
|
|
2641
2655
|
sanitized[k] = "[REDACTED]"
|
|
2642
|
-
elif isinstance(v, dict):
|
|
2643
|
-
sanitized[k] = self._sanitize_outputs(v)
|
|
2644
2656
|
else:
|
|
2645
|
-
sanitized[k] = v
|
|
2646
|
-
|
|
2657
|
+
sanitized[k] = self._sanitize_value(v)
|
|
2658
|
+
|
|
2647
2659
|
return sanitized
|
|
2648
2660
|
|
|
2661
|
+
def get_all_outputs_sanitized(self, node_id: str) -> Dict[str, Any]:
|
|
2662
|
+
"""외부로 내보낼 노드 출력 — 민감 키가 가려진 사본.
|
|
2663
|
+
|
|
2664
|
+
내부 전송(바인딩 해석 · broker connection auto-inject)은 `get_all_outputs()` 의
|
|
2665
|
+
raw 값을 그대로 쓰고, **외부 노출면**(리스너 이벤트 · `get_state()`)만 이걸 쓴다.
|
|
2666
|
+
"""
|
|
2667
|
+
return self._sanitize_outputs(self.get_all_outputs(node_id)) or {}
|
|
2668
|
+
|
|
2649
2669
|
# === Logging ===
|
|
2650
2670
|
|
|
2651
2671
|
def log(
|
|
@@ -373,6 +373,43 @@ class LSClientManager:
|
|
|
373
373
|
cls._credentials.clear()
|
|
374
374
|
|
|
375
375
|
|
|
376
|
+
def broker_credential_key(product: Optional[str]) -> str:
|
|
377
|
+
"""BrokerNode 자격증명의 시크릿 저장소 키 (product 별 분리).
|
|
378
|
+
|
|
379
|
+
단일 슬롯이면 한 워크플로우에 브로커가 둘 이상일 때(해외주식 + 국내주식) 뒤에 실행된
|
|
380
|
+
BrokerNode 가 앞의 자격증명을 덮어써, 하류가 **다른 계좌의 앱키**로 로그인하게 된다.
|
|
381
|
+
"""
|
|
382
|
+
return f"broker_credentials:{product or 'unknown'}"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _resolve_broker_credentials(
|
|
386
|
+
broker_connection: Optional[Dict[str, Any]],
|
|
387
|
+
context: "ExecutionContext",
|
|
388
|
+
) -> tuple:
|
|
389
|
+
"""브로커 자격증명 조회 → ``(appkey, appsecret, paper_trading)``.
|
|
390
|
+
|
|
391
|
+
정본은 **시크릿 저장소**다 — 자격증명은 노드 출력에 실리지 않는다(평문 유출 방지).
|
|
392
|
+
조회 순서:
|
|
393
|
+
1. product 별 슬롯 (정확 — 다중 브로커 워크플로우에서도 안 섞인다)
|
|
394
|
+
2. 레거시 단일 슬롯 (product 를 모를 때)
|
|
395
|
+
3. ``connection`` 안의 평문 키 — **레거시 폴백**. 옛 워크플로우/외부 호출자와
|
|
396
|
+
deep_validate 픽스처가 아직 이 모양을 쓴다.
|
|
397
|
+
"""
|
|
398
|
+
conn = broker_connection or {}
|
|
399
|
+
product = conn.get("product")
|
|
400
|
+
|
|
401
|
+
cred: Dict[str, Any] = {}
|
|
402
|
+
if product:
|
|
403
|
+
cred = context.get_credential(broker_credential_key(product)) or {}
|
|
404
|
+
if not cred:
|
|
405
|
+
cred = context.get_credential() or {}
|
|
406
|
+
|
|
407
|
+
appkey = cred.get("appkey") or conn.get("appkey", "")
|
|
408
|
+
appsecret = cred.get("appsecret") or conn.get("appsecret", "")
|
|
409
|
+
paper_trading = cred.get("paper_trading", conn.get("paper_trading", False))
|
|
410
|
+
return appkey, appsecret, paper_trading
|
|
411
|
+
|
|
412
|
+
|
|
376
413
|
def ensure_ls_login(
|
|
377
414
|
appkey: str,
|
|
378
415
|
appsecret: str,
|
|
@@ -1974,6 +2011,12 @@ class ScreenerNodeExecutor(NodeExecutorBase):
|
|
|
1974
2011
|
"market_cap": mcap,
|
|
1975
2012
|
# g3101 enrich 시 volume 이 채워졌을 수 있음
|
|
1976
2013
|
"volume": int(sym.get("volume") or 0),
|
|
2014
|
+
# 상류가 준 값 통과 (없으면 빈 문자열). LS 마스터에는 섹터가 없다.
|
|
2015
|
+
# 그래도 키는 내보낸다 — yfinance 분기와 키 집합이 다르면 같은 포트가
|
|
2016
|
+
# 분기마다 다른 모양이 되고, 선언이 어느 쪽을 적든 반대 분기에서 거짓말이 된다.
|
|
2017
|
+
"name": sym.get("name", "") or "",
|
|
2018
|
+
"market": sym.get("market", "") or "",
|
|
2019
|
+
"sector": sym.get("sector", "") or "",
|
|
1977
2020
|
})
|
|
1978
2021
|
|
|
1979
2022
|
# 2단계: volume_min 명시 시 g3101로 거래량 확인 (이미 enrich 된 경우 skip)
|
|
@@ -2218,8 +2261,11 @@ class ScreenerNodeExecutor(NodeExecutorBase):
|
|
|
2218
2261
|
return entry.get("symbol", "")
|
|
2219
2262
|
return entry
|
|
2220
2263
|
|
|
2264
|
+
# 상류(SymbolQueryNode 등)가 준 name / market 을 그대로 통과시키려면 원본 dict 이 필요하다.
|
|
2265
|
+
# 예전엔 (원본코드, yf티커) 두 개만 들고 다녀서 종목명이 **여기서 소실**됐다 —
|
|
2266
|
+
# 표에 종목명 열을 넣은 예제가 조용히 '-' 만 찍고 있었다.
|
|
2221
2267
|
lookup_pairs = [
|
|
2222
|
-
(_original_symbol(s), _to_yfinance_ticker(s))
|
|
2268
|
+
(_original_symbol(s), _to_yfinance_ticker(s), s if isinstance(s, dict) else {})
|
|
2223
2269
|
for s in symbols
|
|
2224
2270
|
]
|
|
2225
2271
|
|
|
@@ -2230,7 +2276,7 @@ class ScreenerNodeExecutor(NodeExecutorBase):
|
|
|
2230
2276
|
attempted = 0
|
|
2231
2277
|
info_succeeded = 0
|
|
2232
2278
|
|
|
2233
|
-
for original_sym, yf_ticker in lookup_pairs[:max_results * 2]: # 여유분
|
|
2279
|
+
for original_sym, yf_ticker, src in lookup_pairs[:max_results * 2]: # 여유분
|
|
2234
2280
|
if not yf_ticker:
|
|
2235
2281
|
continue
|
|
2236
2282
|
attempted += 1
|
|
@@ -2280,6 +2326,8 @@ class ScreenerNodeExecutor(NodeExecutorBase):
|
|
|
2280
2326
|
filtered.append({
|
|
2281
2327
|
"exchange": mapped_exchange,
|
|
2282
2328
|
"symbol": original_sym,
|
|
2329
|
+
"name": src.get("name") or info.get("shortName") or info.get("longName") or "",
|
|
2330
|
+
"market": src.get("market", "") or "",
|
|
2283
2331
|
"market_cap": mcap,
|
|
2284
2332
|
"volume": vol,
|
|
2285
2333
|
"price": price,
|
|
@@ -3156,11 +3204,17 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
3156
3204
|
paper_trading = config.get("paper_trading", paper_trading)
|
|
3157
3205
|
|
|
3158
3206
|
if appkey and appsecret:
|
|
3159
|
-
|
|
3207
|
+
cred_payload = {
|
|
3160
3208
|
"appkey": appkey,
|
|
3161
3209
|
"appsecret": appsecret,
|
|
3162
3210
|
"paper_trading": paper_trading,
|
|
3163
|
-
}
|
|
3211
|
+
}
|
|
3212
|
+
# 자격증명은 **시크릿 저장소에만** 둔다 — 노드 출력(`connection`)에 실으면
|
|
3213
|
+
# 리스너(SSE) · get_state · 체크포인트로 평문이 새어 나간다.
|
|
3214
|
+
# product 별 슬롯: 한 워크플로우에 브로커가 둘 이상이면(해외+국내) 단일 슬롯은 덮어써진다.
|
|
3215
|
+
context.set_secret(broker_credential_key(product), cred_payload)
|
|
3216
|
+
# 레거시 단일 슬롯 — 아직 product 별 조회로 이관되지 않은 소비처를 위해 유지한다.
|
|
3217
|
+
context.set_secret("credential_id", cred_payload)
|
|
3164
3218
|
context.log("info", f"Broker credentials stored (paper_trading={paper_trading})", node_id)
|
|
3165
3219
|
else:
|
|
3166
3220
|
context.log("warning", "No credentials found - some features may not work", node_id)
|
|
@@ -3255,14 +3309,15 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
3255
3309
|
context=context,
|
|
3256
3310
|
)
|
|
3257
3311
|
|
|
3312
|
+
# 🔐 `connection` 은 노드 출력이라 리스너(SSE) · get_state · 체크포인트로 외부에 나간다.
|
|
3313
|
+
# 자격증명은 싣지 않는다 — 하류는 `product` 로 시크릿 저장소에서 꺼낸다
|
|
3314
|
+
# (`_resolve_broker_credentials`). 여기 있던 평문 appkey/appsecret 이 실제로 새고 있었다.
|
|
3258
3315
|
return {
|
|
3259
3316
|
"connected": True,
|
|
3260
3317
|
"connection": {
|
|
3261
3318
|
"provider": provider,
|
|
3262
3319
|
"product": product,
|
|
3263
3320
|
"paper_trading": paper_trading,
|
|
3264
|
-
"appkey": appkey,
|
|
3265
|
-
"appsecret": appsecret,
|
|
3266
3321
|
}
|
|
3267
3322
|
}
|
|
3268
3323
|
|
|
@@ -4406,7 +4461,11 @@ class AccountNodeExecutor(NodeExecutorBase):
|
|
|
4406
4461
|
"purchase_amount": item.FcurrBuyAmt,
|
|
4407
4462
|
})
|
|
4408
4463
|
|
|
4409
|
-
held_symbols
|
|
4464
|
+
# held_symbols 는 선언된 출력 포트다. 예전엔 여기서 계산만 하고 **반환 dict 에 싣지
|
|
4465
|
+
# 않아** 늘 비어 있었다 — 바인딩하면 정적 검증은 통과하고 런타임엔 None 이었다.
|
|
4466
|
+
held_symbols = [
|
|
4467
|
+
{"exchange": p.get("exchange", ""), "symbol": p["symbol"]} for p in positions
|
|
4468
|
+
]
|
|
4410
4469
|
|
|
4411
4470
|
# block2 = 전체 평가 요약. `orderable_amount` is set later by
|
|
4412
4471
|
# COSOQ02701; pre-seed it as None so consumers can distinguish
|
|
@@ -4469,6 +4528,7 @@ class AccountNodeExecutor(NodeExecutorBase):
|
|
|
4469
4528
|
|
|
4470
4529
|
context.log("info", f"AccountNode: {len(positions)} positions fetched", node_id)
|
|
4471
4530
|
return {
|
|
4531
|
+
"held_symbols": held_symbols,
|
|
4472
4532
|
"positions": positions,
|
|
4473
4533
|
"balance": balance_info,
|
|
4474
4534
|
}
|
|
@@ -4590,6 +4650,10 @@ class AccountNodeExecutor(NodeExecutorBase):
|
|
|
4590
4650
|
|
|
4591
4651
|
context.log("info", f"AccountNode (korea_stock): {len(positions)} positions fetched", node_id)
|
|
4592
4652
|
return {
|
|
4653
|
+
"held_symbols": [
|
|
4654
|
+
{"exchange": p.get("exchange", "KRX"), "symbol": p["symbol"]}
|
|
4655
|
+
for p in positions
|
|
4656
|
+
],
|
|
4593
4657
|
"positions": positions,
|
|
4594
4658
|
"balance": balance_info,
|
|
4595
4659
|
}
|
|
@@ -4656,7 +4720,7 @@ class AccountNodeExecutor(NodeExecutorBase):
|
|
|
4656
4720
|
"pnl_amount": float(item.AbrdFutsEvalPnlAmt) if item.AbrdFutsEvalPnlAmt else 0.0,
|
|
4657
4721
|
"currency": item.CrcyCodeVal.strip() if item.CrcyCodeVal else "USD",
|
|
4658
4722
|
})
|
|
4659
|
-
held_symbols.append(symbol)
|
|
4723
|
+
held_symbols.append({"exchange": "HKEX", "symbol": symbol})
|
|
4660
4724
|
|
|
4661
4725
|
# 2. CIDBQ05300: 예탁자산 조회 (CIDBQ03000 상위호환).
|
|
4662
4726
|
# Wrapped in its own try so a balance failure no longer drops
|
|
@@ -4738,6 +4802,7 @@ class AccountNodeExecutor(NodeExecutorBase):
|
|
|
4738
4802
|
|
|
4739
4803
|
context.log("info", f"AccountNode (futures): {len(positions)} positions, {len(balance_by_currency)} currencies", node_id)
|
|
4740
4804
|
return {
|
|
4805
|
+
"held_symbols": held_symbols,
|
|
4741
4806
|
"positions": positions,
|
|
4742
4807
|
"balance": balance_info,
|
|
4743
4808
|
}
|
|
@@ -4754,6 +4819,7 @@ class AccountNodeExecutor(NodeExecutorBase):
|
|
|
4754
4819
|
other consumers can refuse to coerce missing balances to 0.0.
|
|
4755
4820
|
"""
|
|
4756
4821
|
result: Dict[str, Any] = {
|
|
4822
|
+
"held_symbols": [],
|
|
4757
4823
|
"positions": [],
|
|
4758
4824
|
"balance": {
|
|
4759
4825
|
"cash": 0.0,
|
|
@@ -5297,7 +5363,9 @@ class RealAccountNodeExecutor(NodeExecutorBase):
|
|
|
5297
5363
|
serialized_positions.append({
|
|
5298
5364
|
"symbol": sym,
|
|
5299
5365
|
"exchange": getattr(pos, 'market_code', 'NASDAQ'),
|
|
5366
|
+
"market_code": getattr(pos, 'market_code', ''),
|
|
5300
5367
|
"name": getattr(pos, 'symbol_name', sym),
|
|
5368
|
+
"qty": quantity,
|
|
5301
5369
|
"quantity": quantity, # NewOrderNode 호환
|
|
5302
5370
|
"price": current_price, # NewOrderNode 호환
|
|
5303
5371
|
"avg_price": float(pos.buy_price),
|
|
@@ -5305,6 +5373,7 @@ class RealAccountNodeExecutor(NodeExecutorBase):
|
|
|
5305
5373
|
"pnl_rate": float(pos.pnl_rate) if pos.pnl_rate else 0,
|
|
5306
5374
|
"pnl_amount": float(pos.pnl_amount) if pos.pnl_amount else 0,
|
|
5307
5375
|
"currency": getattr(pos, 'currency_code', 'USD'),
|
|
5376
|
+
"eval_amount": float(pos.eval_amount) if pos.eval_amount else 0,
|
|
5308
5377
|
"product": "overseas_stock", # 상품 유형
|
|
5309
5378
|
})
|
|
5310
5379
|
|
|
@@ -5683,13 +5752,17 @@ class RealAccountNodeExecutor(NodeExecutorBase):
|
|
|
5683
5752
|
serialized_positions.append({
|
|
5684
5753
|
"symbol": sym,
|
|
5685
5754
|
"exchange": "KRX",
|
|
5755
|
+
"market_code": getattr(pos, 'market', ''),
|
|
5686
5756
|
"name": getattr(pos, 'symbol_name', sym),
|
|
5757
|
+
"qty": quantity,
|
|
5687
5758
|
"quantity": quantity,
|
|
5688
5759
|
"price": current_price,
|
|
5689
5760
|
"avg_price": float(pos.buy_price),
|
|
5690
5761
|
"current_price": current_price,
|
|
5691
5762
|
"pnl_rate": float(pos.pnl_rate) if pos.pnl_rate else 0,
|
|
5692
5763
|
"pnl_amount": float(pos.pnl_amount) if pos.pnl_amount else 0,
|
|
5764
|
+
"currency": "KRW",
|
|
5765
|
+
"eval_amount": float(pos.eval_amount) if pos.eval_amount else 0,
|
|
5693
5766
|
"product": "korea_stock",
|
|
5694
5767
|
})
|
|
5695
5768
|
|
|
@@ -5753,20 +5826,31 @@ class RealAccountNodeExecutor(NodeExecutorBase):
|
|
|
5753
5826
|
"""해외주식 Tracker에서 현재 데이터 추출 (리스트 형태로 반환)"""
|
|
5754
5827
|
positions = []
|
|
5755
5828
|
for symbol, pos in tracker.get_positions().items():
|
|
5829
|
+
quantity = pos.quantity
|
|
5830
|
+
current_price = float(pos.current_price)
|
|
5756
5831
|
positions.append({
|
|
5757
5832
|
"symbol": symbol,
|
|
5758
5833
|
"name": getattr(pos, 'name', getattr(pos, 'symbol_name', symbol)),
|
|
5759
|
-
|
|
5834
|
+
# REST 스냅샷 갈래(_ls_stock_with_tracker)와 **같은 키 집합**이어야 한다.
|
|
5835
|
+
# 안 그러면 같은 포트가 스냅샷일 땐 exchange/quantity 를 주고 실시간 틱일 땐
|
|
5836
|
+
# 안 주는, 시점마다 모양이 바뀌는 출력이 된다 (주문 노드가 조용히 깨진다).
|
|
5837
|
+
"exchange": getattr(pos, 'market_code', 'NASDAQ'),
|
|
5838
|
+
"market_code": getattr(pos, 'market_code', ''),
|
|
5839
|
+
"qty": quantity,
|
|
5840
|
+
"quantity": quantity, # NewOrderNode 호환
|
|
5841
|
+
"price": current_price, # NewOrderNode 호환
|
|
5760
5842
|
"avg_price": float(pos.buy_price),
|
|
5761
|
-
"current_price":
|
|
5843
|
+
"current_price": current_price,
|
|
5762
5844
|
"pnl_rate": float(pos.pnl_rate) if pos.pnl_rate else 0,
|
|
5763
5845
|
"pnl_amount": float(pos.pnl_amount) if pos.pnl_amount else 0,
|
|
5764
5846
|
"currency": getattr(pos, 'currency_code', 'USD'),
|
|
5765
5847
|
"eval_amount": float(pos.eval_amount) if pos.eval_amount else 0,
|
|
5766
|
-
"
|
|
5848
|
+
"product": "overseas_stock",
|
|
5767
5849
|
})
|
|
5768
5850
|
|
|
5769
|
-
|
|
5851
|
+
held_symbols = [
|
|
5852
|
+
{"exchange": p["exchange"], "symbol": p["symbol"]} for p in positions
|
|
5853
|
+
]
|
|
5770
5854
|
|
|
5771
5855
|
# balance를 JSON 직렬화 가능한 형태로 변환
|
|
5772
5856
|
# get_balances()는 Dict[str, StockBalanceInfo]를 반환 (통화별)
|
|
@@ -5823,6 +5907,7 @@ class RealAccountNodeExecutor(NodeExecutorBase):
|
|
|
5823
5907
|
}
|
|
5824
5908
|
|
|
5825
5909
|
return {
|
|
5910
|
+
"held_symbols": held_symbols,
|
|
5826
5911
|
"positions": positions,
|
|
5827
5912
|
"balance": balance,
|
|
5828
5913
|
"open_orders": open_orders,
|
|
@@ -5856,8 +5941,11 @@ class RealAccountNodeExecutor(NodeExecutorBase):
|
|
|
5856
5941
|
"name": getattr(pos, 'symbol_name', symbol),
|
|
5857
5942
|
"direction": "long" if is_long else "short",
|
|
5858
5943
|
"close_side": "sell" if is_long else "buy",
|
|
5944
|
+
# REST 스냅샷 갈래(_ls_futureoption_with_tracker)와 같은 키 집합
|
|
5945
|
+
"qty": quantity,
|
|
5859
5946
|
"quantity": quantity, # qty → quantity (NewOrderNode 호환)
|
|
5860
5947
|
"price": current_price, # current_price → price (NewOrderNode 호환)
|
|
5948
|
+
"product": "overseas_futures",
|
|
5861
5949
|
"entry_price": float(getattr(pos, 'entry_price', 0)),
|
|
5862
5950
|
"current_price": current_price,
|
|
5863
5951
|
"pnl_amount": float(getattr(pos, 'pnl_amount', 0) or 0),
|
|
@@ -5900,6 +5988,10 @@ class RealAccountNodeExecutor(NodeExecutorBase):
|
|
|
5900
5988
|
}
|
|
5901
5989
|
|
|
5902
5990
|
return {
|
|
5991
|
+
"held_symbols": [
|
|
5992
|
+
{"exchange": pos.get("exchange", ""), "symbol": pos["symbol"]}
|
|
5993
|
+
for pos in positions
|
|
5994
|
+
],
|
|
5903
5995
|
"positions": positions,
|
|
5904
5996
|
"balance": balance,
|
|
5905
5997
|
"open_orders": open_orders,
|
|
@@ -5909,17 +6001,24 @@ class RealAccountNodeExecutor(NodeExecutorBase):
|
|
|
5909
6001
|
"""국내주식 KrStockAccountTracker에서 현재 데이터 추출"""
|
|
5910
6002
|
positions = []
|
|
5911
6003
|
for symbol, pos in tracker.get_positions().items():
|
|
6004
|
+
quantity = pos.quantity
|
|
6005
|
+
current_price = float(pos.current_price)
|
|
5912
6006
|
positions.append({
|
|
5913
6007
|
"symbol": symbol,
|
|
5914
6008
|
"name": getattr(pos, 'symbol_name', symbol),
|
|
5915
|
-
|
|
6009
|
+
# REST 스냅샷 갈래(_ls_korea_stock_with_tracker)와 같은 키 집합
|
|
6010
|
+
"exchange": "KRX",
|
|
6011
|
+
"market_code": getattr(pos, 'market', ''),
|
|
6012
|
+
"qty": quantity,
|
|
6013
|
+
"quantity": quantity, # NewOrderNode 호환
|
|
6014
|
+
"price": current_price, # NewOrderNode 호환
|
|
5916
6015
|
"avg_price": float(pos.buy_price),
|
|
5917
|
-
"current_price":
|
|
6016
|
+
"current_price": current_price,
|
|
5918
6017
|
"pnl_rate": pos.pnl_rate,
|
|
5919
6018
|
"pnl_amount": float(pos.pnl_amount),
|
|
5920
6019
|
"currency": "KRW",
|
|
5921
6020
|
"eval_amount": float(pos.eval_amount),
|
|
5922
|
-
"
|
|
6021
|
+
"product": "korea_stock",
|
|
5923
6022
|
})
|
|
5924
6023
|
|
|
5925
6024
|
# KrStockAccountTracker.get_balance() → KrStockBalanceInfo (단일 객체)
|
|
@@ -5954,6 +6053,10 @@ class RealAccountNodeExecutor(NodeExecutorBase):
|
|
|
5954
6053
|
}
|
|
5955
6054
|
|
|
5956
6055
|
return {
|
|
6056
|
+
"held_symbols": [
|
|
6057
|
+
{"exchange": pos.get("exchange", ""), "symbol": pos["symbol"]}
|
|
6058
|
+
for pos in positions
|
|
6059
|
+
],
|
|
5957
6060
|
"positions": positions,
|
|
5958
6061
|
"balance": balance,
|
|
5959
6062
|
"open_orders": open_orders,
|
|
@@ -5977,6 +6080,7 @@ class RealAccountNodeExecutor(NodeExecutorBase):
|
|
|
5977
6080
|
consumers do not silently treat the unavailable balance as 0.
|
|
5978
6081
|
"""
|
|
5979
6082
|
result: Dict[str, Any] = {
|
|
6083
|
+
"held_symbols": [],
|
|
5980
6084
|
"positions": [],
|
|
5981
6085
|
"balance": {
|
|
5982
6086
|
"cash": 0.0,
|
|
@@ -6122,9 +6226,8 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
|
|
|
6122
6226
|
import asyncio
|
|
6123
6227
|
from datetime import datetime
|
|
6124
6228
|
|
|
6125
|
-
|
|
6126
|
-
appsecret = broker_connection
|
|
6127
|
-
paper_trading = broker_connection.get("paper_trading", False)
|
|
6229
|
+
# 🔐 자격증명은 시크릿 저장소에서 — `connection` 출력에는 더 이상 싣지 않는다.
|
|
6230
|
+
appkey, appsecret, paper_trading = _resolve_broker_credentials(broker_connection, context)
|
|
6128
6231
|
|
|
6129
6232
|
# 현재 이벤트 루프 캡처 (콜백에서 사용)
|
|
6130
6233
|
loop = asyncio.get_running_loop()
|
|
@@ -6290,9 +6393,8 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
|
|
|
6290
6393
|
import asyncio
|
|
6291
6394
|
from datetime import datetime
|
|
6292
6395
|
|
|
6293
|
-
|
|
6294
|
-
appsecret = broker_connection
|
|
6295
|
-
paper_trading = broker_connection.get("paper_trading", False)
|
|
6396
|
+
# 🔐 자격증명은 시크릿 저장소에서 — `connection` 출력에는 더 이상 싣지 않는다.
|
|
6397
|
+
appkey, appsecret, paper_trading = _resolve_broker_credentials(broker_connection, context)
|
|
6296
6398
|
|
|
6297
6399
|
# 현재 이벤트 루프 캡처 (콜백에서 사용)
|
|
6298
6400
|
loop = asyncio.get_running_loop()
|
|
@@ -6465,8 +6567,8 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
|
|
|
6465
6567
|
import asyncio
|
|
6466
6568
|
from datetime import datetime
|
|
6467
6569
|
|
|
6468
|
-
|
|
6469
|
-
appsecret = broker_connection
|
|
6570
|
+
# 🔐 자격증명은 시크릿 저장소에서 — `connection` 출력에는 더 이상 싣지 않는다.
|
|
6571
|
+
appkey, appsecret, _paper = _resolve_broker_credentials(broker_connection, context)
|
|
6470
6572
|
|
|
6471
6573
|
loop = asyncio.get_running_loop()
|
|
6472
6574
|
|
|
@@ -6629,43 +6731,6 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
|
|
|
6629
6731
|
|
|
6630
6732
|
return []
|
|
6631
6733
|
|
|
6632
|
-
def _build_full_data(
|
|
6633
|
-
self,
|
|
6634
|
-
symbols_raw: List[Dict[str, str]],
|
|
6635
|
-
prices: Dict[str, float],
|
|
6636
|
-
volumes: Dict[str, int],
|
|
6637
|
-
bids: Dict[str, float],
|
|
6638
|
-
asks: Dict[str, float],
|
|
6639
|
-
) -> Dict[str, Dict[str, Any]]:
|
|
6640
|
-
"""
|
|
6641
|
-
종목별 전체 시세 데이터 구조 생성
|
|
6642
|
-
|
|
6643
|
-
Returns:
|
|
6644
|
-
{
|
|
6645
|
-
"AAPL": {"symbol": "AAPL", "exchange": "NASDAQ", "price": 192.30, ...},
|
|
6646
|
-
"TSLA": {...},
|
|
6647
|
-
}
|
|
6648
|
-
"""
|
|
6649
|
-
data = {}
|
|
6650
|
-
for entry in symbols_raw:
|
|
6651
|
-
if isinstance(entry, dict):
|
|
6652
|
-
symbol = entry.get("symbol", "")
|
|
6653
|
-
exchange = entry.get("exchange", "")
|
|
6654
|
-
else:
|
|
6655
|
-
symbol = entry
|
|
6656
|
-
exchange = ""
|
|
6657
|
-
|
|
6658
|
-
if symbol:
|
|
6659
|
-
data[symbol] = {
|
|
6660
|
-
"symbol": symbol,
|
|
6661
|
-
"exchange": exchange,
|
|
6662
|
-
"price": prices.get(symbol),
|
|
6663
|
-
"volume": volumes.get(symbol),
|
|
6664
|
-
"bid": bids.get(symbol),
|
|
6665
|
-
"ask": asks.get(symbol),
|
|
6666
|
-
}
|
|
6667
|
-
return data
|
|
6668
|
-
|
|
6669
6734
|
|
|
6670
6735
|
class RealOrderEventNodeExecutor(NodeExecutorBase):
|
|
6671
6736
|
"""
|
|
@@ -7539,9 +7604,9 @@ class MarketStatusNodeExecutor(NodeExecutorBase):
|
|
|
7539
7604
|
)
|
|
7540
7605
|
|
|
7541
7606
|
# 2. LS 로그인 (broker 와 동일 instance 재사용)
|
|
7542
|
-
|
|
7543
|
-
appsecret = broker_connection
|
|
7544
|
-
paper_trading = bool(
|
|
7607
|
+
# 🔐 자격증명은 시크릿 저장소에서 — `connection` 출력에는 더 이상 싣지 않는다.
|
|
7608
|
+
appkey, appsecret, paper_trading = _resolve_broker_credentials(broker_connection, context)
|
|
7609
|
+
paper_trading = bool(paper_trading)
|
|
7545
7610
|
broker_product = broker_connection.get("product", "overseas_stock")
|
|
7546
7611
|
|
|
7547
7612
|
ls, success, error = ensure_ls_login(
|
|
@@ -9297,6 +9362,36 @@ class IfNodeExecutor(NodeExecutorBase):
|
|
|
9297
9362
|
return False
|
|
9298
9363
|
|
|
9299
9364
|
|
|
9365
|
+
# 전일대비(delta)의 부호 규칙은 LS TR 마다 다르다 — 실측으로만 확정된다 (2026-07-13):
|
|
9366
|
+
# g3101 (해외주식) : diff = 절댓값, 부호는 sign 에 별도 → NVDA sign='5' diff='2.41' rate=-1.14
|
|
9367
|
+
# t1102 (국내주식) : change = 절댓값, 부호는 sign 에 별도 → 삼성 sign='5' change=30500 diff=-10.70
|
|
9368
|
+
# o3105 (해외선물) : YdiffP = 이미 부호 포함(문서의 "Absolute" 는 오기) → HBIN26 sign='5' YdiffP=-105.0
|
|
9369
|
+
# 등락률(%)은 세 TR 모두 부호를 갖는다.
|
|
9370
|
+
#
|
|
9371
|
+
# 그래서 "무조건 abs() 후 sign 적용"은 위험하다 — o3105 처럼 이미 음수인 값을 양수로 뒤집는다.
|
|
9372
|
+
# 방향 코드가 명확할 때만 크기를 그 방향으로 맞추고, 불명이면 원값의 부호를 신뢰한다.
|
|
9373
|
+
_LS_DOWN_SIGNS = ("4", "5", "-") # 4=하한, 5=하락
|
|
9374
|
+
_LS_UP_SIGNS = ("1", "2", "+") # 1=상한, 2=상승 (3=보합 → 값이 0 이라 통과시켜도 무해)
|
|
9375
|
+
|
|
9376
|
+
|
|
9377
|
+
def _ls_signed_change(magnitude: Any, sign: Any) -> float:
|
|
9378
|
+
"""LS 전일대비(delta)를 방향 코드에 맞춰 부호 있는 값으로 정규화한다.
|
|
9379
|
+
|
|
9380
|
+
멱등하다 — 이미 부호가 실려 온 값(o3105)에 다시 적용해도 결과가 바뀌지 않는다.
|
|
9381
|
+
방향 코드가 비었거나 알 수 없으면 **원값을 그대로** 둔다(멋대로 양수로 만들지 않는다).
|
|
9382
|
+
"""
|
|
9383
|
+
try:
|
|
9384
|
+
v = float(magnitude or 0)
|
|
9385
|
+
except (TypeError, ValueError):
|
|
9386
|
+
return 0.0
|
|
9387
|
+
s = str(sign or "").strip()
|
|
9388
|
+
if s in _LS_DOWN_SIGNS:
|
|
9389
|
+
return -abs(v)
|
|
9390
|
+
if s in _LS_UP_SIGNS:
|
|
9391
|
+
return abs(v)
|
|
9392
|
+
return v
|
|
9393
|
+
|
|
9394
|
+
|
|
9300
9395
|
class MarketDataNodeExecutor(NodeExecutorBase):
|
|
9301
9396
|
"""
|
|
9302
9397
|
MarketDataNode executor - REST API 현재가 조회 (당일 데이터만)
|
|
@@ -9509,7 +9604,8 @@ class MarketDataNodeExecutor(NodeExecutorBase):
|
|
|
9509
9604
|
"symbol": symbol,
|
|
9510
9605
|
"exchange": resolved_exchange,
|
|
9511
9606
|
"price": float(out_block.price or 0),
|
|
9512
|
-
|
|
9607
|
+
# g3101 `diff` 는 절댓값 — 부호는 `sign` 에 따로 온다.
|
|
9608
|
+
"change": _ls_signed_change(out_block.diff, getattr(out_block, "sign", "")),
|
|
9513
9609
|
"change_pct": float(out_block.rate or 0),
|
|
9514
9610
|
"volume": int(out_block.volume or 0),
|
|
9515
9611
|
"open": float(out_block.open or 0),
|
|
@@ -9594,7 +9690,9 @@ class MarketDataNodeExecutor(NodeExecutorBase):
|
|
|
9594
9690
|
"exchange": exchange,
|
|
9595
9691
|
"symbol_name": out_block.SymbolNm or symbol,
|
|
9596
9692
|
"price": float(out_block.TrdP or 0),
|
|
9597
|
-
|
|
9693
|
+
# o3105 `YdiffP` 는 이미 부호를 포함한다(실측). 헬퍼는 멱등이라 값이 안 바뀌며,
|
|
9694
|
+
# LS 가 언젠가 절댓값으로 바꿔 보내도 `YdiffSign` 으로 방어된다.
|
|
9695
|
+
"change": _ls_signed_change(out_block.YdiffP, getattr(out_block, "YdiffSign", "")),
|
|
9598
9696
|
"change_pct": float(out_block.Diff or 0),
|
|
9599
9697
|
"volume": int(out_block.TotQ or 0),
|
|
9600
9698
|
"open": float(out_block.OpenP or 0),
|
|
@@ -9667,9 +9765,9 @@ class MarketDataNodeExecutor(NodeExecutorBase):
|
|
|
9667
9765
|
blk = response.block
|
|
9668
9766
|
|
|
9669
9767
|
# sign: 1=상한/2=상승 → 양수, 4=하한/5=하락 → 음수
|
|
9670
|
-
|
|
9671
|
-
|
|
9672
|
-
|
|
9768
|
+
# 국내 현재가 `change` 도 절댓값 — 부호는 `sign` 에 따로 온다 (해외와 동일 규칙).
|
|
9769
|
+
# 국내 호가는 원 단위 정수라 기존 int 출력 타입을 유지한다.
|
|
9770
|
+
change_val = int(_ls_signed_change(blk.change, getattr(blk, "sign", "")))
|
|
9673
9771
|
|
|
9674
9772
|
values.append({
|
|
9675
9773
|
"symbol": symbol,
|
|
@@ -19279,7 +19377,9 @@ class WorkflowJob:
|
|
|
19279
19377
|
entry: Dict[str, Any] = {
|
|
19280
19378
|
"state": cached_state.value,
|
|
19281
19379
|
"node_type": node.node_type,
|
|
19282
|
-
|
|
19380
|
+
# 🔐 외부 노출면 — BrokerNode 는 하류 전송용으로 connection.appkey/appsecret 을
|
|
19381
|
+
# 출력에 싣는다. 리스너 경로는 이미 가리는데 여기만 raw 로 새고 있었다.
|
|
19382
|
+
"outputs": self.context.get_all_outputs_sanitized(node_id),
|
|
19283
19383
|
}
|
|
19284
19384
|
if node_id in self._node_errors:
|
|
19285
19385
|
entry["error"] = self._node_errors[node_id]
|
|
@@ -326,6 +326,9 @@ class WorkflowResolver:
|
|
|
326
326
|
# 10.7 AIAgentNode ai_model 엣지 필수 검증 (LLM 없이는 애초에 동작 불가)
|
|
327
327
|
self._validate_ai_agent_nodes(workflow, result)
|
|
328
328
|
|
|
329
|
+
# 10.8 Display 노드 columns 키가 상류 출력 스키마에 실재하는지 (없는 키는 표에 '-' 만 찍힌다)
|
|
330
|
+
self._validate_display_columns(workflow, registry, result)
|
|
331
|
+
|
|
329
332
|
# 11. Static recommendations (topology analysis)
|
|
330
333
|
for rec in run_static_recommendation_rules(
|
|
331
334
|
workflow,
|
|
@@ -1279,6 +1282,124 @@ class WorkflowResolver:
|
|
|
1279
1282
|
)
|
|
1280
1283
|
)
|
|
1281
1284
|
|
|
1285
|
+
def _validate_display_columns(self, workflow, registry, result: ValidationResult) -> None:
|
|
1286
|
+
"""Display 노드의 `columns` 키가 상류 출력 스키마에 실재하는지 검증한다.
|
|
1287
|
+
|
|
1288
|
+
`columns` 는 `{{ }}` 바인딩이 아니라 **평범한 문자열 목록**이라 표현식 검증
|
|
1289
|
+
(`_validate_expression_references`)의 사정권 밖이었다. 그래서 상류가 내보내지 않는
|
|
1290
|
+
이름을 적어도 아무도 막지 않았고, 표는 그 칸에 조용히 `-` 만 찍은 채 워크플로우는
|
|
1291
|
+
`completed / errors=0` 으로 보고했다.
|
|
1292
|
+
(실측 2026-07-13: 챗봇이 `current_price`/`change_percent` 로 저작 → 시세 노드는
|
|
1293
|
+
`price`/`change_pct` 를 내보냄 → 사용자가 요청한 '현재가'가 표에서 사라졌다.)
|
|
1294
|
+
|
|
1295
|
+
🔴 **오탐 0 원칙 — 검증된 포트에만 건다.**
|
|
1296
|
+
`fields` 를 선언했다는 것만으로는 부족하다. 예전엔 라이브러리 곳곳의 선언이 런타임보다
|
|
1297
|
+
**불완전**했다(예: `ScreenerNode.symbols` 런타임은 price/market_cap/volume/sector 를
|
|
1298
|
+
담는데 공유 상수 `SYMBOL_LIST_FIELDS` 는 exchange/symbol 둘만 선언 — 2026-07-14 노드별
|
|
1299
|
+
상수로 쪼개서 해소). 증명 안 된 포트에 이 가드를 걸면 **정상 워크플로우를 대량 오탐**한다.
|
|
1300
|
+
|
|
1301
|
+
그래서 `tests/test_output_schema_contract.py` 로 **선언 == 런타임** 이 증명된 포트만
|
|
1302
|
+
여기 등록한다. 다른 포트의 선언을 바로잡을 때마다 계약 검사에 추가하고 이 목록을 넓힌다.
|
|
1303
|
+
"""
|
|
1304
|
+
import re as _re
|
|
1305
|
+
|
|
1306
|
+
# (node_type, port) — 계약 검사로 선언 == 런타임 이 증명된 포트만.
|
|
1307
|
+
# 증명 위치: `tests/test_output_schema_contract.py::CASES`
|
|
1308
|
+
# 🔴 실시간 시세의 `ohlcv_data` / `data` 는 **일부러 뺐다** — 값이 `{종목: [봉]}` dict 라
|
|
1309
|
+
# 표의 열(column)이 봉 필드인지 종목코드인지 정해져 있지 않다. 근거 없이 걸면 오탐이다.
|
|
1310
|
+
VERIFIED_PORTS = {
|
|
1311
|
+
("OverseasStockMarketDataNode", "value"),
|
|
1312
|
+
("OverseasStockMarketDataNode", "values"),
|
|
1313
|
+
("OverseasFuturesMarketDataNode", "value"),
|
|
1314
|
+
("OverseasFuturesMarketDataNode", "values"),
|
|
1315
|
+
("KoreaStockMarketDataNode", "value"),
|
|
1316
|
+
("KoreaStockMarketDataNode", "values"),
|
|
1317
|
+
# 종목 리스트
|
|
1318
|
+
("WatchlistNode", "symbols"),
|
|
1319
|
+
("MarketUniverseNode", "symbols"),
|
|
1320
|
+
("ScreenerNode", "symbols"),
|
|
1321
|
+
("OverseasStockSymbolQueryNode", "symbols"),
|
|
1322
|
+
("KoreaStockSymbolQueryNode", "symbols"),
|
|
1323
|
+
("OverseasFuturesSymbolQueryNode", "symbols"),
|
|
1324
|
+
# 계좌 (REST)
|
|
1325
|
+
("OverseasStockAccountNode", "positions"),
|
|
1326
|
+
("KoreaStockAccountNode", "positions"),
|
|
1327
|
+
("OverseasFuturesAccountNode", "positions"),
|
|
1328
|
+
("OverseasStockAccountNode", "held_symbols"),
|
|
1329
|
+
("KoreaStockAccountNode", "held_symbols"),
|
|
1330
|
+
("OverseasFuturesAccountNode", "held_symbols"),
|
|
1331
|
+
# 계좌 (실시간)
|
|
1332
|
+
("OverseasStockRealAccountNode", "positions"),
|
|
1333
|
+
("KoreaStockRealAccountNode", "positions"),
|
|
1334
|
+
("OverseasFuturesRealAccountNode", "positions"),
|
|
1335
|
+
("OverseasStockRealAccountNode", "held_symbols"),
|
|
1336
|
+
("KoreaStockRealAccountNode", "held_symbols"),
|
|
1337
|
+
("OverseasFuturesRealAccountNode", "held_symbols"),
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
DISPLAY_TYPES = {"TableDisplayNode", "ChartDisplayNode"}
|
|
1341
|
+
nodes_by_id = {
|
|
1342
|
+
n.get("id"): n for n in workflow.nodes
|
|
1343
|
+
if isinstance(n, dict) and n.get("id")
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
for node in workflow.nodes:
|
|
1347
|
+
if not isinstance(node, dict) or node.get("type") not in DISPLAY_TYPES:
|
|
1348
|
+
continue
|
|
1349
|
+
columns = node.get("columns")
|
|
1350
|
+
data_expr = node.get("data")
|
|
1351
|
+
if not columns or not isinstance(columns, list) or not isinstance(data_expr, str):
|
|
1352
|
+
continue
|
|
1353
|
+
|
|
1354
|
+
m = _re.search(r"nodes\.([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)", data_expr)
|
|
1355
|
+
if not m:
|
|
1356
|
+
continue
|
|
1357
|
+
src_id, port = m.group(1), m.group(2)
|
|
1358
|
+
src_node = nodes_by_id.get(src_id)
|
|
1359
|
+
if not src_node:
|
|
1360
|
+
continue # 노드 부재는 _validate_expression_references 가 잡는다
|
|
1361
|
+
if (src_node.get("type"), port) not in VERIFIED_PORTS:
|
|
1362
|
+
continue # 선언이 런타임과 일치한다고 증명되지 않은 포트 → 검사하지 않는다
|
|
1363
|
+
|
|
1364
|
+
schema = registry.get_schema(src_node.get("type"))
|
|
1365
|
+
if not schema:
|
|
1366
|
+
continue
|
|
1367
|
+
declared: Set[str] = set()
|
|
1368
|
+
for out in (getattr(schema, "outputs", None) or []):
|
|
1369
|
+
nm = out.get("name") if isinstance(out, dict) else getattr(out, "name", None)
|
|
1370
|
+
if nm != port:
|
|
1371
|
+
continue
|
|
1372
|
+
fields = out.get("fields") if isinstance(out, dict) else getattr(out, "fields", None)
|
|
1373
|
+
for f in (fields or []):
|
|
1374
|
+
fn = f.get("name") if isinstance(f, dict) else getattr(f, "name", None)
|
|
1375
|
+
if fn:
|
|
1376
|
+
declared.add(fn)
|
|
1377
|
+
break
|
|
1378
|
+
if not declared:
|
|
1379
|
+
continue # 출력 모양이 열려 있음 → 검사 근거 없음 (오탐 방지)
|
|
1380
|
+
|
|
1381
|
+
for col in columns:
|
|
1382
|
+
key = col.get("key") if isinstance(col, dict) else col
|
|
1383
|
+
if not isinstance(key, str) or not key or key in declared:
|
|
1384
|
+
continue
|
|
1385
|
+
result.add(
|
|
1386
|
+
build_error(
|
|
1387
|
+
ErrorCode.INVALID_EXPRESSION_REF,
|
|
1388
|
+
f"{node.get('type')} '{node.get('id')}' lists column '{key}', but node "
|
|
1389
|
+
f"'{src_id}' does not output a field with that name on port '{port}'. "
|
|
1390
|
+
f"The column would render as '-' while the workflow still reports success.",
|
|
1391
|
+
location=ErrorLocation(
|
|
1392
|
+
node_id=node.get("id"),
|
|
1393
|
+
node_type=node.get("type"),
|
|
1394
|
+
field_path="columns",
|
|
1395
|
+
),
|
|
1396
|
+
suggestion=(
|
|
1397
|
+
f"Use one of the fields '{src_id}.{port}' actually outputs: "
|
|
1398
|
+
f"{', '.join(sorted(declared))}."
|
|
1399
|
+
),
|
|
1400
|
+
)
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1282
1403
|
def _validate_credential_references(
|
|
1283
1404
|
self,
|
|
1284
1405
|
workflow,
|
|
@@ -5,7 +5,7 @@ authors = [
|
|
|
5
5
|
homepage = "https://programgarden.com"
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
7
|
name = "programgarden"
|
|
8
|
-
version = "1.
|
|
8
|
+
version = "1.28.0"
|
|
9
9
|
license = "AGPL-3.0-or-later"
|
|
10
10
|
description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
|
|
11
11
|
readme = "README.md"
|
|
@@ -29,7 +29,7 @@ lxml = "^6.0.2"
|
|
|
29
29
|
pytickersymbols = {version = ">=1.17.5", python = ">=3.12,<4.0"}
|
|
30
30
|
aiosqlite = "^0.20.0"
|
|
31
31
|
litellm = ">=1.40.0"
|
|
32
|
-
programgarden-core = "^1.
|
|
32
|
+
programgarden-core = "^1.20.0"
|
|
33
33
|
programgarden-finance = "^1.6.14"
|
|
34
34
|
programgarden-community = "^1.13.11"
|
|
35
35
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.27.0 → programgarden-1.28.0}/programgarden/database/workflow_risk_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|