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.
Files changed (39) hide show
  1. {programgarden-1.35.0 → programgarden-1.36.0}/PKG-INFO +2 -2
  2. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/context.py +74 -1
  3. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/database/workflow_position_tracker.py +136 -31
  4. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/executor.py +55 -2
  5. {programgarden-1.35.0 → programgarden-1.36.0}/pyproject.toml +9 -6
  6. {programgarden-1.35.0 → programgarden-1.36.0}/README.md +0 -0
  7. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/__init__.py +0 -0
  8. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/binding_validator.py +0 -0
  9. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/client.py +0 -0
  10. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/code_worker.py +0 -0
  11. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/database/__init__.py +0 -0
  12. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/database/checkpoint_manager.py +0 -0
  13. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/database/query_builder.py +0 -0
  14. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  15. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/deep_fixtures.py +0 -0
  16. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/futures_pnl.py +0 -0
  17. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/node_runner.py +0 -0
  18. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/order_lifecycle.py +0 -0
  19. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/plugin/__init__.py +0 -0
  20. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/plugin/sandbox.py +0 -0
  21. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/providers/__init__.py +0 -0
  22. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/providers/llm_errors.py +0 -0
  23. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/providers/llm_provider.py +0 -0
  24. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/reconnect_handler.py +0 -0
  25. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resolver.py +0 -0
  26. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resource/__init__.py +0 -0
  27. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resource/context.py +0 -0
  28. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resource/limiter.py +0 -0
  29. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resource/monitor.py +0 -0
  30. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/resource/throttle.py +0 -0
  31. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/semantic_rules.py +0 -0
  32. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/__init__.py +0 -0
  33. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/credential_tools.py +0 -0
  34. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/definition_tools.py +0 -0
  35. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/event_tools.py +0 -0
  36. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/job_tools.py +0 -0
  37. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/registry_tools.py +0 -0
  38. {programgarden-1.35.0 → programgarden-1.36.0}/programgarden/tools/sqlite_tools.py +0 -0
  39. {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.35.0
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.26.0,<2.0.0)
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,
@@ -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
- return await self._process_fill_internal(fill, "workflow")
502
- # No matching order. Classify by the published media table
503
- # (MEDIA_CODES_* above) — never by "not 40":
504
- # human (85 HTS / 22 iPhone / 23 Android / 00 branch) → "manual"
505
- # other (96/LP/SK/SO or unlisted) → "other"
506
- # api (40/41/43) or blank → buffer below
507
- channel = media_channel(commda_code)
508
- if channel == "human":
509
- return await self._process_fill_internal(fill, "manual")
510
- if channel == "other":
511
- # A code the table does not attribute — more of these exist than
512
- # we have measured (only 40/51/85/03 are live-observed so far).
513
- # Log it so the table can grow from evidence, never from guesses.
514
- logger.info("Unlisted media code %r on fill %s/%s — classified other",
515
- commda_code, order_date, order_no)
516
- return await self._process_fill_internal(fill, "other")
517
-
518
- # An API code or an empty media code ("medium unknown"): buffer to
519
- # give a lagging order record a chance to arrive, then
520
- # _process_timeout_fill files it as workflow (matched) or unknown_api
521
- # (unmatched). An unknown medium is never asserted to be a person's
522
- # HTS trade, so it floors to unknown_api rather than manual.
523
- self._next_pending_fill += 1
524
- key = self._next_pending_fill
525
- self._pending_fills[key] = fill
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(self, fill: PendingFill, classification: str) -> str:
617
- """Gate explicit replay before atomically mutating FIFO and history."""
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": reject_info.model_dump() if reject_info else None,
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.35.0"
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.26.0 이어야 한다 — context.py 가 event_data 에 personal_metrics 를
33
- # 무조건 실어 WorkflowPnLEvent(**event_data) 를 만드는데, 그 필드는 core 1.26.0 에서
34
- # 추가됐다. 하한을 낮게 두면 core 1.25.x 로 해석된 환경에서 생성이 TypeError 로 죽고,
35
- # 그 예외가 바로 위 except Exception 에 삼켜져 **PnL 이벤트가 통째로 조용히 멈춘다.**
36
- programgarden-core = "^1.26.0"
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