programgarden 1.31.1__tar.gz → 1.31.2__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 (37) hide show
  1. {programgarden-1.31.1 → programgarden-1.31.2}/PKG-INFO +3 -3
  2. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/deep_fixtures.py +45 -10
  3. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/executor.py +381 -104
  4. {programgarden-1.31.1 → programgarden-1.31.2}/pyproject.toml +3 -3
  5. {programgarden-1.31.1 → programgarden-1.31.2}/README.md +0 -0
  6. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/__init__.py +0 -0
  7. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/binding_validator.py +0 -0
  8. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/client.py +0 -0
  9. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/code_worker.py +0 -0
  10. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/context.py +0 -0
  11. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/database/__init__.py +0 -0
  12. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/database/checkpoint_manager.py +0 -0
  13. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/database/query_builder.py +0 -0
  14. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/database/workflow_position_tracker.py +0 -0
  15. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/database/workflow_risk_tracker.py +0 -0
  16. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/node_runner.py +0 -0
  17. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/plugin/__init__.py +0 -0
  18. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/plugin/sandbox.py +0 -0
  19. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/providers/__init__.py +0 -0
  20. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/providers/llm_errors.py +0 -0
  21. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/providers/llm_provider.py +0 -0
  22. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/reconnect_handler.py +0 -0
  23. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resolver.py +0 -0
  24. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resource/__init__.py +0 -0
  25. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resource/context.py +0 -0
  26. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resource/limiter.py +0 -0
  27. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resource/monitor.py +0 -0
  28. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resource/throttle.py +0 -0
  29. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/semantic_rules.py +0 -0
  30. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.31.1
3
+ Version: 1.31.2
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -16,8 +16,8 @@ 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.15.0,<2.0.0)
19
- Requires-Dist: programgarden-core (>=1.23.0,<2.0.0)
20
- Requires-Dist: programgarden-finance (>=1.7.0,<2.0.0)
19
+ Requires-Dist: programgarden-core (>=1.24.0,<2.0.0)
20
+ Requires-Dist: programgarden-finance (>=1.8.0,<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)
23
23
  Requires-Dist: pydantic (>=2.0.0,<3.0.0)
@@ -20,6 +20,7 @@ All user-facing strings in this module must be English.
20
20
  """
21
21
  from __future__ import annotations
22
22
 
23
+ import calendar
23
24
  from datetime import datetime, timedelta, timezone
24
25
  from typing import Any, Dict, List, Optional
25
26
 
@@ -28,6 +29,11 @@ from typing import Any, Dict, List, Optional
28
29
  # reproducible and do not depend on wall-clock during a validation pass.
29
30
  _FIXTURE_ANCHOR = datetime(2025, 1, 2, tzinfo=timezone.utc)
30
31
 
32
+ # 월말 만기 구멍 방어용 임계(달력일). 당월 잔여일이 이 값 미만이면 futures fixture 가 익월물로
33
+ # 롤오버한다 — HKEX HSI/MHI 지수선물이 '끝에서 두 번째 영업일'에 만기라, 월말이 주말이면 만기가
34
+ # 월말에서 최대 3 달력일까지 앞선다. 그 구간을 덮도록 넉넉히 5 로 잡는다(근거는 fixture 주석 참조).
35
+ _MONTH_END_ROLLOVER_DAYS = 5
36
+
31
37
 
32
38
  def _norm_symbols(raw: Any) -> List[Dict[str, str]]:
33
39
  """Normalise a symbols input into ``[{"symbol", "exchange"}]``.
@@ -318,15 +324,28 @@ def real_order_event_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
318
324
  }
319
325
 
320
326
 
321
- def futures_contract_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
327
+ def futures_contract_fixture(
328
+ config: Dict[str, Any], *, now: Optional[datetime] = None
329
+ ) -> Dict[str, Any]:
322
330
  """FuturesContractNode deep fixture (no o3101 master query).
323
331
 
324
332
  Real shape: ``{"symbols": [{exchange, symbol}], "contracts": [...], "count": int}``.
325
333
 
326
- The month code is derived from the fixture anchor, not the wall clock, so a deep
327
- run is reproducible. The *symbol string* is therefore not a real listed contract —
328
- that is fine and deliberate: deep_validate checks field/type/flow integrity, and
329
- downstream fixtures key off the symbol string only as an opaque identifier.
334
+ The contract-month code is derived from the *execution time* (``now`` or the wall
335
+ clock), **not** the fixed anchor, so the fabricated symbol is a currently-live month.
336
+ Anchoring the month to 2025-01 (as this fixture used to) always minted a long-expired
337
+ symbol (HMHF25): deep_validate only checks field/type/flow integrity, but a dry-run
338
+ can let that symbol flow into a downstream quote node that hits LS for real, and LS
339
+ returns *empty data with no error* for an expired contract — a silent workflow death.
340
+ ``now`` is an injection hook so tests can pin the clock; the OHLCV series and fill
341
+ timestamp keep using ``_FIXTURE_ANCHOR`` for reproducibility, only the month here is
342
+ wall-clock based.
343
+
344
+ Limitation: with no listing master, the fixture cannot know which months a given
345
+ underlying actually lists. For a **quarterly-only** product (front lands on a
346
+ non-quarter month — 1·2·4·5·7·8·10·11), the fabricated front/next symbol is not a
347
+ real listed contract. That case is meant to surface as a *named* failure at run time
348
+ via the live FuturesContractNode's o3101 reason exposure, not to be papered over here.
330
349
  """
331
350
  raw = config.get("base_products") or []
332
351
  if isinstance(raw, str):
@@ -340,18 +359,34 @@ def futures_contract_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
340
359
  exchange = _ENUM_TO_EXCHCD.get(exchange, exchange) or "HKEX"
341
360
 
342
361
  # 월물 문자 코드 (F=1월 … Z=12월) — 실제 노드와 같은 표기를 쓴다.
343
- # contract_selection 을 실제 노드와 같은 규칙으로 반영한다(front=당월, next=익월,
344
- # quarterly=3·6·9·12월 중 최근접). fixture 가 이걸 무시하면 세 설정이 같은 심볼을 내
345
- # 배선 검증이 selection 오류를 못 잡는다.
346
362
  month_letters = "FGHJKMNQUVXZ"
347
363
  selection = str(config.get("contract_selection") or "front").strip().lower()
348
- month = _FIXTURE_ANCHOR.month
364
+
365
+ # 월물의 기준은 실행 시각(오늘)이다. now 는 테스트가 시각을 주입하기 위한 훅.
366
+ base = now or datetime.now(timezone.utc)
367
+ year = base.year
368
+ month = base.month
369
+
370
+ # 🔴 월말 만기 구멍 방어. HKEX HSI/MHI 지수선물은 그 달 '끝에서 두 번째 영업일'에 만기다.
371
+ # 실제 노드는 만기분을 LS 마스터가 빼주므로 그 며칠 뒤엔 익월물이 자동으로 근월이 되지만,
372
+ # fixture 엔 마스터가 없어 front 를 당월로 무조건 잡으면 만기 지난 며칠 동안 다시 만료 심볼을 낸다.
373
+ # 정확한 만기일 계산 대신 보수적으로: 당월 잔여일이 임계 미만이면 익월로 롤오버한다(_MONTH_END_ROLLOVER_DAYS).
374
+ # 롤오버가 이르게 걸려도 익월물 = 여전히 만료 안 된 미래 월물이라 안전하다(front 정밀도만 조금 손해).
375
+ last_day = calendar.monthrange(year, month)[1]
376
+ if last_day - base.day < _MONTH_END_ROLLOVER_DAYS:
377
+ month += 1 # front 를 익월로 롤오버
378
+
379
+ # contract_selection 을 실제 노드 _select 와 같은 규칙으로 반영한다(front=근월,
380
+ # next=그 다음 달=candidates[1], quarterly=근월 이상 최근접 분기월 3·6·9·12). fixture 가
381
+ # 이걸 무시하면 세 설정이 같은 심볼을 내 배선 검증이 selection 오류를 못 잡는다.
349
382
  if selection == "next":
350
383
  month += 1
351
384
  elif selection == "quarterly":
352
385
  while month % 3:
353
386
  month += 1
354
- year = _FIXTURE_ANCHOR.year + (month - 1) // 12
387
+
388
+ # 월 오버플로(>12)를 연도로 정규화한다.
389
+ year += (month - 1) // 12
355
390
  month = (month - 1) % 12 + 1
356
391
  letter = month_letters[month - 1]
357
392
  yy = f"{year % 100:02d}"
@@ -26,6 +26,7 @@ from programgarden_core import (
26
26
  OrderRejectInfo,
27
27
  ValidationLimits,
28
28
  map_reject_code,
29
+ diagnose_missing_order_no,
29
30
  )
30
31
  from programgarden.context import ExecutionContext, WorkflowEvent
31
32
  from programgarden.reconnect_handler import ReconnectHandler
@@ -44,6 +45,29 @@ from programgarden_core.nodes.base import BaseMessagingNode
44
45
  logger = logging.getLogger("programgarden.executor")
45
46
 
46
47
 
48
+ # 해외주식 주문시장코드(OrdMktCode) — LS 는 **"81"/"82" 두 값만** 받는다.
49
+ # COSAT00301/COSAT00311 의 OrdMktCode 도, 시세 g3101 의 exchcd 도 모두
50
+ # ``Literal["81", "82"]`` 이고 SDK 어디에도 "83" 은 없다.
51
+ #
52
+ # 🔴 AMEX(현 NYSE American)를 "83" 으로 보내던 것이 결함이었다 — 주문/정정/취소
53
+ # InBlock 생성 단계에서 pydantic ValidationError 로 죽어 **AMEX 종목은 주문도
54
+ # 취소도 불가능**했다. 엔진의 실시간·관심종목 경로는 이미 AMEX 를 "81" 로
55
+ # 매핑하고 있어(아래 _resolve_symbol_exchange 계열) 내부적으로도 어긋나 있었다.
56
+ # NYSE American 은 NYSE 가 운영하므로 "81" 로 통일한다.
57
+ #
58
+ # 이 표는 **주문 3종(신규/정정/취소)이 공유**한다. 종전에는 세 executor 클래스가
59
+ # 각자 사본을 들고 있어, 한 곳만 고치면 나머지가 조용히 남는 구조였다(같은 사고
60
+ # 패턴이 노드타입 목록 복사에서 이미 한 번 났다).
61
+ OVERSEAS_STOCK_MARKET_CODES = {
62
+ "NYSE": "81",
63
+ "NASDAQ": "82",
64
+ "AMEX": "81", # NYSE American — LS 전용 코드가 없어 NYSE 와 같은 81
65
+ "81": "81",
66
+ "82": "82",
67
+ "83": "81", # 과거 우리가 쓰던 잘못된 AMEX 코드가 저장된 워크플로우 방어
68
+ }
69
+
70
+
47
71
  def _safe_print(*args: Any, **kwargs: Any) -> None:
48
72
  """Emit console output without ever letting it abort the caller.
49
73
 
@@ -346,6 +370,43 @@ def _build_reconnect_hooks(
346
370
  class NodeExecutorBase:
347
371
  """Node executor base class"""
348
372
 
373
+ async def _notify_order_reject(
374
+ self,
375
+ context: ExecutionContext,
376
+ node_id: str,
377
+ symbol: str,
378
+ reject: OrderRejectInfo,
379
+ node_type: str = "OverseasStockNewOrderNode",
380
+ ) -> None:
381
+ """주문 거부 시 투자자 알림 1건 발행(on_notification).
382
+
383
+ on_log warning 과 별개로, AI/UI/텔레그램 소비자가 거부 사유(cause)와 대응
384
+ 팁(tip)을 구조화 payload 로 받도록 한다. ``node_type`` 은 시장별 주문 노드
385
+ 이름(해외주식/해외선물/국내주식)을 정확히 라벨링하기 위해 호출자가 전달한다.
386
+ """
387
+ try:
388
+ await context.send_notification(
389
+ # ORDER_REJECTED is the semantically correct category for a
390
+ # broker reject (RISK_ALERT means drawdown/risk-halt).
391
+ category=NotificationCategory.ORDER_REJECTED,
392
+ severity=NotificationSeverity.WARNING,
393
+ title=f"Order rejected: {symbol}",
394
+ message=f"{symbol} order rejected — {reject.cause}",
395
+ node_id=node_id,
396
+ node_type=node_type,
397
+ data={
398
+ "symbol": symbol,
399
+ "rsp_cd": reject.rsp_cd,
400
+ "cause": reject.cause,
401
+ "tip": reject.tip,
402
+ "raw_msg": reject.raw_msg,
403
+ "known": reject.known,
404
+ },
405
+ )
406
+ except Exception:
407
+ # 알림 전파 실패가 주문 결과 반환을 막아서는 안 된다.
408
+ pass
409
+
349
410
  async def execute(
350
411
  self,
351
412
  node_id: str,
@@ -3096,19 +3157,71 @@ class SymbolQueryNodeExecutor(NodeExecutorBase):
3096
3157
 
3097
3158
  all_symbols = []
3098
3159
 
3099
- try:
3100
- # gubun 은 거래소 필터가 **아니다** — 실측: 0/1/2/6 어느 값이든 같은 전체 목록을 준다.
3101
- # 거래소 좁히기는 응답의 ExchCd 로 클라이언트에서 한다(아래). gubun 은 "0" 고정.
3102
- query = ls.overseas_futureoption().market().해외선물마스터조회(
3103
- body=o3101.O3101InBlock(gubun="0")
3104
- )
3105
-
3106
- result = await query.req_async()
3160
+ # LS 는 같은 앱키로 요청이 몰리면 o3101 에 빈 블록을 준다(에러가 아니라 빈 배열). 이건 단발
3161
+ # 호출이라 재시도 없이 하드 실패로 올리면 일시적 토큰 경합 한 번이 워크플로우 틱을 죽인다 —
3162
+ # 형제 노드(FuturesContractNode)와 같은 3회 재시도 패턴을 쓴다. 그래도 비면 조용히 빈 배열을
3163
+ # 주지 않고 LS 원문(rsp_cd/rsp_msg/error_msg)을 실어 실패한다. 업무거부는 HTTP 200 + rsp_cd
3164
+ # 에만 원인이 있으므로(error_msg 는 "" 일 수 있다), rsp_cd 를 반드시 남긴다.
3165
+ rows: List[Any] = []
3166
+ last_error: Optional[Exception] = None
3167
+ last_rsp_cd = ""
3168
+ last_rsp_msg = ""
3169
+ last_error_msg = ""
3170
+ for attempt in range(3):
3171
+ if attempt:
3172
+ await asyncio.sleep(1.5 * attempt)
3173
+ try:
3174
+ # gubun 은 거래소 필터가 **아니다** — 실측: 0/1/2/6 어느 값이든 같은 전체 목록을 준다.
3175
+ # 거래소 좁히기는 응답의 ExchCd 로 클라이언트에서 한다(아래). gubun 은 "0" 고정.
3176
+ query = ls.overseas_futureoption().market().해외선물마스터조회(
3177
+ body=o3101.O3101InBlock(gubun="0")
3178
+ )
3179
+ result = await query.req_async()
3180
+ except Exception as e: # noqa: BLE001 — 원인을 그대로 사용자에게 전달한다
3181
+ last_error = e
3182
+ context.log("warning", f"o3101 조회 실패 (시도 {attempt + 1}/3): {e}", node_id)
3183
+ continue
3184
+ last_rsp_cd = (getattr(result, "rsp_cd", "") or "").strip()
3185
+ last_rsp_msg = (getattr(result, "rsp_msg", "") or "").strip()
3186
+ last_error_msg = (getattr(result, "error_msg", "") or "").strip()
3107
3187
  rows = list(getattr(result, 'block', None) or [])
3188
+ if rows:
3189
+ break
3190
+ context.log(
3191
+ "warning",
3192
+ f"o3101 이 빈 마스터를 반환 (시도 {attempt + 1}/3): "
3193
+ f"rsp_cd={last_rsp_cd or '(none)'}, rsp_msg={last_rsp_msg or '(none)'}"
3194
+ + (f", error_msg={last_error_msg}" if last_error_msg else ""),
3195
+ node_id,
3196
+ )
3108
3197
 
3109
- except Exception as e:
3110
- context.log("error", f"o3101 API error: {str(e)}", node_id)
3111
- return {"symbols": [], "count": 0, "error": str(e)}
3198
+ # 빈 응답을 조용히 넘기지 않는다 — 빈 유니버스는 하류를 전부 no-op 시키고 워크플로우는
3199
+ # '성공'한 척 아무것도 안 한다. 이 저장소가 없애려는 바로 그 무음 실패다.
3200
+ if not rows:
3201
+ if last_error is not None:
3202
+ raise RuntimeError(
3203
+ f"OverseasFuturesSymbolQueryNode[{node_id}]: LS master query (o3101) failed "
3204
+ f"after 3 attempts: {last_error}"
3205
+ ) from last_error
3206
+ ls_reason = ", ".join(
3207
+ part for part in (
3208
+ f"rsp_cd={last_rsp_cd}" if last_rsp_cd else "",
3209
+ f"rsp_msg={last_rsp_msg}" if last_rsp_msg else "",
3210
+ f"error_msg={last_error_msg}" if last_error_msg else "",
3211
+ ) if part
3212
+ )
3213
+ hint = (
3214
+ " A non-'00000' rsp_cd means missing overseas-futures market entitlement or a "
3215
+ "rejected token, not a transient collision."
3216
+ if last_rsp_cd and last_rsp_cd != "00000" else ""
3217
+ )
3218
+ raise RuntimeError(
3219
+ f"OverseasFuturesSymbolQueryNode[{node_id}]: LS returned an empty overseas-futures "
3220
+ f"master (o3101) on 3 attempts"
3221
+ + (f". LS says: {ls_reason}.{hint}" if ls_reason else
3222
+ " with no LS response code. The broker session may lack overseas-futures "
3223
+ "entitlement, or too many requests share this app key.")
3224
+ )
3112
3225
 
3113
3226
  wanted_exchange = self.FUTURES_EXCHANGE_CODES.get(futures_exchange, futures_exchange)
3114
3227
  if wanted_exchange in ("1", "", "ALL"):
@@ -3377,8 +3490,16 @@ class FuturesContractNodeExecutor(NodeExecutorBase):
3377
3490
  # LS 는 같은 앱키로 요청이 몰리면 o3101 에 **빈 블록**을 돌려준다(에러가 아니라 빈 배열).
3378
3491
  # 그 한 번에 워크플로우를 죽이면, 실제로는 멀쩡한 전략이 스케줄 한 틱을 통째로 날린다.
3379
3492
  # 짧게 몇 번 다시 물어보고, 그래도 비어 있을 때만 (진짜 권한/장애로 보고) 크게 실패한다.
3493
+ #
3494
+ # 🔴 업무거부는 HTTP 200 + 빈 block + rsp_cd(원인) 로 온다(error_msg 는 "" 일 수 있다).
3495
+ # 매 시도의 LS 원문(rsp_cd/rsp_msg/error_msg)을 보관해 재시도 경고와 최종 실패에 그대로
3496
+ # 싣는다 — 이 한 줄이 "토큰 무효화인가 / 시세 권한 문제인가"를 라이브 프로브 없이 판별해 준다.
3497
+ # (`if error_msg:` 로 판정하면 rsp_cd 에만 실린 업무거부를 전부 놓쳐 "빈 마스터"로 오진한다.)
3380
3498
  rows: List[Any] = []
3381
3499
  last_error: Optional[Exception] = None
3500
+ last_rsp_cd = ""
3501
+ last_rsp_msg = ""
3502
+ last_error_msg = ""
3382
3503
  for attempt in range(3):
3383
3504
  if attempt:
3384
3505
  await asyncio.sleep(1.5 * attempt)
@@ -3391,10 +3512,19 @@ class FuturesContractNodeExecutor(NodeExecutorBase):
3391
3512
  last_error = e
3392
3513
  context.log("warning", f"o3101 조회 실패 (시도 {attempt + 1}/3): {e}", node_id)
3393
3514
  continue
3515
+ last_rsp_cd = (getattr(result, "rsp_cd", "") or "").strip()
3516
+ last_rsp_msg = (getattr(result, "rsp_msg", "") or "").strip()
3517
+ last_error_msg = (getattr(result, "error_msg", "") or "").strip()
3394
3518
  rows = getattr(result, "block", None) or []
3395
3519
  if rows:
3396
3520
  break
3397
- context.log("warning", f"o3101 이 빈 마스터를 반환 (시도 {attempt + 1}/3)", node_id)
3521
+ context.log(
3522
+ "warning",
3523
+ f"o3101 이 빈 마스터를 반환 (시도 {attempt + 1}/3): "
3524
+ f"rsp_cd={last_rsp_cd or '(none)'}, rsp_msg={last_rsp_msg or '(none)'}"
3525
+ + (f", error_msg={last_error_msg}" if last_error_msg else ""),
3526
+ node_id,
3527
+ )
3398
3528
 
3399
3529
  if not rows:
3400
3530
  if last_error is not None:
@@ -3402,10 +3532,34 @@ class FuturesContractNodeExecutor(NodeExecutorBase):
3402
3532
  f"FuturesContractNode[{node_id}]: LS contract-master query (o3101) failed "
3403
3533
  f"after 3 attempts: {last_error}"
3404
3534
  ) from last_error
3535
+ # LS 원문 우선 — 추측 문구는 원문이 하나도 없을 때만.
3536
+ ls_reason = ", ".join(
3537
+ part for part in (
3538
+ f"rsp_cd={last_rsp_cd}" if last_rsp_cd else "",
3539
+ f"rsp_msg={last_rsp_msg}" if last_rsp_msg else "",
3540
+ f"error_msg={last_error_msg}" if last_error_msg else "",
3541
+ ) if part
3542
+ )
3543
+ if ls_reason:
3544
+ # rsp_cd 가 비었거나 '00000'(성공)이면 마스터가 진짜로 빈 것, 아니면 그 코드가 원인.
3545
+ if last_rsp_cd and last_rsp_cd != "00000":
3546
+ hint = (
3547
+ " A non-'00000' rsp_cd means the broker session lacks overseas-futures "
3548
+ "market entitlement or the token was rejected — not a transient app-key collision."
3549
+ )
3550
+ else:
3551
+ hint = (
3552
+ " rsp_cd is success/blank, so the master is genuinely empty for this account — "
3553
+ "check overseas-futures entitlement, or too many requests share this app key."
3554
+ )
3555
+ raise RuntimeError(
3556
+ f"FuturesContractNode[{node_id}]: LS returned an empty contract master (o3101) "
3557
+ f"on 3 attempts. LS says: {ls_reason}.{hint}"
3558
+ )
3405
3559
  raise RuntimeError(
3406
3560
  f"FuturesContractNode[{node_id}]: LS returned an empty contract master (o3101) "
3407
- f"on 3 attempts. The broker session may lack overseas-futures entitlement, or too "
3408
- f"many requests are sharing this app key at once."
3561
+ f"on 3 attempts with no LS response code. The broker session may lack overseas-futures "
3562
+ f"entitlement, or too many requests are sharing this app key at once."
3409
3563
  )
3410
3564
 
3411
3565
  # 거래소 필터를 걸기 **전** 목록도 들고 있는다 — 실패 메시지의 "쓸 수 있는 코드" 를
@@ -13529,14 +13683,8 @@ class NewOrderNodeExecutor(NodeExecutorBase):
13529
13683
  """
13530
13684
 
13531
13685
  # 해외주식 시장 코드 매핑
13532
- STOCK_MARKET_CODES = {
13533
- "NYSE": "81",
13534
- "NASDAQ": "82",
13535
- "AMEX": "83",
13536
- "81": "81",
13537
- "82": "82",
13538
- "83": "83",
13539
- }
13686
+ # 모듈 단일 정의를 참조한다 — 사본을 두면 한 곳만 고쳐지는 사고가 난다.
13687
+ STOCK_MARKET_CODES = OVERSEAS_STOCK_MARKET_CODES
13540
13688
 
13541
13689
  # 해외주식 호가 유형 코드 매핑
13542
13690
  STOCK_PRICE_TYPE_CODES = {
@@ -14067,14 +14215,13 @@ class NewOrderNodeExecutor(NodeExecutorBase):
14067
14215
  if not order_no:
14068
14216
  msg = response.rsp_msg or "주문번호 없음"
14069
14217
  context.log("warning", f"Order submitted but no OrderNo returned: {symbol} - {msg}", node_id)
14070
- # rsp_cd 가 성공("00000")인데 OrderNo 가 안 온 케이스 — rsp_cd 매핑이
14071
- # 아니라 전용 진단을 동봉한다(장 마감 / 브로커 지연 등).
14072
- reject = OrderRejectInfo(
14073
- rsp_cd=response.rsp_cd or "",
14074
- cause="Order accepted but no order number returned (likely market closed or broker delay)",
14075
- tip="Verify market hours and re-query open orders to confirm whether the order was actually placed.",
14076
- raw_msg=msg,
14077
- known=True,
14218
+ # 🔴 종전에는 무조건 "장 마감 / 브로커 지연" 이라고 단정했다. 전제는
14219
+ # "rsp_cd 는 성공인데 OrdNo 만 없다" 였는데, 2026-08-19 실계좌 실측에서
14220
+ # LS 의 업무 거부는 **error_msg 가 비고 rsp_cd 에 오류 코드가 실린 채
14221
+ # 접수 블록 자체가 없이** 온다는 게 확인됐다(02201 예수금 부족 등).
14222
+ # 그래서 이 분기가 실제 거부를 삼키고 틀린 원인을 말하고 있었다.
14223
+ reject = diagnose_missing_order_no(
14224
+ "overseas_stock", response.rsp_cd or "", msg
14078
14225
  )
14079
14226
  await self._notify_order_reject(context, node_id, symbol, reject)
14080
14227
  return self._order_result(
@@ -14616,14 +14763,13 @@ class NewOrderNodeExecutor(NodeExecutorBase):
14616
14763
  if not order_no:
14617
14764
  msg = response.rsp_msg or "주문번호 없음"
14618
14765
  context.log("warning", f"Futures order submitted but no OrderNo returned: {symbol} - {msg}", node_id)
14619
- # rsp_cd 성공인데 OrderNo 가 안 온 케이스 — 전용 lifecycle 진단 동봉
14620
- # (장 마감 / 브로커 지연 등). stock 경로와 동일 구조.
14621
- reject = OrderRejectInfo(
14622
- rsp_cd=response.rsp_cd or "",
14623
- cause="Order accepted but no order number returned (likely market closed or broker delay)",
14624
- tip="Verify market hours and re-query open orders to confirm whether the order was actually placed.",
14625
- raw_msg=msg,
14626
- known=True,
14766
+ # 🔴 종전에는 무조건 "장 마감 / 브로커 지연" 이라고 단정했다. 전제는
14767
+ # "rsp_cd 는 성공인데 OrdNo 만 없다" 였는데, 2026-08-19 실계좌 실측에서
14768
+ # LS 의 업무 거부는 **error_msg 가 비고 rsp_cd 에 오류 코드가 실린 채
14769
+ # 접수 블록 자체가 없이** 온다는 게 확인됐다(02201 예수금 부족 등).
14770
+ # 그래서 이 분기가 실제 거부를 삼키고 틀린 원인을 말하고 있었다.
14771
+ reject = diagnose_missing_order_no(
14772
+ "overseas_futures", response.rsp_cd or "", msg
14627
14773
  )
14628
14774
  await self._notify_order_reject(
14629
14775
  context, node_id, symbol, reject,
@@ -14755,12 +14901,13 @@ class NewOrderNodeExecutor(NodeExecutorBase):
14755
14901
  if not order_no:
14756
14902
  msg = response.rsp_msg or "주문번호 없음"
14757
14903
  context.log("warning", f"Korea stock order submitted but no OrderNo returned: {symbol} - {msg}", node_id)
14758
- reject = OrderRejectInfo(
14759
- rsp_cd=response.rsp_cd or "",
14760
- cause="Order accepted but no order number returned (likely market closed or broker delay)",
14761
- tip="Verify market hours and re-query open orders to confirm whether the order was actually placed.",
14762
- raw_msg=msg,
14763
- known=True,
14904
+ # 🔴 종전에는 무조건 "장 마감 / 브로커 지연" 이라고 단정했다. 전제는
14905
+ # "rsp_cd 는 성공인데 OrdNo 만 없다" 였는데, 2026-08-19 실계좌 실측에서
14906
+ # LS 의 업무 거부는 **error_msg 가 비고 rsp_cd 에 오류 코드가 실린 채
14907
+ # 접수 블록 자체가 없이** 온다는 게 확인됐다(02201 예수금 부족 등).
14908
+ # 그래서 이 분기가 실제 거부를 삼키고 틀린 원인을 말하고 있었다.
14909
+ reject = diagnose_missing_order_no(
14910
+ "korea_stock", response.rsp_cd or "", msg
14764
14911
  )
14765
14912
  await self._notify_order_reject(
14766
14913
  context, node_id, symbol, reject,
@@ -14791,43 +14938,6 @@ class NewOrderNodeExecutor(NodeExecutorBase):
14791
14938
  False, symbol, "KRX", side, qty, price, str(e), reject_info=reject,
14792
14939
  )
14793
14940
 
14794
- async def _notify_order_reject(
14795
- self,
14796
- context: ExecutionContext,
14797
- node_id: str,
14798
- symbol: str,
14799
- reject: OrderRejectInfo,
14800
- node_type: str = "OverseasStockNewOrderNode",
14801
- ) -> None:
14802
- """주문 거부 시 투자자 알림 1건 발행(on_notification).
14803
-
14804
- on_log warning 과 별개로, AI/UI/텔레그램 소비자가 거부 사유(cause)와 대응
14805
- 팁(tip)을 구조화 payload 로 받도록 한다. ``node_type`` 은 시장별 주문 노드
14806
- 이름(해외주식/해외선물/국내주식)을 정확히 라벨링하기 위해 호출자가 전달한다.
14807
- """
14808
- try:
14809
- await context.send_notification(
14810
- # ORDER_REJECTED is the semantically correct category for a
14811
- # broker reject (RISK_ALERT means drawdown/risk-halt).
14812
- category=NotificationCategory.ORDER_REJECTED,
14813
- severity=NotificationSeverity.WARNING,
14814
- title=f"Order rejected: {symbol}",
14815
- message=f"{symbol} order rejected — {reject.cause}",
14816
- node_id=node_id,
14817
- node_type=node_type,
14818
- data={
14819
- "symbol": symbol,
14820
- "rsp_cd": reject.rsp_cd,
14821
- "cause": reject.cause,
14822
- "tip": reject.tip,
14823
- "raw_msg": reject.raw_msg,
14824
- "known": reject.known,
14825
- },
14826
- )
14827
- except Exception:
14828
- # 알림 전파 실패가 주문 결과 반환을 막아서는 안 된다.
14829
- pass
14830
-
14831
14941
  def _order_result(
14832
14942
  self,
14833
14943
  success: bool,
@@ -14918,7 +15028,7 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
14918
15028
 
14919
15029
  지원 노드:
14920
15030
  - OverseasStockModifyOrderNode: 해외주식 정정주문 (COSAT00311)
14921
- - OverseasFuturesModifyOrderNode: 해외선물 정정주문 (CIDBT00200)
15031
+ - OverseasFuturesModifyOrderNode: 해외선물 정정주문 (CIDBT00900)
14922
15032
 
14923
15033
  입력:
14924
15034
  - original_order_id: 원주문번호 (필수)
@@ -14929,11 +15039,7 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
14929
15039
  """
14930
15040
 
14931
15041
  # 해외주식 시장 코드 매핑
14932
- STOCK_MARKET_CODES = {
14933
- "NYSE": "81",
14934
- "NASDAQ": "82",
14935
- "AMEX": "83",
14936
- }
15042
+ STOCK_MARKET_CODES = OVERSEAS_STOCK_MARKET_CODES
14937
15043
 
14938
15044
  async def execute(
14939
15045
  self,
@@ -15401,8 +15507,8 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15401
15507
  주문 취소 Executor
15402
15508
 
15403
15509
  지원 노드:
15404
- - OverseasStockCancelOrderNode: 해외주식 취소주문 (COSAT00303)
15405
- - OverseasFuturesCancelOrderNode: 해외선물 취소주문 (CIDBT00300)
15510
+ - OverseasStockCancelOrderNode: 해외주식 취소주문 (COSAT00301, OrdPtnCode="08")
15511
+ - OverseasFuturesCancelOrderNode: 해외선물 취소주문 (CIDBT01000)
15406
15512
 
15407
15513
  입력:
15408
15514
  - original_order_id: 취소할 주문번호 (필수)
@@ -15413,11 +15519,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15413
15519
  """
15414
15520
 
15415
15521
  # 해외주식 시장 코드 매핑
15416
- STOCK_MARKET_CODES = {
15417
- "NYSE": "81",
15418
- "NASDAQ": "82",
15419
- "AMEX": "83",
15420
- }
15522
+ STOCK_MARKET_CODES = OVERSEAS_STOCK_MARKET_CODES
15421
15523
 
15422
15524
  async def execute(
15423
15525
  self,
@@ -15544,7 +15646,19 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15544
15646
  context: ExecutionContext,
15545
15647
  node_id: str,
15546
15648
  ) -> Dict[str, Any]:
15547
- """해외주식 취소주문 실행 (COSAT00301)"""
15649
+ """해외주식 취소주문 실행 (COSAT00301)
15650
+
15651
+ 해외주식엔 전용 취소 TR 이 없다 — 신규와 같은 COSAT00301 에 OrdPtnCode="08"
15652
+ 을 실어 보낸다.
15653
+
15654
+ ★ 라이브 실측 (2026-08-19, 실계좌 SNDL@NASDAQ 1주 왕복) — 이 경로가 LS 에서
15655
+ 실제로 접수됨을 확인했다:
15656
+ · 취소 응답 rsp_cd="00156" "취소주문이 완료되었습니다." + 취소 전용 새 OrdNo
15657
+ · **OrdQty=0 = 전량 취소** (LS 소스에 선언이 없어 미지수였던 값의 서버측
15658
+ 해석 확정 — 거부도 무시도 아니다). 원주문이 미체결에서 소멸했고 원장에서
15659
+ ExecQty=0 · UnercQty=0 으로 남았다.
15660
+ · 미국 정규장 밖에서도 접수된다.
15661
+ """
15548
15662
  from programgarden_finance.ls.overseas_stock.order.COSAT00301.blocks import COSAT00301InBlock1
15549
15663
 
15550
15664
 
@@ -15580,16 +15694,59 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15580
15694
  "cancelled_order": None,
15581
15695
  }
15582
15696
 
15697
+ # 🔴 error_msg 부재만으로 성공 처리하면 **살아 있는 주문을 "취소됨" 으로
15698
+ # 보고**한다. LS 는 업무 거부(이미 체결됨·원주문 없음 등)를 error_msg 없이
15699
+ # HTTP 200 + 오류 rsp_cd 로 돌려주고, 그때 접수 블록(block2)이 비거나
15700
+ # 취소주문번호(OrdNo)가 0/빈 값으로 온다. 신규주문 경로에는 이미 같은
15701
+ # 가드가 있는데 취소에만 없어 비대칭이었다 — 취소는 "이미 체결됨" 류
15702
+ # 거부가 정상 빈발 경로라 신규보다 더 아프다.
15703
+ block2 = getattr(response, "block2", None)
15704
+ cancel_ord_no = str(getattr(block2, "OrdNo", "") or "").strip() if block2 else ""
15705
+ if not cancel_ord_no or cancel_ord_no == "0":
15706
+ rsp_cd = getattr(response, "rsp_cd", "") or ""
15707
+ rsp_msg = getattr(response, "rsp_msg", "") or ""
15708
+ # 실측 취소 거부 코드(2026-08-19): 02259 = 그 원주문번호가 없음,
15709
+ # 03759 = 정정/취소할 잔량이 없음(이미 체결됐거나 이미 취소됨).
15710
+ # 코드표에 있으면 원인·조치를 특정해 주고, 없으면 원문만 넘긴다.
15711
+ reject = diagnose_missing_order_no("overseas_stock", rsp_cd, rsp_msg)
15712
+ reason = (
15713
+ f"취소가 접수되지 않았습니다 (취소주문번호 미발급) — "
15714
+ f"rsp_cd={rsp_cd or '없음'}, rsp_msg={rsp_msg or '없음'}. "
15715
+ f"{reject.cause}"
15716
+ + (f" {reject.tip}" if reject.tip else
15717
+ " 원주문이 그대로 살아 있을 수 있으니 미체결 조회로 확인하세요.")
15718
+ )
15719
+ context.log("warning", f"Cancel order rejected: {symbol} — {reason}", node_id)
15720
+ await self._notify_order_reject(
15721
+ context, node_id, symbol, reject,
15722
+ node_type="OverseasStockCancelOrderNode",
15723
+ )
15724
+ return {
15725
+ "cancel_result": {
15726
+ "success": False,
15727
+ "error": reason,
15728
+ "order_id": order_id,
15729
+ "product": "overseas_stock",
15730
+ "rsp_cd": rsp_cd,
15731
+ "rsp_msg": rsp_msg,
15732
+ "reject_info": reject.model_dump(),
15733
+ },
15734
+ "cancelled_order_id": "",
15735
+ "cancelled_order": None,
15736
+ }
15737
+
15583
15738
  context.log(
15584
15739
  "info",
15585
- f"Order cancelled: {symbol} order_id={order_id}",
15740
+ f"Order cancelled: {symbol} order_id={order_id} cancel_order_no={cancel_ord_no}",
15586
15741
  node_id
15587
15742
  )
15588
-
15743
+
15589
15744
  return {
15590
15745
  "cancel_result": {
15591
15746
  "success": True,
15592
15747
  "order_id": order_id,
15748
+ # 취소는 그 자체로 새 주문번호를 받는다 — 원주문번호와 구분해 남긴다.
15749
+ "cancel_order_no": cancel_ord_no,
15593
15750
  "product": "overseas_stock",
15594
15751
  },
15595
15752
  "cancelled_order_id": order_id,
@@ -15661,16 +15818,46 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15661
15818
  "cancelled_order": None,
15662
15819
  }
15663
15820
 
15821
+ # 해외주식 취소와 동일한 가드 — error_msg 부재만 보고 성공 처리하면 살아
15822
+ # 있는 주문을 "취소됨" 으로 보고한다. 선물 응답의 주문번호 필드는 OrdNo 가
15823
+ # 아니라 **OvrsFutsOrdNo** 다(CIDBT01000OutBlock2).
15824
+ block2 = getattr(response, "block2", None)
15825
+ cancel_ord_no = (
15826
+ str(getattr(block2, "OvrsFutsOrdNo", "") or "").strip() if block2 else ""
15827
+ )
15828
+ if not cancel_ord_no or cancel_ord_no == "0":
15829
+ rsp_cd = getattr(response, "rsp_cd", "") or ""
15830
+ rsp_msg = getattr(response, "rsp_msg", "") or ""
15831
+ reason = (
15832
+ f"취소가 접수되지 않았습니다 (취소주문번호 미발급) — "
15833
+ f"rsp_cd={rsp_cd or '없음'}, rsp_msg={rsp_msg or '없음'}. "
15834
+ "원주문이 그대로 살아 있을 수 있으니 미체결 조회로 확인하세요."
15835
+ )
15836
+ context.log("warning", f"Cancel futures order rejected: {symbol} — {reason}", node_id)
15837
+ return {
15838
+ "cancel_result": {
15839
+ "success": False,
15840
+ "error": reason,
15841
+ "order_id": order_id,
15842
+ "product": "overseas_futures",
15843
+ "rsp_cd": rsp_cd,
15844
+ "rsp_msg": rsp_msg,
15845
+ },
15846
+ "cancelled_order_id": "",
15847
+ "cancelled_order": None,
15848
+ }
15849
+
15664
15850
  context.log(
15665
15851
  "info",
15666
- f"Futures order cancelled: {symbol} order_id={order_id}",
15852
+ f"Futures order cancelled: {symbol} order_id={order_id} cancel_order_no={cancel_ord_no}",
15667
15853
  node_id
15668
15854
  )
15669
-
15855
+
15670
15856
  return {
15671
15857
  "cancel_result": {
15672
15858
  "success": True,
15673
15859
  "order_id": order_id,
15860
+ "cancel_order_no": cancel_ord_no,
15674
15861
  "product": "overseas_futures",
15675
15862
  },
15676
15863
  "cancelled_order_id": order_id,
@@ -15695,6 +15882,43 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15695
15882
  "cancelled_order": None,
15696
15883
  }
15697
15884
 
15885
+ async def _fetch_korea_remaining_qty(
15886
+ self,
15887
+ ls,
15888
+ order_id: str,
15889
+ symbol: str,
15890
+ context: ExecutionContext,
15891
+ node_id: str,
15892
+ ) -> int:
15893
+ """원주문의 미체결 잔량을 t0425 로 조회한다. 못 구하면 0 (추측하지 않는다).
15894
+
15895
+ 🔴 t0425 의 ``expcode`` 는 **6자리 순수 코드**다. 주문 TR(CSPAT00601)의
15896
+ ``IsuNo`` 는 ``A``+6자리도 받으므로 그대로 넘기면 **0건**이 돌아오고, 그
15897
+ 0건을 "잔량 없음" 으로 읽으면 조용히 틀린 수량으로 취소하게 된다.
15898
+ """
15899
+ try:
15900
+ from programgarden_finance.ls.korea_stock.accno.t0425.blocks import T0425InBlock
15901
+
15902
+ code = symbol[1:] if len(symbol) == 7 and symbol[:1].isalpha() else symbol
15903
+ response = await ls.korea_stock().accno().t0425(
15904
+ T0425InBlock(expcode=code, chegb="2", medosu="0", sortgb="1", cts_ordno="")
15905
+ ).req_async()
15906
+ if getattr(response, "error_msg", None):
15907
+ context.log("warning", f"t0425 조회 실패: {response.error_msg}", node_id)
15908
+ return 0
15909
+ for row in (getattr(response, "block", None) or []):
15910
+ if str(getattr(row, "ordno", "")) == str(order_id):
15911
+ return int(getattr(row, "ordrem", 0) or 0)
15912
+ context.log(
15913
+ "warning",
15914
+ f"원주문 {order_id} 이 미체결 목록에 없습니다 (이미 체결/취소됐을 수 있음)",
15915
+ node_id,
15916
+ )
15917
+ return 0
15918
+ except Exception as e: # 조회 실패는 취소 실패로 이어져야 한다 — 추측 금지
15919
+ context.log("warning", f"원주문 잔량 조회 중 오류: {e}", node_id)
15920
+ return 0
15921
+
15698
15922
  async def _cancel_korea_stock(
15699
15923
  self,
15700
15924
  ls,
@@ -15707,12 +15931,37 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15707
15931
  """국내주식 취소주문 실행 (CSPAT00801)"""
15708
15932
  from programgarden_finance.ls.korea_stock.order.CSPAT00801.blocks import CSPAT00801InBlock1
15709
15933
 
15710
- # 취소 수량: config에서 지정하거나, 미지정 시 원주문 잔량 사용
15934
+ # 취소 수량. 🔴 국내는 해외와 의미가 **다르다** (2026-08-19 실계좌 실측):
15935
+ # 해외주식 COSAT00301 : OrdQty=0 → 전량 취소
15936
+ # 국내주식 CSPAT00801 : OrdQty=N → **N주만** 취소 (부분취소)
15937
+ # 실측 근거 — 2주 주문(ordno=15531)을 OrdQty=1 로 취소하니 rsp_cd=00156
15938
+ # "취소주문이 완료되었습니다" 를 받고도 t0425 잔량이 2→**1** 로 남았다.
15939
+ # 따라서 수량 미지정 시 1 로 떨어뜨리면 "취소했습니다" 라고 답한 뒤 잔량이
15940
+ # 그대로 살아 시장에 남는다(사용자는 취소된 줄 안다). 미지정이면 원주문
15941
+ # 잔량을 조회해서 그 값을 쓰고, 잔량을 못 구하면 **1 로 추측하지 않고** 실패한다.
15711
15942
  cancel_qty = config.get("quantity", 0) or config.get("cancel_quantity", 0)
15712
15943
  if not cancel_qty:
15713
- context.log("warning", f"cancel_quantity not specified, using original order remaining qty from order info", node_id)
15714
- # 미체결 잔량 정보가 없으면 기본 1 (API에서 잔량 초과 시 에러 반환)
15715
- cancel_qty = config.get("remaining_quantity", 1)
15944
+ cancel_qty = config.get("remaining_quantity", 0)
15945
+ if not cancel_qty:
15946
+ cancel_qty = await self._fetch_korea_remaining_qty(ls, order_id, symbol, context, node_id)
15947
+ if not cancel_qty:
15948
+ reason = (
15949
+ "취소 수량을 정할 수 없습니다 — 취소할 수량(quantity)이 지정되지 않았고 "
15950
+ f"원주문({order_id})의 미체결 잔량도 조회되지 않았습니다. "
15951
+ "국내주식 취소는 수량만큼만 취소되므로(부분취소), 임의 수량으로 보내면 "
15952
+ "일부만 취소되고 나머지가 시장에 남습니다. 취소할 수량을 지정해 주세요."
15953
+ )
15954
+ context.log("warning", f"Korea stock cancel aborted: {symbol} — {reason}", node_id)
15955
+ return {
15956
+ "cancel_result": {
15957
+ "success": False,
15958
+ "error": reason,
15959
+ "order_id": order_id,
15960
+ "product": "korea_stock",
15961
+ },
15962
+ "cancelled_order_id": "",
15963
+ "cancelled_order": None,
15964
+ }
15716
15965
 
15717
15966
  try:
15718
15967
  body = CSPAT00801InBlock1(
@@ -15737,9 +15986,36 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15737
15986
  "cancelled_order": None,
15738
15987
  }
15739
15988
 
15989
+ # 해외 취소와 동일한 가드. 국내는 **실전 계좌 전용**(모의투자 없음)이라
15990
+ # "취소됨" 오보의 대가가 가장 크다. CSPAT00801OutBlock2.OrdNo 가 새로
15991
+ # 발급되는 취소주문번호이고, 0/빈 값이면 접수되지 않은 것이다.
15992
+ block2 = getattr(response, "block2", None)
15993
+ cancel_ord_no = str(getattr(block2, "OrdNo", "") or "").strip() if block2 else ""
15994
+ if not cancel_ord_no or cancel_ord_no == "0":
15995
+ rsp_cd = getattr(response, "rsp_cd", "") or ""
15996
+ rsp_msg = getattr(response, "rsp_msg", "") or ""
15997
+ reason = (
15998
+ f"취소가 접수되지 않았습니다 (취소주문번호 미발급) — "
15999
+ f"rsp_cd={rsp_cd or '없음'}, rsp_msg={rsp_msg or '없음'}. "
16000
+ "원주문이 그대로 살아 있을 수 있으니 미체결 조회로 확인하세요."
16001
+ )
16002
+ context.log("warning", f"Korea stock cancel rejected: {symbol} — {reason}", node_id)
16003
+ return {
16004
+ "cancel_result": {
16005
+ "success": False,
16006
+ "error": reason,
16007
+ "order_id": order_id,
16008
+ "product": "korea_stock",
16009
+ "rsp_cd": rsp_cd,
16010
+ "rsp_msg": rsp_msg,
16011
+ },
16012
+ "cancelled_order_id": "",
16013
+ "cancelled_order": None,
16014
+ }
16015
+
15740
16016
  context.log(
15741
16017
  "info",
15742
- f"Korea stock order cancelled: {symbol} order_id={order_id}",
16018
+ f"Korea stock order cancelled: {symbol} order_id={order_id} cancel_order_no={cancel_ord_no}",
15743
16019
  node_id
15744
16020
  )
15745
16021
 
@@ -15747,6 +16023,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15747
16023
  "cancel_result": {
15748
16024
  "success": True,
15749
16025
  "order_id": order_id,
16026
+ "cancel_order_no": cancel_ord_no,
15750
16027
  "product": "korea_stock",
15751
16028
  },
15752
16029
  "cancelled_order_id": order_id,
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.31.1"
8
+ version = "1.31.2"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -29,8 +29,8 @@ 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.23.0"
33
- programgarden-finance = "^1.7.0"
32
+ programgarden-core = "^1.24.0"
33
+ programgarden-finance = "^1.8.0"
34
34
  programgarden-community = "^1.15.0"
35
35
 
36
36
  [tool.poetry.group.dev.dependencies]
File without changes