programgarden 1.35.0__tar.gz → 1.36.0__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.35.0 → programgarden-1.36.0}/PKG-INFO +2 -2
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/context.py +74 -1
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/database/workflow_position_tracker.py +136 -31
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/executor.py +55 -2
- {programgarden-1.35.0 → programgarden-1.36.0}/pyproject.toml +9 -6
- {programgarden-1.35.0 → programgarden-1.36.0}/README.md +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/__init__.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/binding_validator.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/client.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/code_worker.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/database/__init__.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/futures_pnl.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/node_runner.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/order_lifecycle.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resolver.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resource/context.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/validation_recommender.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: programgarden
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.36.0
|
|
4
4
|
Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
|
|
5
5
|
License-Expression: AGPL-3.0-or-later
|
|
6
6
|
Author: 프로그램동산
|
|
@@ -16,7 +16,7 @@ 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.
|
|
19
|
+
Requires-Dist: programgarden-core (>=1.28.0,<2.0.0)
|
|
20
20
|
Requires-Dist: programgarden-finance (>=1.9.5,<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)
|
|
@@ -11,7 +11,7 @@ Workflow execution context protocol
|
|
|
11
11
|
|
|
12
12
|
from typing import Optional, Dict, Any, List, Protocol, runtime_checkable, Callable, Awaitable, TYPE_CHECKING, Tuple
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
|
-
from datetime import datetime
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
15
|
from enum import Enum
|
|
16
16
|
from collections import deque
|
|
17
17
|
import asyncio
|
|
@@ -38,6 +38,7 @@ from programgarden_core.bases.listener import (
|
|
|
38
38
|
LLMStreamEvent,
|
|
39
39
|
TokenUsageEvent,
|
|
40
40
|
AIToolCallEvent,
|
|
41
|
+
OrderFillEvent,
|
|
41
42
|
)
|
|
42
43
|
from programgarden_core.models.resilience import RetryEvent
|
|
43
44
|
|
|
@@ -2413,6 +2414,12 @@ class ExecutionContext:
|
|
|
2413
2414
|
# 거래 모드 메타데이터 기록 (데이터 초기화 없이 모드만 업데이트)
|
|
2414
2415
|
self._workflow_position_tracker.update_trading_mode(trading_mode)
|
|
2415
2416
|
|
|
2417
|
+
# 체결 확정 → on_order_fill 리스너 연결. 즉시/버퍼/타임아웃 경로가
|
|
2418
|
+
# 모두 원장의 단일 지점을 거치므로 여기서 한 번만 연결한다.
|
|
2419
|
+
self._workflow_position_tracker.set_fill_classified_callback(
|
|
2420
|
+
self._on_tracker_fill_classified
|
|
2421
|
+
)
|
|
2422
|
+
|
|
2416
2423
|
# PnL refresh용 정보 저장
|
|
2417
2424
|
self._workflow_broker_node_id = broker_node_id
|
|
2418
2425
|
self._workflow_product = product
|
|
@@ -2683,6 +2690,72 @@ class ExecutionContext:
|
|
|
2683
2690
|
logger.warning(f"Failed to record workflow fill: {e}")
|
|
2684
2691
|
return "error"
|
|
2685
2692
|
|
|
2693
|
+
async def _on_tracker_fill_classified(self, fill, classification: str) -> None:
|
|
2694
|
+
"""Bridge a newly recorded ledger fill to on_order_fill listeners.
|
|
2695
|
+
|
|
2696
|
+
The tracker invokes this exactly once per new fill — for immediate,
|
|
2697
|
+
buffered, and timeout-resolved classifications alike — so notification
|
|
2698
|
+
happens whether ``record_workflow_fill`` returned a final classification
|
|
2699
|
+
or "pending" that only settled after the arrival-buffering timeout.
|
|
2700
|
+
Facts only: no monetary computation. Manual/unknown_api/other fills have
|
|
2701
|
+
no matching workflow order, so node_id stays None.
|
|
2702
|
+
"""
|
|
2703
|
+
tracker = self._workflow_position_tracker
|
|
2704
|
+
if tracker is None:
|
|
2705
|
+
return
|
|
2706
|
+
|
|
2707
|
+
job_id = self.job_id
|
|
2708
|
+
node_id = None
|
|
2709
|
+
try:
|
|
2710
|
+
identity = tracker.lookup_order_identity(fill.order_no, fill.order_date)
|
|
2711
|
+
if identity is not None:
|
|
2712
|
+
row_job_id, node_id = identity
|
|
2713
|
+
if row_job_id:
|
|
2714
|
+
job_id = row_job_id
|
|
2715
|
+
except Exception as e:
|
|
2716
|
+
logger.warning(f"Failed to resolve order identity for fill: {e}")
|
|
2717
|
+
|
|
2718
|
+
received_at = getattr(fill, "received_at", None)
|
|
2719
|
+
received_iso = (
|
|
2720
|
+
received_at.isoformat() if hasattr(received_at, "isoformat")
|
|
2721
|
+
else datetime.now(timezone.utc).isoformat()
|
|
2722
|
+
)
|
|
2723
|
+
|
|
2724
|
+
event = OrderFillEvent(
|
|
2725
|
+
job_id=job_id,
|
|
2726
|
+
node_id=node_id,
|
|
2727
|
+
order_no=fill.order_no,
|
|
2728
|
+
order_date=fill.order_date,
|
|
2729
|
+
execution_id=fill.execution_id,
|
|
2730
|
+
symbol=fill.symbol,
|
|
2731
|
+
exchange=fill.exchange,
|
|
2732
|
+
side=fill.side,
|
|
2733
|
+
quantity=fill.quantity,
|
|
2734
|
+
price=fill.price,
|
|
2735
|
+
fill_time=fill.fill_time,
|
|
2736
|
+
product=tracker.product,
|
|
2737
|
+
provider=tracker.provider,
|
|
2738
|
+
classification=classification,
|
|
2739
|
+
commda_code=fill.commda_code,
|
|
2740
|
+
trading_mode=tracker.trading_mode,
|
|
2741
|
+
received_at=received_iso,
|
|
2742
|
+
)
|
|
2743
|
+
await self.notify_order_fill(event)
|
|
2744
|
+
|
|
2745
|
+
async def notify_order_fill(self, event: OrderFillEvent) -> None:
|
|
2746
|
+
"""Notify all listeners about a confirmed order fill (facts only)."""
|
|
2747
|
+
if not self._listeners:
|
|
2748
|
+
return
|
|
2749
|
+
|
|
2750
|
+
for listener in self._listeners:
|
|
2751
|
+
try:
|
|
2752
|
+
await listener.on_order_fill(event)
|
|
2753
|
+
except Exception as e:
|
|
2754
|
+
logger.warning(f"Listener error on_order_fill: {e}")
|
|
2755
|
+
|
|
2756
|
+
# SSE 스트림 전송을 위해 이벤트 루프에 제어권 양보
|
|
2757
|
+
await asyncio.sleep(0)
|
|
2758
|
+
|
|
2686
2759
|
async def cancel_workflow_order(
|
|
2687
2760
|
self,
|
|
2688
2761
|
order_no: str,
|
{programgarden-1.35.0 → programgarden-1.36.0}/programgarden/database/workflow_position_tracker.py
RENAMED
|
@@ -19,7 +19,7 @@ import re
|
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
20
|
from datetime import datetime, timezone
|
|
21
21
|
from decimal import Decimal, ROUND_HALF_UP
|
|
22
|
-
from typing import Dict, List, Optional, Any, Tuple
|
|
22
|
+
from typing import Dict, List, Optional, Any, Tuple, Callable, Awaitable
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
|
|
25
25
|
logger = logging.getLogger(__name__)
|
|
@@ -184,8 +184,69 @@ class WorkflowPositionTracker:
|
|
|
184
184
|
# 역산 불가한 틱에서는 이 캐시(최근 성공값)로 폴백해 FIFO 산술을 스케일한다.
|
|
185
185
|
self._multiplier_cache: Dict[str, Decimal] = {}
|
|
186
186
|
|
|
187
|
+
# 체결 확정 콜백. 원장에 **새로** 기록된 체결마다 정확히 1회 발화한다 —
|
|
188
|
+
# 즉시/버퍼/타임아웃 경로가 모두 _process_fill_internal 을 거치므로 한 곳에서
|
|
189
|
+
# 부른다. 리플레이(동일 execution identity 재도착)는 그 앞에서 조기 반환하므로
|
|
190
|
+
# 재발화하지 않는다. 컨텍스트가 이걸 on_order_fill 리스너로 연결한다.
|
|
191
|
+
self._on_fill_classified: Optional[
|
|
192
|
+
Callable[["PendingFill", str], Awaitable[None]]
|
|
193
|
+
] = None
|
|
194
|
+
|
|
187
195
|
# DB 초기화
|
|
188
196
|
self._init_db()
|
|
197
|
+
|
|
198
|
+
def set_fill_classified_callback(
|
|
199
|
+
self,
|
|
200
|
+
callback: Optional[Callable[["PendingFill", str], Awaitable[None]]],
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Register an async callback fired once per newly recorded fill.
|
|
203
|
+
|
|
204
|
+
The callback receives ``(fill: PendingFill, classification: str)`` where
|
|
205
|
+
classification is one of "workflow" | "manual" | "unknown_api" | "other".
|
|
206
|
+
It must not mutate ledger state; exceptions raised by it are caught and
|
|
207
|
+
logged so they never break FIFO recording.
|
|
208
|
+
"""
|
|
209
|
+
self._on_fill_classified = callback
|
|
210
|
+
|
|
211
|
+
def lookup_order_identity(
|
|
212
|
+
self,
|
|
213
|
+
order_no: str,
|
|
214
|
+
order_date: str,
|
|
215
|
+
) -> Optional[Tuple[Optional[str], Optional[str]]]:
|
|
216
|
+
"""Return ``(job_id, node_id)`` for a recorded workflow order, else None.
|
|
217
|
+
|
|
218
|
+
Scoped to this ledger's product/provider/trading mode and the order date.
|
|
219
|
+
Matches the exact order number first, then the normalized form (so
|
|
220
|
+
zero-padding differences between the order ACK and the fill still map).
|
|
221
|
+
A fill that is not one of our workflow orders returns None (node_id then
|
|
222
|
+
stays None on the emitted event).
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
norm = self._normalize_identifier(order_no)
|
|
226
|
+
except ValueError:
|
|
227
|
+
norm = None
|
|
228
|
+
try:
|
|
229
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
230
|
+
rows = conn.execute(
|
|
231
|
+
"""
|
|
232
|
+
SELECT order_no, job_id, node_id FROM workflow_orders
|
|
233
|
+
WHERE product = ? AND provider = ? AND trading_mode = ? AND order_date = ?
|
|
234
|
+
""",
|
|
235
|
+
(self.product, self.provider, self.trading_mode, order_date),
|
|
236
|
+
).fetchall()
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.warning(f"lookup_order_identity failed: {e}")
|
|
239
|
+
return None
|
|
240
|
+
for row_order_no, job_id, node_id in rows:
|
|
241
|
+
if row_order_no == order_no:
|
|
242
|
+
return (job_id, node_id)
|
|
243
|
+
if norm is not None:
|
|
244
|
+
try:
|
|
245
|
+
if self._normalize_identifier(row_order_no) == norm:
|
|
246
|
+
return (job_id, node_id)
|
|
247
|
+
except ValueError:
|
|
248
|
+
continue
|
|
249
|
+
return None
|
|
189
250
|
|
|
190
251
|
def _init_db(self) -> None:
|
|
191
252
|
"""데이터베이스 테이블 초기화"""
|
|
@@ -410,6 +471,7 @@ class WorkflowPositionTracker:
|
|
|
410
471
|
|
|
411
472
|
async def _process_buffered_fill(self, order_no: str, order_date: str) -> None:
|
|
412
473
|
"""Process all buffered partial fills after recording their order."""
|
|
474
|
+
notifications: list = []
|
|
413
475
|
async with self._buffer_lock:
|
|
414
476
|
for key, fill in list(self._pending_fills.items()):
|
|
415
477
|
if fill.order_date != order_date:
|
|
@@ -418,8 +480,9 @@ class WorkflowPositionTracker:
|
|
|
418
480
|
if self._normalize_identifier(fill.execution_id) is not None:
|
|
419
481
|
matches = self._normalize_identifier(fill.order_no) == self._normalize_identifier(order_no)
|
|
420
482
|
if matches:
|
|
421
|
-
await self._process_fill_internal(fill, "workflow")
|
|
483
|
+
await self._process_fill_internal(fill, "workflow", notifications)
|
|
422
484
|
self._pending_fills.pop(key)
|
|
485
|
+
await self._emit_fill_notifications(notifications)
|
|
423
486
|
|
|
424
487
|
async def record_fill(
|
|
425
488
|
self,
|
|
@@ -480,6 +543,8 @@ class WorkflowPositionTracker:
|
|
|
480
543
|
execution_id=execution_id, account_avg_price=account_avg_price,
|
|
481
544
|
)
|
|
482
545
|
identity = self._execution_key(fill)
|
|
546
|
+
notifications: list = []
|
|
547
|
+
result: Optional[str] = None
|
|
483
548
|
async with self._buffer_lock:
|
|
484
549
|
if identity is not None:
|
|
485
550
|
for pending in self._pending_fills.values():
|
|
@@ -498,31 +563,36 @@ class WorkflowPositionTracker:
|
|
|
498
563
|
# of a hardcoded '40', our own OPEN-API fills may not carry '40'; the
|
|
499
564
|
# old media-first ordering would then misfile them as manual.)
|
|
500
565
|
if self._is_workflow_fill(fill):
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
566
|
+
result = await self._process_fill_internal(fill, "workflow", notifications)
|
|
567
|
+
else:
|
|
568
|
+
# No matching order. Classify by the published media table
|
|
569
|
+
# (MEDIA_CODES_* above) — never by "not 40":
|
|
570
|
+
# human (85 HTS / 22 iPhone / 23 Android / 00 branch) → "manual"
|
|
571
|
+
# other (96/LP/SK/SO or unlisted) → "other"
|
|
572
|
+
# api (40/41/43) or blank → buffer below
|
|
573
|
+
channel = media_channel(commda_code)
|
|
574
|
+
if channel == "human":
|
|
575
|
+
result = await self._process_fill_internal(fill, "manual", notifications)
|
|
576
|
+
elif channel == "other":
|
|
577
|
+
# A code the table does not attribute — more of these exist than
|
|
578
|
+
# we have measured (only 40/51/85/03 are live-observed so far).
|
|
579
|
+
# Log it so the table can grow from evidence, never from guesses.
|
|
580
|
+
logger.info("Unlisted media code %r on fill %s/%s — classified other",
|
|
581
|
+
commda_code, order_date, order_no)
|
|
582
|
+
result = await self._process_fill_internal(fill, "other", notifications)
|
|
583
|
+
else:
|
|
584
|
+
# An API code or an empty media code ("medium unknown"): buffer to
|
|
585
|
+
# give a lagging order record a chance to arrive, then
|
|
586
|
+
# _process_timeout_fill files it as workflow (matched) or unknown_api
|
|
587
|
+
# (unmatched). An unknown medium is never asserted to be a person's
|
|
588
|
+
# HTS trade, so it floors to unknown_api rather than manual.
|
|
589
|
+
self._next_pending_fill += 1
|
|
590
|
+
key = self._next_pending_fill
|
|
591
|
+
self._pending_fills[key] = fill
|
|
592
|
+
# Emit any confirmed-fill notifications now that _buffer_lock is released.
|
|
593
|
+
if result is not None:
|
|
594
|
+
await self._emit_fill_notifications(notifications)
|
|
595
|
+
return result
|
|
526
596
|
asyncio.create_task(self._process_timeout_fill(key))
|
|
527
597
|
logger.debug(f"Buffered fill (waiting for order): {key}")
|
|
528
598
|
return "pending"
|
|
@@ -530,14 +600,16 @@ class WorkflowPositionTracker:
|
|
|
530
600
|
async def _process_timeout_fill(self, key: int) -> None:
|
|
531
601
|
"""Expire one arrival, without consuming a later partial's timeout."""
|
|
532
602
|
await asyncio.sleep(self.FILL_BUFFER_TIMEOUT)
|
|
603
|
+
notifications: list = []
|
|
533
604
|
async with self._buffer_lock:
|
|
534
605
|
if key in self._pending_fills:
|
|
535
606
|
fill = self._pending_fills[key]
|
|
536
607
|
classification = "workflow" if self._is_workflow_fill(fill) else "unknown_api"
|
|
537
608
|
if classification == "unknown_api":
|
|
538
609
|
logger.warning("Fill expired before its order was recorded; classifying as unknown_api")
|
|
539
|
-
await self._process_fill_internal(fill, classification)
|
|
610
|
+
await self._process_fill_internal(fill, classification, notifications)
|
|
540
611
|
self._pending_fills.pop(key)
|
|
612
|
+
await self._emit_fill_notifications(notifications)
|
|
541
613
|
|
|
542
614
|
@staticmethod
|
|
543
615
|
def _normalize_identifier(value: Optional[str | int]) -> Optional[str]:
|
|
@@ -613,8 +685,18 @@ class WorkflowPositionTracker:
|
|
|
613
685
|
""", identity[:4])
|
|
614
686
|
return any(self._normalize_identifier(row[0]) == identity[4] for row in rows)
|
|
615
687
|
|
|
616
|
-
async def _process_fill_internal(
|
|
617
|
-
|
|
688
|
+
async def _process_fill_internal(
|
|
689
|
+
self, fill: PendingFill, classification: str, notifications: list
|
|
690
|
+
) -> str:
|
|
691
|
+
"""Gate explicit replay before atomically mutating FIFO and history.
|
|
692
|
+
|
|
693
|
+
A newly confirmed fill is *queued* into ``notifications`` (a caller-owned
|
|
694
|
+
list) instead of being emitted here — the caller emits it via
|
|
695
|
+
``_emit_fill_notifications`` AFTER releasing ``_buffer_lock``. Emitting
|
|
696
|
+
under the lock would await the listener chain (a network POST in the
|
|
697
|
+
pg-worker forwarder, ``request_timeout`` seconds) while holding
|
|
698
|
+
``_buffer_lock``, serializing every other broker fill push behind it.
|
|
699
|
+
Replays return early and queue nothing (no re-fire)."""
|
|
618
700
|
fill_datetime = f"{fill.order_date}_{fill.fill_time}"
|
|
619
701
|
|
|
620
702
|
with sqlite3.connect(self.db_path) as conn:
|
|
@@ -681,8 +763,31 @@ class WorkflowPositionTracker:
|
|
|
681
763
|
self.fill_revision += 1
|
|
682
764
|
|
|
683
765
|
logger.debug(f"Processed fill: {fill.symbol} {fill.side} {fill.quantity}@{fill.price} [{classification}] ({self.trading_mode})")
|
|
766
|
+
|
|
767
|
+
# 새 체결이 원장에 확정됐다 → on_order_fill 통지를 큐잉한다(1회 발화).
|
|
768
|
+
# 실제 emit 은 caller 가 _buffer_lock 을 놓은 뒤 _emit_fill_notifications
|
|
769
|
+
# 로 수행한다(락 안에서 리스너 network POST 를 await 하지 않기 위함).
|
|
770
|
+
# 리플레이는 위에서 previous 반환으로 이미 걸러졌으므로 여기 오지 않는다.
|
|
771
|
+
if self._on_fill_classified is not None:
|
|
772
|
+
notifications.append((fill, classification))
|
|
773
|
+
|
|
684
774
|
return classification
|
|
685
|
-
|
|
775
|
+
|
|
776
|
+
async def _emit_fill_notifications(self, notifications: list) -> None:
|
|
777
|
+
"""Emit queued on_order_fill notifications OUTSIDE ``_buffer_lock``.
|
|
778
|
+
|
|
779
|
+
Each caller collects confirmed fills into its own list under the lock
|
|
780
|
+
(via ``_process_fill_internal``) and drains them here once the lock is
|
|
781
|
+
released, so a slow listener never blocks other broker fill pushes.
|
|
782
|
+
"""
|
|
783
|
+
if self._on_fill_classified is None:
|
|
784
|
+
return
|
|
785
|
+
for fill, classification in notifications:
|
|
786
|
+
try:
|
|
787
|
+
await self._on_fill_classified(fill, classification)
|
|
788
|
+
except Exception as cb_err:
|
|
789
|
+
logger.warning(f"Fill-classified callback error: {cb_err}")
|
|
790
|
+
|
|
686
791
|
def _process_sell_fifo(
|
|
687
792
|
self,
|
|
688
793
|
cursor: sqlite3.Cursor,
|
|
@@ -14484,7 +14484,7 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
14484
14484
|
order_type = config.get("order_type", "limit")
|
|
14485
14485
|
|
|
14486
14486
|
# order 정규화
|
|
14487
|
-
normalized_order = self._normalize_order(order, config)
|
|
14487
|
+
normalized_order = self._normalize_order(order, config, context, node_id)
|
|
14488
14488
|
|
|
14489
14489
|
if not normalized_order:
|
|
14490
14490
|
context.log("warning", f"{node_type}: 주문할 종목이 없습니다", node_id)
|
|
@@ -14677,12 +14677,18 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
14677
14677
|
self,
|
|
14678
14678
|
order_raw: Any,
|
|
14679
14679
|
config: Dict[str, Any],
|
|
14680
|
+
context: Optional["ExecutionContext"] = None,
|
|
14681
|
+
node_id: Optional[str] = None,
|
|
14680
14682
|
) -> Optional[Dict[str, Any]]:
|
|
14681
14683
|
"""
|
|
14682
14684
|
order 객체를 정규화 (단일 종목)
|
|
14683
14685
|
|
|
14684
14686
|
입력: {symbol, exchange, quantity, price}
|
|
14685
14687
|
출력: {symbol, exchange, quantity, price} (검증된 객체) 또는 None
|
|
14688
|
+
|
|
14689
|
+
symbol 또는 exchange 가 미해석 템플릿('{{ ... }}')이면 주문을 만들지 않고
|
|
14690
|
+
None 을 반환한다 — 상류 바인딩이 풀리지 않은 것이므로, 이 값이 브로커
|
|
14691
|
+
주문(매수·매도·시장가·지정가 전부)이나 원장에 절대 실려선 안 된다(C23).
|
|
14686
14692
|
"""
|
|
14687
14693
|
if not order_raw:
|
|
14688
14694
|
return None
|
|
@@ -14695,6 +14701,28 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
14695
14701
|
return None
|
|
14696
14702
|
|
|
14697
14703
|
exchange = order_raw.get("exchange", "NASDAQ")
|
|
14704
|
+
|
|
14705
|
+
# C23: 미해석 템플릿 심볼/거래소 차단. 상류 노드가 실행되지 않았거나
|
|
14706
|
+
# 포트가 없어 '{{ item.symbol }}' 같은 리터럴이 그대로 넘어온 경우다.
|
|
14707
|
+
# 여기서 막지 않으면 매도 시장가·sizing price>0 분기에서 이 원문이 실제
|
|
14708
|
+
# 브로커 주문(IsuNo)으로 나갈 수 있다.
|
|
14709
|
+
if (isinstance(symbol, str) and "{{" in symbol) or (
|
|
14710
|
+
isinstance(exchange, str) and "{{" in exchange
|
|
14711
|
+
):
|
|
14712
|
+
if context is not None:
|
|
14713
|
+
context.log(
|
|
14714
|
+
"error",
|
|
14715
|
+
f"주문 심볼/거래소가 미해석 템플릿입니다 — 주문을 만들지 않습니다 "
|
|
14716
|
+
f"(unresolved_template_symbol: symbol={symbol!r}, exchange={exchange!r})",
|
|
14717
|
+
node_id,
|
|
14718
|
+
)
|
|
14719
|
+
else:
|
|
14720
|
+
logger.error(
|
|
14721
|
+
"_normalize_order: unresolved_template_symbol "
|
|
14722
|
+
"(symbol=%r, exchange=%r) — refusing to build order",
|
|
14723
|
+
symbol, exchange,
|
|
14724
|
+
)
|
|
14725
|
+
return None
|
|
14698
14726
|
try:
|
|
14699
14727
|
raw_quantity = float(order_raw.get("quantity", 0))
|
|
14700
14728
|
except (ValueError, TypeError):
|
|
@@ -15838,6 +15866,31 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
15838
15866
|
은 ``order_result.diagnostics`` 로 추가 동봉한다. AI 챗봇 소비자는
|
|
15839
15867
|
``diagnostics`` 로 자가수정/안내를 결정적으로 분기할 수 있다.
|
|
15840
15868
|
"""
|
|
15869
|
+
diagnostics = reject_info.model_dump() if reject_info else None
|
|
15870
|
+
|
|
15871
|
+
# C23 방어선: 미해석 템플릿이 결과의 symbol/exchange 자리를 차지하면 안 된다.
|
|
15872
|
+
# _normalize_order 가 이미 그런 주문 자체를 만들지 않지만, 그래도 실패 결과를
|
|
15873
|
+
# 남기는 경로(예: 현재가 조회 실패)가 템플릿 값으로 _order_result 를 부르면
|
|
15874
|
+
# 여기서 symbol/exchange 를 None 으로 비우고, error 코드를 고정하며, 원문은
|
|
15875
|
+
# diagnostics 에 보존한다 — 원장/서버가 심볼 자리에 '{{ ... }}' 를 절대 받지
|
|
15876
|
+
# 않게(prod 실측 order_fills id=17 재발 차단).
|
|
15877
|
+
if (isinstance(symbol, str) and "{{" in symbol) or (
|
|
15878
|
+
isinstance(exchange, str) and "{{" in exchange
|
|
15879
|
+
):
|
|
15880
|
+
template_diag = {
|
|
15881
|
+
"unresolved_template": True,
|
|
15882
|
+
"raw_symbol": symbol,
|
|
15883
|
+
"raw_exchange": exchange,
|
|
15884
|
+
}
|
|
15885
|
+
diagnostics = (
|
|
15886
|
+
{**diagnostics, **template_diag}
|
|
15887
|
+
if isinstance(diagnostics, dict) else template_diag
|
|
15888
|
+
)
|
|
15889
|
+
error = "unresolved_template_symbol"
|
|
15890
|
+
success = False
|
|
15891
|
+
symbol = None
|
|
15892
|
+
exchange = None
|
|
15893
|
+
|
|
15841
15894
|
return {
|
|
15842
15895
|
"order_result": {
|
|
15843
15896
|
"success": success,
|
|
@@ -15848,7 +15901,7 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
15848
15901
|
"price": price,
|
|
15849
15902
|
"status": "submitted" if success else "failed",
|
|
15850
15903
|
"error": error,
|
|
15851
|
-
"diagnostics":
|
|
15904
|
+
"diagnostics": diagnostics,
|
|
15852
15905
|
},
|
|
15853
15906
|
"order_id": order_id,
|
|
15854
15907
|
}
|
|
@@ -5,7 +5,7 @@ authors = [
|
|
|
5
5
|
homepage = "https://programgarden.com"
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
7
|
name = "programgarden"
|
|
8
|
-
version = "1.
|
|
8
|
+
version = "1.36.0"
|
|
9
9
|
license = "AGPL-3.0-or-later"
|
|
10
10
|
description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
|
|
11
11
|
readme = "README.md"
|
|
@@ -29,11 +29,14 @@ 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
|
-
# 🔴 core 하한은 1.
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
32
|
+
# 🔴 core 하한은 1.28.0 이어야 한다 — 이 엔진은 import 시점에 context.py 가
|
|
33
|
+
# `from programgarden_core.bases.listener import OrderFillEvent` 를 eager import
|
|
34
|
+
# 한다(C24). OrderFillEvent 는 core 1.28.0 에서 신설됐으므로, 하한을 낮게 두면
|
|
35
|
+
# 구 core(≤1.27.0)로 resolve 된 환경에서 `import programgarden` 자체가 ImportError
|
|
36
|
+
# 로 통째 실패한다(1.35.1 의 조용한 TypeError 보다 강한 모듈 로드 실패).
|
|
37
|
+
# (1.26.0 이후로도 context.py 는 WorkflowPnLEvent(**event_data) 에 personal_metrics
|
|
38
|
+
# 를 무조건 실으므로 그 하한 근거도 이 상한에 포함된다.)
|
|
39
|
+
programgarden-core = "^1.28.0"
|
|
37
40
|
programgarden-finance = "^1.9.5"
|
|
38
41
|
programgarden-community = "^1.15.0"
|
|
39
42
|
|
|
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.35.0 → programgarden-1.36.0}/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
|
|
File without changes
|
|
File without changes
|