programgarden 1.31.0__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.0 → programgarden-1.31.2}/PKG-INFO +3 -3
  2. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/deep_fixtures.py +45 -10
  3. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/executor.py +435 -108
  4. {programgarden-1.31.0 → programgarden-1.31.2}/pyproject.toml +3 -3
  5. {programgarden-1.31.0 → programgarden-1.31.2}/README.md +0 -0
  6. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/__init__.py +0 -0
  7. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/binding_validator.py +0 -0
  8. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/client.py +0 -0
  9. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/code_worker.py +0 -0
  10. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/context.py +0 -0
  11. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/database/__init__.py +0 -0
  12. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/database/checkpoint_manager.py +0 -0
  13. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/database/query_builder.py +0 -0
  14. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/database/workflow_position_tracker.py +0 -0
  15. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/database/workflow_risk_tracker.py +0 -0
  16. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/node_runner.py +0 -0
  17. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/plugin/__init__.py +0 -0
  18. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/plugin/sandbox.py +0 -0
  19. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/providers/__init__.py +0 -0
  20. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/providers/llm_errors.py +0 -0
  21. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/providers/llm_provider.py +0 -0
  22. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/reconnect_handler.py +0 -0
  23. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resolver.py +0 -0
  24. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resource/__init__.py +0 -0
  25. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resource/context.py +0 -0
  26. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resource/limiter.py +0 -0
  27. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resource/monitor.py +0 -0
  28. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resource/throttle.py +0 -0
  29. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/semantic_rules.py +0 -0
  30. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.31.0 → 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.0
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}"
@@ -12,6 +12,7 @@ from typing import Optional, Dict, Any, List, Callable, Awaitable, Set, Tuple
12
12
  from datetime import datetime
13
13
  import asyncio
14
14
  import ast
15
+ import copy
15
16
  import re
16
17
  import uuid
17
18
  import logging
@@ -25,6 +26,7 @@ from programgarden_core import (
25
26
  OrderRejectInfo,
26
27
  ValidationLimits,
27
28
  map_reject_code,
29
+ diagnose_missing_order_no,
28
30
  )
29
31
  from programgarden.context import ExecutionContext, WorkflowEvent
30
32
  from programgarden.reconnect_handler import ReconnectHandler
@@ -43,6 +45,29 @@ from programgarden_core.nodes.base import BaseMessagingNode
43
45
  logger = logging.getLogger("programgarden.executor")
44
46
 
45
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
+
46
71
  def _safe_print(*args: Any, **kwargs: Any) -> None:
47
72
  """Emit console output without ever letting it abort the caller.
48
73
 
@@ -345,6 +370,43 @@ def _build_reconnect_hooks(
345
370
  class NodeExecutorBase:
346
371
  """Node executor base class"""
347
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
+
348
410
  async def execute(
349
411
  self,
350
412
  node_id: str,
@@ -3095,19 +3157,71 @@ class SymbolQueryNodeExecutor(NodeExecutorBase):
3095
3157
 
3096
3158
  all_symbols = []
3097
3159
 
3098
- try:
3099
- # gubun 은 거래소 필터가 **아니다** — 실측: 0/1/2/6 어느 값이든 같은 전체 목록을 준다.
3100
- # 거래소 좁히기는 응답의 ExchCd 로 클라이언트에서 한다(아래). gubun 은 "0" 고정.
3101
- query = ls.overseas_futureoption().market().해외선물마스터조회(
3102
- body=o3101.O3101InBlock(gubun="0")
3103
- )
3104
-
3105
- 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()
3106
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
+ )
3107
3197
 
3108
- except Exception as e:
3109
- context.log("error", f"o3101 API error: {str(e)}", node_id)
3110
- 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
+ )
3111
3225
 
3112
3226
  wanted_exchange = self.FUTURES_EXCHANGE_CODES.get(futures_exchange, futures_exchange)
3113
3227
  if wanted_exchange in ("1", "", "ALL"):
@@ -3376,8 +3490,16 @@ class FuturesContractNodeExecutor(NodeExecutorBase):
3376
3490
  # LS 는 같은 앱키로 요청이 몰리면 o3101 에 **빈 블록**을 돌려준다(에러가 아니라 빈 배열).
3377
3491
  # 그 한 번에 워크플로우를 죽이면, 실제로는 멀쩡한 전략이 스케줄 한 틱을 통째로 날린다.
3378
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 에만 실린 업무거부를 전부 놓쳐 "빈 마스터"로 오진한다.)
3379
3498
  rows: List[Any] = []
3380
3499
  last_error: Optional[Exception] = None
3500
+ last_rsp_cd = ""
3501
+ last_rsp_msg = ""
3502
+ last_error_msg = ""
3381
3503
  for attempt in range(3):
3382
3504
  if attempt:
3383
3505
  await asyncio.sleep(1.5 * attempt)
@@ -3390,10 +3512,19 @@ class FuturesContractNodeExecutor(NodeExecutorBase):
3390
3512
  last_error = e
3391
3513
  context.log("warning", f"o3101 조회 실패 (시도 {attempt + 1}/3): {e}", node_id)
3392
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()
3393
3518
  rows = getattr(result, "block", None) or []
3394
3519
  if rows:
3395
3520
  break
3396
- 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
+ )
3397
3528
 
3398
3529
  if not rows:
3399
3530
  if last_error is not None:
@@ -3401,10 +3532,34 @@ class FuturesContractNodeExecutor(NodeExecutorBase):
3401
3532
  f"FuturesContractNode[{node_id}]: LS contract-master query (o3101) failed "
3402
3533
  f"after 3 attempts: {last_error}"
3403
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
+ )
3404
3559
  raise RuntimeError(
3405
3560
  f"FuturesContractNode[{node_id}]: LS returned an empty contract master (o3101) "
3406
- f"on 3 attempts. The broker session may lack overseas-futures entitlement, or too "
3407
- 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."
3408
3563
  )
3409
3564
 
3410
3565
  # 거래소 필터를 걸기 **전** 목록도 들고 있는다 — 실패 메시지의 "쓸 수 있는 코드" 를
@@ -13528,14 +13683,8 @@ class NewOrderNodeExecutor(NodeExecutorBase):
13528
13683
  """
13529
13684
 
13530
13685
  # 해외주식 시장 코드 매핑
13531
- STOCK_MARKET_CODES = {
13532
- "NYSE": "81",
13533
- "NASDAQ": "82",
13534
- "AMEX": "83",
13535
- "81": "81",
13536
- "82": "82",
13537
- "83": "83",
13538
- }
13686
+ # 모듈 단일 정의를 참조한다 — 사본을 두면 한 곳만 고쳐지는 사고가 난다.
13687
+ STOCK_MARKET_CODES = OVERSEAS_STOCK_MARKET_CODES
13539
13688
 
13540
13689
  # 해외주식 호가 유형 코드 매핑
13541
13690
  STOCK_PRICE_TYPE_CODES = {
@@ -13683,13 +13832,23 @@ class NewOrderNodeExecutor(NodeExecutorBase):
13683
13832
  item=normalized_order,
13684
13833
  )
13685
13834
  if existing_order is not None:
13835
+ _inner = existing_order.get("order_result")
13836
+ _inner = _inner if isinstance(_inner, dict) else {}
13686
13837
  context.log(
13687
13838
  "info",
13688
13839
  f"{node_type}: 중복 주문 차단 — idempotency 레지스트리에서 기존 결과 반환 "
13689
- f"(order_no={existing_order.get('order_no', '?')})",
13840
+ f"(order_no={existing_order.get('order_id') or _inner.get('order_no') or '?'})",
13690
13841
  node_id,
13691
13842
  )
13692
- return existing_order
13843
+ # 리플레이 표식: 이 완료 이벤트는 새 주문이 아니라 저장된 결과의 재방출이다.
13844
+ # 리스너(트레이 per-order durable 로그 등)가 이걸 신규 주문으로 재적재하면
13845
+ # 같은 실주문이 로그에 2행 생긴다 — 깊은 복사에 표식을 달아 반환한다
13846
+ # (레지스트리 원본은 불변 유지).
13847
+ replayed = copy.deepcopy(existing_order)
13848
+ if isinstance(replayed.get("order_result"), dict):
13849
+ replayed["order_result"]["idempotent_replay"] = True
13850
+ replayed["idempotent_replay"] = True
13851
+ return replayed
13693
13852
 
13694
13853
  # === 4. LS 로그인 ===
13695
13854
  credential = context.get_credential()
@@ -14056,14 +14215,13 @@ class NewOrderNodeExecutor(NodeExecutorBase):
14056
14215
  if not order_no:
14057
14216
  msg = response.rsp_msg or "주문번호 없음"
14058
14217
  context.log("warning", f"Order submitted but no OrderNo returned: {symbol} - {msg}", node_id)
14059
- # rsp_cd 가 성공("00000")인데 OrderNo 가 안 온 케이스 — rsp_cd 매핑이
14060
- # 아니라 전용 진단을 동봉한다(장 마감 / 브로커 지연 등).
14061
- reject = OrderRejectInfo(
14062
- rsp_cd=response.rsp_cd or "",
14063
- cause="Order accepted but no order number returned (likely market closed or broker delay)",
14064
- tip="Verify market hours and re-query open orders to confirm whether the order was actually placed.",
14065
- raw_msg=msg,
14066
- 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
14067
14225
  )
14068
14226
  await self._notify_order_reject(context, node_id, symbol, reject)
14069
14227
  return self._order_result(
@@ -14605,14 +14763,13 @@ class NewOrderNodeExecutor(NodeExecutorBase):
14605
14763
  if not order_no:
14606
14764
  msg = response.rsp_msg or "주문번호 없음"
14607
14765
  context.log("warning", f"Futures order submitted but no OrderNo returned: {symbol} - {msg}", node_id)
14608
- # rsp_cd 성공인데 OrderNo 가 안 온 케이스 — 전용 lifecycle 진단 동봉
14609
- # (장 마감 / 브로커 지연 등). stock 경로와 동일 구조.
14610
- reject = OrderRejectInfo(
14611
- rsp_cd=response.rsp_cd or "",
14612
- cause="Order accepted but no order number returned (likely market closed or broker delay)",
14613
- tip="Verify market hours and re-query open orders to confirm whether the order was actually placed.",
14614
- raw_msg=msg,
14615
- 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
14616
14773
  )
14617
14774
  await self._notify_order_reject(
14618
14775
  context, node_id, symbol, reject,
@@ -14744,12 +14901,13 @@ class NewOrderNodeExecutor(NodeExecutorBase):
14744
14901
  if not order_no:
14745
14902
  msg = response.rsp_msg or "주문번호 없음"
14746
14903
  context.log("warning", f"Korea stock order submitted but no OrderNo returned: {symbol} - {msg}", node_id)
14747
- reject = OrderRejectInfo(
14748
- rsp_cd=response.rsp_cd or "",
14749
- cause="Order accepted but no order number returned (likely market closed or broker delay)",
14750
- tip="Verify market hours and re-query open orders to confirm whether the order was actually placed.",
14751
- raw_msg=msg,
14752
- 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
14753
14911
  )
14754
14912
  await self._notify_order_reject(
14755
14913
  context, node_id, symbol, reject,
@@ -14780,43 +14938,6 @@ class NewOrderNodeExecutor(NodeExecutorBase):
14780
14938
  False, symbol, "KRX", side, qty, price, str(e), reject_info=reject,
14781
14939
  )
14782
14940
 
14783
- async def _notify_order_reject(
14784
- self,
14785
- context: ExecutionContext,
14786
- node_id: str,
14787
- symbol: str,
14788
- reject: OrderRejectInfo,
14789
- node_type: str = "OverseasStockNewOrderNode",
14790
- ) -> None:
14791
- """주문 거부 시 투자자 알림 1건 발행(on_notification).
14792
-
14793
- on_log warning 과 별개로, AI/UI/텔레그램 소비자가 거부 사유(cause)와 대응
14794
- 팁(tip)을 구조화 payload 로 받도록 한다. ``node_type`` 은 시장별 주문 노드
14795
- 이름(해외주식/해외선물/국내주식)을 정확히 라벨링하기 위해 호출자가 전달한다.
14796
- """
14797
- try:
14798
- await context.send_notification(
14799
- # ORDER_REJECTED is the semantically correct category for a
14800
- # broker reject (RISK_ALERT means drawdown/risk-halt).
14801
- category=NotificationCategory.ORDER_REJECTED,
14802
- severity=NotificationSeverity.WARNING,
14803
- title=f"Order rejected: {symbol}",
14804
- message=f"{symbol} order rejected — {reject.cause}",
14805
- node_id=node_id,
14806
- node_type=node_type,
14807
- data={
14808
- "symbol": symbol,
14809
- "rsp_cd": reject.rsp_cd,
14810
- "cause": reject.cause,
14811
- "tip": reject.tip,
14812
- "raw_msg": reject.raw_msg,
14813
- "known": reject.known,
14814
- },
14815
- )
14816
- except Exception:
14817
- # 알림 전파 실패가 주문 결과 반환을 막아서는 안 된다.
14818
- pass
14819
-
14820
14941
  def _order_result(
14821
14942
  self,
14822
14943
  success: bool,
@@ -14907,7 +15028,7 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
14907
15028
 
14908
15029
  지원 노드:
14909
15030
  - OverseasStockModifyOrderNode: 해외주식 정정주문 (COSAT00311)
14910
- - OverseasFuturesModifyOrderNode: 해외선물 정정주문 (CIDBT00200)
15031
+ - OverseasFuturesModifyOrderNode: 해외선물 정정주문 (CIDBT00900)
14911
15032
 
14912
15033
  입력:
14913
15034
  - original_order_id: 원주문번호 (필수)
@@ -14918,11 +15039,7 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
14918
15039
  """
14919
15040
 
14920
15041
  # 해외주식 시장 코드 매핑
14921
- STOCK_MARKET_CODES = {
14922
- "NYSE": "81",
14923
- "NASDAQ": "82",
14924
- "AMEX": "83",
14925
- }
15042
+ STOCK_MARKET_CODES = OVERSEAS_STOCK_MARKET_CODES
14926
15043
 
14927
15044
  async def execute(
14928
15045
  self,
@@ -15390,8 +15507,8 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15390
15507
  주문 취소 Executor
15391
15508
 
15392
15509
  지원 노드:
15393
- - OverseasStockCancelOrderNode: 해외주식 취소주문 (COSAT00303)
15394
- - OverseasFuturesCancelOrderNode: 해외선물 취소주문 (CIDBT00300)
15510
+ - OverseasStockCancelOrderNode: 해외주식 취소주문 (COSAT00301, OrdPtnCode="08")
15511
+ - OverseasFuturesCancelOrderNode: 해외선물 취소주문 (CIDBT01000)
15395
15512
 
15396
15513
  입력:
15397
15514
  - original_order_id: 취소할 주문번호 (필수)
@@ -15402,11 +15519,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15402
15519
  """
15403
15520
 
15404
15521
  # 해외주식 시장 코드 매핑
15405
- STOCK_MARKET_CODES = {
15406
- "NYSE": "81",
15407
- "NASDAQ": "82",
15408
- "AMEX": "83",
15409
- }
15522
+ STOCK_MARKET_CODES = OVERSEAS_STOCK_MARKET_CODES
15410
15523
 
15411
15524
  async def execute(
15412
15525
  self,
@@ -15533,7 +15646,19 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15533
15646
  context: ExecutionContext,
15534
15647
  node_id: str,
15535
15648
  ) -> Dict[str, Any]:
15536
- """해외주식 취소주문 실행 (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
+ """
15537
15662
  from programgarden_finance.ls.overseas_stock.order.COSAT00301.blocks import COSAT00301InBlock1
15538
15663
 
15539
15664
 
@@ -15569,16 +15694,59 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15569
15694
  "cancelled_order": None,
15570
15695
  }
15571
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
+
15572
15738
  context.log(
15573
15739
  "info",
15574
- f"Order cancelled: {symbol} order_id={order_id}",
15740
+ f"Order cancelled: {symbol} order_id={order_id} cancel_order_no={cancel_ord_no}",
15575
15741
  node_id
15576
15742
  )
15577
-
15743
+
15578
15744
  return {
15579
15745
  "cancel_result": {
15580
15746
  "success": True,
15581
15747
  "order_id": order_id,
15748
+ # 취소는 그 자체로 새 주문번호를 받는다 — 원주문번호와 구분해 남긴다.
15749
+ "cancel_order_no": cancel_ord_no,
15582
15750
  "product": "overseas_stock",
15583
15751
  },
15584
15752
  "cancelled_order_id": order_id,
@@ -15650,16 +15818,46 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15650
15818
  "cancelled_order": None,
15651
15819
  }
15652
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
+
15653
15850
  context.log(
15654
15851
  "info",
15655
- 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}",
15656
15853
  node_id
15657
15854
  )
15658
-
15855
+
15659
15856
  return {
15660
15857
  "cancel_result": {
15661
15858
  "success": True,
15662
15859
  "order_id": order_id,
15860
+ "cancel_order_no": cancel_ord_no,
15663
15861
  "product": "overseas_futures",
15664
15862
  },
15665
15863
  "cancelled_order_id": order_id,
@@ -15684,6 +15882,43 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15684
15882
  "cancelled_order": None,
15685
15883
  }
15686
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
+
15687
15922
  async def _cancel_korea_stock(
15688
15923
  self,
15689
15924
  ls,
@@ -15696,12 +15931,37 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15696
15931
  """국내주식 취소주문 실행 (CSPAT00801)"""
15697
15932
  from programgarden_finance.ls.korea_stock.order.CSPAT00801.blocks import CSPAT00801InBlock1
15698
15933
 
15699
- # 취소 수량: 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 로 추측하지 않고** 실패한다.
15700
15942
  cancel_qty = config.get("quantity", 0) or config.get("cancel_quantity", 0)
15701
15943
  if not cancel_qty:
15702
- context.log("warning", f"cancel_quantity not specified, using original order remaining qty from order info", node_id)
15703
- # 미체결 잔량 정보가 없으면 기본 1 (API에서 잔량 초과 시 에러 반환)
15704
- 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
+ }
15705
15965
 
15706
15966
  try:
15707
15967
  body = CSPAT00801InBlock1(
@@ -15726,9 +15986,36 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15726
15986
  "cancelled_order": None,
15727
15987
  }
15728
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
+
15729
16016
  context.log(
15730
16017
  "info",
15731
- 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}",
15732
16019
  node_id
15733
16020
  )
15734
16021
 
@@ -15736,6 +16023,7 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
15736
16023
  "cancel_result": {
15737
16024
  "success": True,
15738
16025
  "order_id": order_id,
16026
+ "cancel_order_no": cancel_ord_no,
15739
16027
  "product": "korea_stock",
15740
16028
  },
15741
16029
  "cancelled_order_id": order_id,
@@ -19492,6 +19780,36 @@ class WorkflowJob:
19492
19780
  for port_name, value in outputs.items():
19493
19781
  self.context.set_output(node_id, port_name, value)
19494
19782
 
19783
+ # 분기 안 주문 실행 관측성 (2026-08-16): 분기 노드는 메인 루프가 건너뛰어
19784
+ # (⏭ Skipping branch node) node_state 이벤트가 전혀 방출되지 않았다 —
19785
+ # 실주문이 나가도 리스너(트레이 per-order durable 로그·SSE 화면)가 완료를
19786
+ # 못 봤다. 전 노드 방출은 실시간 분기의 틱 재구동에서 이벤트 홍수가 되므로
19787
+ # **주문 결과(order/modify/cancel_result)를 낸 실행에만** COMPLETED 를
19788
+ # 방출한다("제출할 주문 없음" 빈 결과는 제외 — 틱마다 재발화 방지).
19789
+ # 분기 전 노드의 일반 관측성은 SplitNode 시맨틱 개편에서 다룬다.
19790
+ _order_payload = None
19791
+ if isinstance(outputs, dict):
19792
+ for _k in ("order_result", "modify_result", "cancel_result"):
19793
+ _r = outputs.get(_k)
19794
+ if isinstance(_r, dict):
19795
+ _order_payload = _r
19796
+ break
19797
+ if _order_payload is not None and _order_payload.get("reason") not in (
19798
+ "no_signal", "fetch_failed", "no_symbol"
19799
+ ):
19800
+ try:
19801
+ await self.context.notify_node_state(
19802
+ node_id=node_id,
19803
+ node_type=node.node_type,
19804
+ state=NodeState.COMPLETED,
19805
+ outputs=outputs,
19806
+ )
19807
+ except Exception as _notify_err:
19808
+ # 관측 실패가 분기 실행(실주문 경로)을 막으면 안 된다.
19809
+ logger.warning(
19810
+ f"branch order node_state notify failed: {node_id}: {_notify_err}"
19811
+ )
19812
+
19495
19813
  if is_realtime:
19496
19814
  # 이 종목 구독 완료 — 다음 재구동부터는 이 노드를 건너뛴다
19497
19815
  subscribed.append(branch_scope)
@@ -20637,8 +20955,17 @@ class WorkflowJob:
20637
20955
  """
20638
20956
  if not self._is_order_idempotency_enabled():
20639
20957
  return
20640
- # 실패한 주문은 기록하지 않음 (재시도 가능해야 함)
20641
- if not order_result.get('success'):
20958
+ # 실패한 주문은 기록하지 않음 (재시도 가능해야 함).
20959
+ # ⚠️ order_result 는 노드 outputs 통짜 {"order_result": {...}, "order_id": ...}
20960
+ # (NewOrderNodeExecutor → context.record_order_submitted 경유). 예전 flat
20961
+ # {"success": ...} 가정은 중첩 shape 에서 항상 None 이라 성공 주문이 한 번도
20962
+ # 기록되지 않았다 — check 가 항상 미제출을 돌려줘 A-4 가드가 통째로 죽어
20963
+ # 복구 재실행 시 실중복 주문이 나갈 수 있었다(2026-08-16). flat 이 오면
20964
+ # 하위호환으로 그대로 본다.
20965
+ inner = order_result.get("order_result")
20966
+ if not isinstance(inner, dict):
20967
+ inner = order_result
20968
+ if not inner.get('success'):
20642
20969
  return
20643
20970
  try:
20644
20971
  key = self._build_order_idempotency_key(
@@ -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.0"
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