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.
- {programgarden-1.31.1 → programgarden-1.31.2}/PKG-INFO +3 -3
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/deep_fixtures.py +45 -10
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/executor.py +381 -104
- {programgarden-1.31.1 → programgarden-1.31.2}/pyproject.toml +3 -3
- {programgarden-1.31.1 → programgarden-1.31.2}/README.md +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/__init__.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/binding_validator.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/client.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/code_worker.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/context.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/database/__init__.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/node_runner.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resolver.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resource/context.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.31.1 → programgarden-1.31.2}/programgarden/tools/sqlite_tools.py +0 -0
- {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.
|
|
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}"
|
|
@@ -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
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
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
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
13533
|
-
|
|
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
|
-
#
|
|
14071
|
-
#
|
|
14072
|
-
|
|
14073
|
-
|
|
14074
|
-
|
|
14075
|
-
|
|
14076
|
-
|
|
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
|
-
#
|
|
14620
|
-
#
|
|
14621
|
-
|
|
14622
|
-
|
|
14623
|
-
|
|
14624
|
-
|
|
14625
|
-
|
|
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
|
-
|
|
14759
|
-
|
|
14760
|
-
|
|
14761
|
-
|
|
14762
|
-
|
|
14763
|
-
|
|
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: 해외선물 정정주문 (
|
|
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: 해외주식 취소주문 (
|
|
15405
|
-
- OverseasFuturesCancelOrderNode: 해외선물 취소주문 (
|
|
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
|
-
# 취소
|
|
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
|
-
|
|
15714
|
-
|
|
15715
|
-
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
|
+
}
|
|
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.
|
|
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.1 → programgarden-1.31.2}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.31.1 → 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
|