programgarden 1.29.2__tar.gz → 1.29.3__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.29.2 → programgarden-1.29.3}/PKG-INFO +1 -1
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/__init__.py +1 -1
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/executor.py +434 -8
- {programgarden-1.29.2 → programgarden-1.29.3}/pyproject.toml +1 -1
- {programgarden-1.29.2 → programgarden-1.29.3}/README.md +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/binding_validator.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/client.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/code_worker.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/context.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/database/__init__.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/node_runner.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/resolver.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/resource/context.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.3}/programgarden/validation_recommender.py +0 -0
|
@@ -77,6 +77,95 @@ REALTIME_NODE_TYPES: frozenset = frozenset({
|
|
|
77
77
|
})
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def _order_failure_from_outputs(outputs: Any) -> Optional[str]:
|
|
81
|
+
"""Return a human-readable reason if an order node *failed without raising*.
|
|
82
|
+
|
|
83
|
+
Order nodes never raise on failure — they return ``order_result.success=False``
|
|
84
|
+
with an :class:`EmptyOrderReason`. A node that returns normally is reported as
|
|
85
|
+
COMPLETED with ``errors_count=0`` and ``last_error=None``, so "the order never
|
|
86
|
+
went out" leaves **no trace on any observable surface**: the dashboard, the
|
|
87
|
+
monitoring page and the user all see a clean run. In automated trading that is
|
|
88
|
+
the single most dangerous failure mode.
|
|
89
|
+
|
|
90
|
+
``no_signal`` ("no trading signal today") is a legitimate no-op and is NOT a
|
|
91
|
+
failure. Every other reason (``fetch_failed``, ``no_symbol``, or an explicit
|
|
92
|
+
``error`` with no reason at all) means an order was supposed to go out and
|
|
93
|
+
didn't — surface it.
|
|
94
|
+
|
|
95
|
+
Returns None when there is nothing wrong.
|
|
96
|
+
"""
|
|
97
|
+
if not isinstance(outputs, dict):
|
|
98
|
+
return None
|
|
99
|
+
result = outputs.get("order_result")
|
|
100
|
+
if not isinstance(result, dict) or result.get("success") is not False:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
reason = result.get("reason")
|
|
104
|
+
if reason == EmptyOrderReason.NO_SIGNAL.value:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
parts = [
|
|
108
|
+
str(p) for p in (result.get("message"), result.get("detail"), result.get("error"))
|
|
109
|
+
if p
|
|
110
|
+
]
|
|
111
|
+
label = reason or "order_failed"
|
|
112
|
+
return f"{label}: {' — '.join(parts)}" if parts else str(label)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _orders_placed_from_outputs(outputs: Any) -> int:
|
|
116
|
+
"""Return how many orders this node successfully *submitted* (accepted).
|
|
117
|
+
|
|
118
|
+
DEF-26: the job's ``orders_placed`` stat was initialised to 0 and never
|
|
119
|
+
incremented, so the dashboard/monitoring always reported "0 orders placed"
|
|
120
|
+
even after a real fill. This derives the submitted count from the node's
|
|
121
|
+
``order_result``:
|
|
122
|
+
|
|
123
|
+
- single-order shape (``_order_result``): 1 when ``success is True`` (an
|
|
124
|
+
order number came back = accepted), else 0.
|
|
125
|
+
- batch shape (``success_count``): that count (defensive — currently unused
|
|
126
|
+
by the live single-order path).
|
|
127
|
+
- non-order nodes / failed orders / no_signal: 0.
|
|
128
|
+
|
|
129
|
+
"Submitted" means accepted by the broker (order number returned), NOT
|
|
130
|
+
filled — see :func:`_orders_filled_from_outputs` for fills (DEF-27).
|
|
131
|
+
"""
|
|
132
|
+
if not isinstance(outputs, dict):
|
|
133
|
+
return 0
|
|
134
|
+
result = outputs.get("order_result")
|
|
135
|
+
if not isinstance(result, dict):
|
|
136
|
+
return 0
|
|
137
|
+
if "success_count" in result:
|
|
138
|
+
try:
|
|
139
|
+
return max(0, int(result.get("success_count") or 0))
|
|
140
|
+
except (TypeError, ValueError):
|
|
141
|
+
return 0
|
|
142
|
+
return 1 if result.get("success") is True else 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _orders_filled_from_outputs(outputs: Any) -> int:
|
|
146
|
+
"""Return how many orders this node confirmed as *fully filled*.
|
|
147
|
+
|
|
148
|
+
DEF-26/DEF-27: ``orders_filled`` was a dead counter, and the engine
|
|
149
|
+
reported a bare "submitted" (order accepted) as if the order had traded.
|
|
150
|
+
An order only counts here when a post-submission re-query confirmed a
|
|
151
|
+
complete fill (``order_result.status == "filled"``, set by
|
|
152
|
+
:meth:`NewOrderNodeExecutor._confirm_order_fill`). ``submitted`` / ``open``
|
|
153
|
+
/ ``partially_filled`` deliberately do NOT count — an accepted-but-unfilled
|
|
154
|
+
order must never inflate the fill counter.
|
|
155
|
+
"""
|
|
156
|
+
if not isinstance(outputs, dict):
|
|
157
|
+
return 0
|
|
158
|
+
result = outputs.get("order_result")
|
|
159
|
+
if not isinstance(result, dict):
|
|
160
|
+
return 0
|
|
161
|
+
if "filled_count" in result:
|
|
162
|
+
try:
|
|
163
|
+
return max(0, int(result.get("filled_count") or 0))
|
|
164
|
+
except (TypeError, ValueError):
|
|
165
|
+
return 0
|
|
166
|
+
return 1 if (result.get("status") == "filled" and result.get("success") is True) else 0
|
|
167
|
+
|
|
168
|
+
|
|
80
169
|
def _free_root_names(text: str) -> Set[str]:
|
|
81
170
|
"""Return the set of free-variable root identifiers referenced (ctx=Load) by
|
|
82
171
|
every ``{{ ... }}`` expression embedded in ``text``.
|
|
@@ -13527,6 +13616,20 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
13527
13616
|
context.log("error", f"{node_type}: Unsupported product: {product}", node_id)
|
|
13528
13617
|
return self._error_result(f"Unsupported product: {product}")
|
|
13529
13618
|
|
|
13619
|
+
# === 5.4. DEF-27: 접수(order number) ≠ 체결. 주문 TR 응답은 주문번호만
|
|
13620
|
+
# 돌려주므로, 방금 접수된 주문의 체결 여부를 best-effort 로 재조회해
|
|
13621
|
+
# order_result.status 를 submitted → filled / partially_filled / open
|
|
13622
|
+
# 으로 승격하고 filled_quantity / filled_count 를 붙인다. 순수 관측용 —
|
|
13623
|
+
# 재조회가 실패해도 접수 성공(success=True)은 절대 뒤집지 않는다. ===
|
|
13624
|
+
if (
|
|
13625
|
+
isinstance(order_result, dict)
|
|
13626
|
+
and isinstance(order_result.get("order_result"), dict)
|
|
13627
|
+
and order_result["order_result"].get("success") is True
|
|
13628
|
+
):
|
|
13629
|
+
await self._confirm_order_fill(
|
|
13630
|
+
ls, product, order_type, order_result, config, context, node_id
|
|
13631
|
+
)
|
|
13632
|
+
|
|
13530
13633
|
# === 5.5. A-4: 성공 주문 idempotency 레지스트리에 기록 ===
|
|
13531
13634
|
context.record_order_submitted(
|
|
13532
13635
|
node_id=node_id,
|
|
@@ -14050,6 +14153,259 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
14050
14153
|
except Exception as e:
|
|
14051
14154
|
context.log("warning", f"Failed to sync fills after market order: {e}", node_id)
|
|
14052
14155
|
|
|
14156
|
+
async def _confirm_order_fill(
|
|
14157
|
+
self,
|
|
14158
|
+
ls,
|
|
14159
|
+
product: str,
|
|
14160
|
+
order_type: str,
|
|
14161
|
+
order_result: Dict[str, Any],
|
|
14162
|
+
config: Dict[str, Any],
|
|
14163
|
+
context: ExecutionContext,
|
|
14164
|
+
node_id: str,
|
|
14165
|
+
) -> None:
|
|
14166
|
+
"""Best-effort post-submission fill confirmation (DEF-27).
|
|
14167
|
+
|
|
14168
|
+
A broker order TR returns the order number only (= accepted), never a
|
|
14169
|
+
fill. This re-queries the order right after submission and upgrades
|
|
14170
|
+
``order_result['order_result']`` in place:
|
|
14171
|
+
|
|
14172
|
+
- ``status``: submitted → ``filled`` / ``partially_filled`` / ``open``
|
|
14173
|
+
- adds ``filled_quantity`` / ``fill_price`` / ``filled_count``
|
|
14174
|
+
|
|
14175
|
+
Purely additive observability: it never raises and never flips
|
|
14176
|
+
``success``. If the re-query errors or the order is not found, the
|
|
14177
|
+
order stays reported as accepted with ``status="open"`` (honest
|
|
14178
|
+
"accepted, not confirmed filled").
|
|
14179
|
+
|
|
14180
|
+
Fills are recorded a few seconds after the accept ack, so every order is
|
|
14181
|
+
polled over a short bounded window (fill_confirm_attempts ×
|
|
14182
|
+
fill_confirm_delay_seconds), breaking as soon as the fill lands; an order
|
|
14183
|
+
still unfilled at the end is reported "open". Per-product re-query TR:
|
|
14184
|
+
korea_stock=t0425, overseas_stock=COSAQ00102, overseas_futures/option=CIDBQ02400.
|
|
14185
|
+
"""
|
|
14186
|
+
try:
|
|
14187
|
+
inner = order_result.get("order_result", {})
|
|
14188
|
+
order_id = str(order_result.get("order_id") or inner.get("order_id") or "")
|
|
14189
|
+
if not order_id:
|
|
14190
|
+
return
|
|
14191
|
+
if product not in (
|
|
14192
|
+
"korea_stock", "overseas_stock", "overseas_futures", "overseas_futureoption",
|
|
14193
|
+
):
|
|
14194
|
+
return # unknown product: no fill re-query — leave as submitted
|
|
14195
|
+
|
|
14196
|
+
try:
|
|
14197
|
+
ordered_qty = int(inner.get("quantity") or 0)
|
|
14198
|
+
except (TypeError, ValueError):
|
|
14199
|
+
ordered_qty = 0
|
|
14200
|
+
|
|
14201
|
+
# A fill is recorded to the day's order/fill query a few seconds AFTER
|
|
14202
|
+
# the accept ack — even for a marketable order (live-observed on HKEX
|
|
14203
|
+
# futures paper: an immediate re-query still shows unfilled). So poll a
|
|
14204
|
+
# short bounded window for every order type, breaking as soon as the
|
|
14205
|
+
# fill lands. A genuinely resting limit order simply exhausts the window
|
|
14206
|
+
# and is reported "open" (honest). Both knobs are configurable; set
|
|
14207
|
+
# fill_confirm_attempts=1 to disable polling for latency-sensitive flows.
|
|
14208
|
+
try:
|
|
14209
|
+
attempts = int(config.get("fill_confirm_attempts", 4))
|
|
14210
|
+
except (TypeError, ValueError):
|
|
14211
|
+
attempts = 4
|
|
14212
|
+
try:
|
|
14213
|
+
delay = float(config.get("fill_confirm_delay_seconds", 2.0))
|
|
14214
|
+
except (TypeError, ValueError):
|
|
14215
|
+
delay = 2.0
|
|
14216
|
+
attempts = max(1, attempts)
|
|
14217
|
+
|
|
14218
|
+
symbol = inner.get("symbol", "")
|
|
14219
|
+
filled_qty, fill_price = 0, 0.0
|
|
14220
|
+
for i in range(attempts):
|
|
14221
|
+
# Query immediately first (catches an already-recorded fill at no
|
|
14222
|
+
# cost), then wait between retries for the lagged fill to land.
|
|
14223
|
+
if i > 0:
|
|
14224
|
+
await asyncio.sleep(delay)
|
|
14225
|
+
if product == "korea_stock":
|
|
14226
|
+
filled_qty, fill_price = await self._query_korea_fill(
|
|
14227
|
+
ls, order_id, symbol, context, node_id
|
|
14228
|
+
)
|
|
14229
|
+
elif product == "overseas_stock":
|
|
14230
|
+
filled_qty, fill_price = await self._query_overseas_stock_fill(
|
|
14231
|
+
ls, order_id, context, node_id
|
|
14232
|
+
)
|
|
14233
|
+
else: # overseas_futures / overseas_futureoption
|
|
14234
|
+
filled_qty, fill_price = await self._query_overseas_futures_fill(
|
|
14235
|
+
ls, order_id, context, node_id
|
|
14236
|
+
)
|
|
14237
|
+
if ordered_qty > 0 and filled_qty >= ordered_qty:
|
|
14238
|
+
break
|
|
14239
|
+
|
|
14240
|
+
if ordered_qty > 0 and filled_qty >= ordered_qty:
|
|
14241
|
+
inner["status"] = "filled"
|
|
14242
|
+
inner["filled_count"] = 1
|
|
14243
|
+
elif filled_qty > 0:
|
|
14244
|
+
inner["status"] = "partially_filled"
|
|
14245
|
+
inner["filled_count"] = 0
|
|
14246
|
+
else:
|
|
14247
|
+
inner["status"] = "open"
|
|
14248
|
+
inner["filled_count"] = 0
|
|
14249
|
+
inner["filled_quantity"] = filled_qty
|
|
14250
|
+
if fill_price > 0:
|
|
14251
|
+
inner["fill_price"] = fill_price
|
|
14252
|
+
|
|
14253
|
+
context.log(
|
|
14254
|
+
"info",
|
|
14255
|
+
f"Fill confirm: order {order_id} → {inner['status']} "
|
|
14256
|
+
f"({filled_qty}/{ordered_qty})",
|
|
14257
|
+
node_id,
|
|
14258
|
+
)
|
|
14259
|
+
except Exception as e:
|
|
14260
|
+
# Best-effort: a re-query failure must not disturb the (successful)
|
|
14261
|
+
# order submission. Leave order_result untouched (status stays
|
|
14262
|
+
# "submitted").
|
|
14263
|
+
context.log("debug", f"Fill confirmation skipped (best-effort): {e}", node_id)
|
|
14264
|
+
|
|
14265
|
+
async def _query_korea_fill(
|
|
14266
|
+
self,
|
|
14267
|
+
ls,
|
|
14268
|
+
order_id: str,
|
|
14269
|
+
symbol: str,
|
|
14270
|
+
context: ExecutionContext,
|
|
14271
|
+
node_id: str,
|
|
14272
|
+
) -> tuple:
|
|
14273
|
+
"""Return ``(filled_qty, avg_fill_price)`` for a korea_stock order via
|
|
14274
|
+
t0425 (당일 체결/미체결 조회). ``(0, 0.0)`` when not found or on error."""
|
|
14275
|
+
try:
|
|
14276
|
+
from programgarden_finance.ls.korea_stock.accno.t0425.blocks import T0425InBlock
|
|
14277
|
+
|
|
14278
|
+
# expcode 는 빈값(전체 종목)으로 둔다 — 주문번호(ordno)가 authoritative
|
|
14279
|
+
# 매칭 키이고, 심볼 포맷 차이('005930' vs 'A005930' 등)로 내 주문을
|
|
14280
|
+
# 놓치는 것을 피한다. symbol 인자는 로깅/시그니처 호환용으로만 유지.
|
|
14281
|
+
_ = symbol
|
|
14282
|
+
response = await ls.korea_stock().accno().t0425(
|
|
14283
|
+
body=T0425InBlock(
|
|
14284
|
+
expcode="", # 전체 종목 (ordno 로 매칭)
|
|
14285
|
+
chegb="0", # 0: 전체(체결+미체결) — 상태 판정에 필요
|
|
14286
|
+
medosu="0", # 0: 전체
|
|
14287
|
+
sortgb="1", # 1: 주문번호 역순(방금 낸 주문이 앞쪽)
|
|
14288
|
+
cts_ordno="",
|
|
14289
|
+
)
|
|
14290
|
+
).req_async()
|
|
14291
|
+
|
|
14292
|
+
if getattr(response, "error_msg", None):
|
|
14293
|
+
context.log("debug", f"t0425 fill query error: {response.error_msg}", node_id)
|
|
14294
|
+
return 0, 0.0
|
|
14295
|
+
|
|
14296
|
+
for item in getattr(response, "block", None) or []:
|
|
14297
|
+
if str(getattr(item, "ordno", "")) == order_id:
|
|
14298
|
+
filled_qty = int(getattr(item, "cheqty", 0) or 0)
|
|
14299
|
+
fill_price = float(getattr(item, "cheprice", 0) or 0)
|
|
14300
|
+
return filled_qty, fill_price
|
|
14301
|
+
return 0, 0.0
|
|
14302
|
+
except Exception as e:
|
|
14303
|
+
context.log("debug", f"t0425 fill query exception: {e}", node_id)
|
|
14304
|
+
return 0, 0.0
|
|
14305
|
+
|
|
14306
|
+
async def _query_overseas_stock_fill(
|
|
14307
|
+
self,
|
|
14308
|
+
ls,
|
|
14309
|
+
order_id: str,
|
|
14310
|
+
context: ExecutionContext,
|
|
14311
|
+
node_id: str,
|
|
14312
|
+
) -> tuple:
|
|
14313
|
+
"""Return ``(filled_qty, avg_fill_price)`` for an overseas_stock order
|
|
14314
|
+
via COSAQ00102 (주문체결내역조회, 체결분만). ``(0, 0.0)`` when not found
|
|
14315
|
+
or on error."""
|
|
14316
|
+
try:
|
|
14317
|
+
from programgarden_finance import COSAQ00102
|
|
14318
|
+
from datetime import datetime as _dt
|
|
14319
|
+
|
|
14320
|
+
today = _dt.now().strftime("%Y%m%d")
|
|
14321
|
+
response = ls.overseas_stock().accno().cosaq00102(
|
|
14322
|
+
body=COSAQ00102.COSAQ00102InBlock1(
|
|
14323
|
+
RecCnt=1,
|
|
14324
|
+
QryTpCode="1", # 계좌별
|
|
14325
|
+
BkseqTpCode="1", # 역순
|
|
14326
|
+
OrdMktCode="00", # 전체 시장
|
|
14327
|
+
BnsTpCode="0", # 전체(매수/매도)
|
|
14328
|
+
IsuNo="", # 전체 종목
|
|
14329
|
+
SrtOrdNo=999999999, # 역순 시작
|
|
14330
|
+
OrdDt=today,
|
|
14331
|
+
ExecYn="1", # 1: 체결만
|
|
14332
|
+
CrcyCode="000", # 전체 통화
|
|
14333
|
+
ThdayBnsAppYn="0",
|
|
14334
|
+
LoanBalHldYn="0",
|
|
14335
|
+
),
|
|
14336
|
+
)
|
|
14337
|
+
result = await response.req_async()
|
|
14338
|
+
if not result or not getattr(result, "block3", None):
|
|
14339
|
+
return 0, 0.0
|
|
14340
|
+
|
|
14341
|
+
filled_qty = 0
|
|
14342
|
+
amount = 0.0
|
|
14343
|
+
for item in result.block3:
|
|
14344
|
+
if str(getattr(item, "OrdNo", 0)) != order_id:
|
|
14345
|
+
continue
|
|
14346
|
+
q = int(getattr(item, "ExecQty", 0) or 0)
|
|
14347
|
+
p = float(
|
|
14348
|
+
getattr(item, "OvrsExecPrc", 0) or getattr(item, "OvrsOrdPrc", 0) or 0
|
|
14349
|
+
)
|
|
14350
|
+
filled_qty += q
|
|
14351
|
+
amount += q * p
|
|
14352
|
+
avg_price = (amount / filled_qty) if filled_qty > 0 else 0.0
|
|
14353
|
+
return filled_qty, avg_price
|
|
14354
|
+
except Exception as e:
|
|
14355
|
+
context.log("debug", f"COSAQ00102 fill query exception: {e}", node_id)
|
|
14356
|
+
return 0, 0.0
|
|
14357
|
+
|
|
14358
|
+
async def _query_overseas_futures_fill(
|
|
14359
|
+
self,
|
|
14360
|
+
ls,
|
|
14361
|
+
order_id: str,
|
|
14362
|
+
context: ExecutionContext,
|
|
14363
|
+
node_id: str,
|
|
14364
|
+
) -> tuple:
|
|
14365
|
+
"""Return ``(filled_qty, avg_fill_price)`` for an overseas_futures/option
|
|
14366
|
+
order via CIDBQ02400 (당일 주문체결내역, 전체). ``(0, 0.0)`` when not
|
|
14367
|
+
found or on error. Also covers HKEX-futures paper accounts."""
|
|
14368
|
+
try:
|
|
14369
|
+
from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ02400.blocks import (
|
|
14370
|
+
CIDBQ02400InBlock1,
|
|
14371
|
+
)
|
|
14372
|
+
from datetime import datetime as _dt
|
|
14373
|
+
|
|
14374
|
+
today = _dt.now().strftime("%Y%m%d")
|
|
14375
|
+
response = await ls.overseas_futureoption().accno().CIDBQ02400(
|
|
14376
|
+
body=CIDBQ02400InBlock1(
|
|
14377
|
+
IsuCodeVal="", # 전체 종목
|
|
14378
|
+
QrySrtDt=today,
|
|
14379
|
+
QryEndDt=today,
|
|
14380
|
+
ThdayTpCode="1", # 1: 당일조회
|
|
14381
|
+
OrdStatCode="0", # 0: 전체(체결+미체결) — 상태 판정에 필요
|
|
14382
|
+
BnsTpCode="0", # 0: 전체
|
|
14383
|
+
QryTpCode="1", # 1: 역순(방금 낸 주문이 앞쪽)
|
|
14384
|
+
OrdPtnCode="00", # 00: 전체
|
|
14385
|
+
OvrsDrvtFnoTpCode="A", # A: 전체(선물+옵션)
|
|
14386
|
+
)
|
|
14387
|
+
).req_async()
|
|
14388
|
+
|
|
14389
|
+
if getattr(response, "error_msg", None):
|
|
14390
|
+
context.log("debug", f"CIDBQ02400 fill query error: {response.error_msg}", node_id)
|
|
14391
|
+
return 0, 0.0
|
|
14392
|
+
|
|
14393
|
+
# block2 = 주문/체결 상세 (한 주문에 다중 체결이면 행이 여럿 → ExecQty 합산)
|
|
14394
|
+
filled_qty = 0
|
|
14395
|
+
amount = 0.0
|
|
14396
|
+
for item in getattr(response, "block2", None) or []:
|
|
14397
|
+
if str(getattr(item, "OvrsFutsOrdNo", "")) != order_id:
|
|
14398
|
+
continue
|
|
14399
|
+
q = int(getattr(item, "ExecQty", 0) or 0)
|
|
14400
|
+
p = float(getattr(item, "AbrdFutsExecPrc", 0) or 0)
|
|
14401
|
+
filled_qty += q
|
|
14402
|
+
amount += q * p
|
|
14403
|
+
avg_price = (amount / filled_qty) if filled_qty > 0 else 0.0
|
|
14404
|
+
return filled_qty, avg_price
|
|
14405
|
+
except Exception as e:
|
|
14406
|
+
context.log("debug", f"CIDBQ02400 fill query exception: {e}", node_id)
|
|
14407
|
+
return 0, 0.0
|
|
14408
|
+
|
|
14053
14409
|
async def _execute_overseas_futures(
|
|
14054
14410
|
self,
|
|
14055
14411
|
ls,
|
|
@@ -18225,6 +18581,53 @@ class WorkflowJob:
|
|
|
18225
18581
|
},
|
|
18226
18582
|
)
|
|
18227
18583
|
|
|
18584
|
+
# An order node that failed did NOT raise — it returned
|
|
18585
|
+
# order_result.success=False. Reporting that as COMPLETED leaves
|
|
18586
|
+
# errors_count=0 / last_error=None, i.e. every observable surface
|
|
18587
|
+
# says the run succeeded while no order actually went out. Surface
|
|
18588
|
+
# it: FAILED node state + job stats + an error log line. no_signal
|
|
18589
|
+
# ("no trading signal today") is a normal no-op and is excluded.
|
|
18590
|
+
order_failure = _order_failure_from_outputs(outputs)
|
|
18591
|
+
|
|
18592
|
+
# DEF-26/DEF-27: wire the previously-dead orders_placed /
|
|
18593
|
+
# orders_filled counters. placed = orders this node actually
|
|
18594
|
+
# submitted (accepted by the broker); filled = orders a
|
|
18595
|
+
# post-submit re-query confirmed as fully traded. Both are 0 for
|
|
18596
|
+
# non-order nodes and for failed orders, so this is safe to run
|
|
18597
|
+
# unconditionally for every node.
|
|
18598
|
+
self.stats["orders_placed"] += _orders_placed_from_outputs(outputs)
|
|
18599
|
+
self.stats["orders_filled"] += _orders_filled_from_outputs(outputs)
|
|
18600
|
+
|
|
18601
|
+
# deep_validate: promote it to a blocking structured error so the
|
|
18602
|
+
# chatbot learns its order wiring is broken at *build* time instead
|
|
18603
|
+
# of shipping a workflow whose order silently never fires.
|
|
18604
|
+
if (
|
|
18605
|
+
order_failure
|
|
18606
|
+
and getattr(self.context, "is_deep_validate", False)
|
|
18607
|
+
and node_id not in self._node_error_infos
|
|
18608
|
+
):
|
|
18609
|
+
from programgarden_core import (
|
|
18610
|
+
ErrorCode,
|
|
18611
|
+
ErrorLocation,
|
|
18612
|
+
build_error,
|
|
18613
|
+
)
|
|
18614
|
+
self._node_error_infos[node_id] = build_error(
|
|
18615
|
+
ErrorCode.DEEP_VALIDATION_NODE_ERROR,
|
|
18616
|
+
f"Node '{node_id}' ({node.node_type}) placed no order: "
|
|
18617
|
+
f"{order_failure}",
|
|
18618
|
+
location=ErrorLocation(node_id=node_id, node_type=node.node_type),
|
|
18619
|
+
suggestion=(
|
|
18620
|
+
"주문 노드가 주문을 내지 못했습니다. 상류 노드의 출력이 이 노드의 "
|
|
18621
|
+
"주문 입력(order/orders)에 실제로 연결되어 있는지, 표현식이 "
|
|
18622
|
+
"해소되는지, 수량·종목이 채워지는지 확인하세요."
|
|
18623
|
+
),
|
|
18624
|
+
details={
|
|
18625
|
+
"raw_message": order_failure,
|
|
18626
|
+
"deep_validate": True,
|
|
18627
|
+
"stage": "order_not_placed",
|
|
18628
|
+
},
|
|
18629
|
+
)
|
|
18630
|
+
|
|
18228
18631
|
# Store outputs
|
|
18229
18632
|
for out_port_name, value in outputs.items():
|
|
18230
18633
|
self.context.set_output(node_id, out_port_name, value)
|
|
@@ -18233,16 +18636,39 @@ class WorkflowJob:
|
|
|
18233
18636
|
# stay_connected 노드는 RUNNING 상태 유지 (계속 실시간 업데이트)
|
|
18234
18637
|
duration_ms = (datetime.utcnow() - start_time).total_seconds() * 1000
|
|
18235
18638
|
is_stay_connected = node_id in self._stay_connected_nodes
|
|
18236
|
-
|
|
18237
|
-
|
|
18238
|
-
|
|
18239
|
-
|
|
18240
|
-
|
|
18241
|
-
|
|
18242
|
-
|
|
18243
|
-
|
|
18639
|
+
|
|
18640
|
+
if order_failure:
|
|
18641
|
+
await self.context.notify_node_state(
|
|
18642
|
+
node_id=node_id,
|
|
18643
|
+
node_type=node.node_type,
|
|
18644
|
+
state=NodeState.FAILED,
|
|
18645
|
+
outputs=outputs,
|
|
18646
|
+
error=order_failure,
|
|
18647
|
+
duration_ms=duration_ms,
|
|
18648
|
+
)
|
|
18649
|
+
self.stats["errors_count"] += 1
|
|
18650
|
+
self.stats["last_error"] = f"{node_id}: {order_failure}"
|
|
18651
|
+
self.stats["last_error_detail"] = {
|
|
18652
|
+
"node_id": node_id,
|
|
18653
|
+
"node_type": node.node_type,
|
|
18654
|
+
"error": order_failure,
|
|
18655
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
18656
|
+
}
|
|
18657
|
+
self.context.log(
|
|
18658
|
+
"error", f"Order not placed by {node_id}: {order_failure}", node_id
|
|
18659
|
+
)
|
|
18660
|
+
else:
|
|
18661
|
+
await self.context.notify_node_state(
|
|
18662
|
+
node_id=node_id,
|
|
18663
|
+
node_type=node.node_type,
|
|
18664
|
+
state=NodeState.RUNNING if is_stay_connected else NodeState.COMPLETED,
|
|
18665
|
+
outputs=outputs,
|
|
18666
|
+
duration_ms=duration_ms,
|
|
18667
|
+
)
|
|
18244
18668
|
|
|
18245
18669
|
# Checkpoint: 노드 완료 추적 + 일회성 워크플로우 노드 완료마다 저장
|
|
18670
|
+
# (실패해도 흐름은 계속한다 — 스케줄 잡이 한 사이클의 주문 실패로
|
|
18671
|
+
# 통째로 죽지 않도록. 관측 표면에는 위에서 이미 실패로 남았다.)
|
|
18246
18672
|
self._completed_node_ids.add(node_id)
|
|
18247
18673
|
if not self._stay_connected_nodes:
|
|
18248
18674
|
await self._save_checkpoint()
|
|
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.29.2 → programgarden-1.29.3}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.29.2 → programgarden-1.29.3}/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
|
|
File without changes
|