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.
- {programgarden-1.31.0 → programgarden-1.31.2}/PKG-INFO +3 -3
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/deep_fixtures.py +45 -10
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/executor.py +435 -108
- {programgarden-1.31.0 → programgarden-1.31.2}/pyproject.toml +3 -3
- {programgarden-1.31.0 → programgarden-1.31.2}/README.md +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/__init__.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/binding_validator.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/client.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/code_worker.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/context.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/database/__init__.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/node_runner.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resolver.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resource/context.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.31.0 → programgarden-1.31.2}/programgarden/tools/sqlite_tools.py +0 -0
- {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.
|
|
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.
|
|
20
|
-
Requires-Dist: programgarden-finance (>=1.
|
|
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(
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
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
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
13532
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
14060
|
-
#
|
|
14061
|
-
|
|
14062
|
-
|
|
14063
|
-
|
|
14064
|
-
|
|
14065
|
-
|
|
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
|
-
#
|
|
14609
|
-
#
|
|
14610
|
-
|
|
14611
|
-
|
|
14612
|
-
|
|
14613
|
-
|
|
14614
|
-
|
|
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
|
-
|
|
14748
|
-
|
|
14749
|
-
|
|
14750
|
-
|
|
14751
|
-
|
|
14752
|
-
|
|
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: 해외선물 정정주문 (
|
|
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: 해외주식 취소주문 (
|
|
15394
|
-
- OverseasFuturesCancelOrderNode: 해외선물 취소주문 (
|
|
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
|
-
# 취소
|
|
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
|
-
|
|
15703
|
-
|
|
15704
|
-
cancel_qty =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
33
|
-
programgarden-finance = "^1.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-1.31.0 → programgarden-1.31.2}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.31.0 → programgarden-1.31.2}/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
|