programgarden 1.37.3__tar.gz → 1.37.4__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 (40) hide show
  1. {programgarden-1.37.3 → programgarden-1.37.4}/PKG-INFO +2 -2
  2. programgarden-1.37.4/programgarden/database/fill_reconciler.py +271 -0
  3. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/database/workflow_position_tracker.py +118 -0
  4. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/executor.py +277 -6
  5. {programgarden-1.37.3 → programgarden-1.37.4}/pyproject.toml +2 -2
  6. {programgarden-1.37.3 → programgarden-1.37.4}/README.md +0 -0
  7. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/__init__.py +0 -0
  8. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/binding_validator.py +0 -0
  9. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/client.py +0 -0
  10. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/code_worker.py +0 -0
  11. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/context.py +0 -0
  12. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/database/__init__.py +0 -0
  13. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/database/checkpoint_manager.py +0 -0
  14. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/database/query_builder.py +0 -0
  15. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/database/workflow_risk_tracker.py +0 -0
  16. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/deep_fixtures.py +0 -0
  17. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/futures_pnl.py +0 -0
  18. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/node_runner.py +0 -0
  19. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/order_lifecycle.py +0 -0
  20. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/plugin/__init__.py +0 -0
  21. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/plugin/sandbox.py +0 -0
  22. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/providers/__init__.py +0 -0
  23. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/providers/llm_errors.py +0 -0
  24. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/providers/llm_provider.py +0 -0
  25. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/reconnect_handler.py +0 -0
  26. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/resolver.py +0 -0
  27. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/resource/__init__.py +0 -0
  28. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/resource/context.py +0 -0
  29. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/resource/limiter.py +0 -0
  30. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/resource/monitor.py +0 -0
  31. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/resource/throttle.py +0 -0
  32. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/semantic_rules.py +0 -0
  33. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/tools/__init__.py +0 -0
  34. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/tools/credential_tools.py +0 -0
  35. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/tools/definition_tools.py +0 -0
  36. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/tools/event_tools.py +0 -0
  37. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/tools/job_tools.py +0 -0
  38. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/tools/registry_tools.py +0 -0
  39. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/tools/sqlite_tools.py +0 -0
  40. {programgarden-1.37.3 → programgarden-1.37.4}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.37.3
3
+ Version: 1.37.4
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.3,<2.0.0)
19
- Requires-Dist: programgarden-core (>=1.28.0,<2.0.0)
19
+ Requires-Dist: programgarden-core (>=1.28.1,<2.0.0)
20
20
  Requires-Dist: programgarden-finance (>=1.9.7,<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)
@@ -0,0 +1,271 @@
1
+ """체결 재조정 — 좁은 확인 창에서 놓친 체결을 뒤늦게 원장에 맞춰 넣는다.
2
+
3
+ ## 왜 필요한가
4
+
5
+ 로트(`workflow_position_lots`)와 체결내역(`trade_history`)은 **체결 이벤트로만** 생긴다
6
+ (`WorkflowPositionTracker._process_fill_internal` 이 유일한 입구). 그런데 주문 접수 직후의
7
+ 인라인 확인은 4회 × 2초, 실질 6초밖에 열리지 않는다. 지정가가 몇 분 뒤에 체결되면 그
8
+ 사실이 원장에 **영영 도착하지 않았고**, 되찾을 경로가 없었다.
9
+
10
+ prod 실측(2026-09-14, 공유 전략 b9f837e0): MARA 매수(09-11)·NIO 매도(09-14)가 둘 다 실제로
11
+ 체결됐는데 `trade_history` 0건 · 로트 0건이었다. 그래서 `workflow_buy_amount` 가 0 이 되고,
12
+ 분모가 0 이라 `workflow_pnl_rate` 가 NULL 로 발행되어 **커뮤니티 수익률이 통째로 비었다.**
13
+
14
+ ## 대칭 — 네 경우를 모두 다룬다
15
+
16
+ 오너 지시(2026-09-14): *"자동매매로 산 게 아닐 수도 맞을 수도 있고, 자동매매로 판 게 아닐
17
+ 수도 맞을 수도 있으니깐 대응되어서 사용자에게도 추정치라도 확인되어야지."*
18
+
19
+ | 매수 | 매도 | 처리 |
20
+ |---|---|---|
21
+ | 자동매매 | 자동매매 | FIFO 실현손익 (정상 경로) |
22
+ | 자동매매 밖 | 자동매매 | 매수 기준이 없다 → 계좌 평균매입가로 **추정** |
23
+ | 자동매매 | 자동매매 밖 | 로트가 남는다 → **유령 포지션**, 밖에서 팔린 것으로 **추정** |
24
+ | 자동매매 밖 | 자동매매 밖 | 전략 성과가 아니다 — off_strategy 로만 집계 |
25
+
26
+ 두 갈래 모두 **추정임을 밝혀서** 기록하고, 확정 실현손익과 섞지 않는다.
27
+
28
+ ## 이 모듈의 경계
29
+
30
+ 브로커를 모른다. 체결 조회는 호출자가 주입한 콜러블로 하고, 계좌 잔고도 인자로 받는다.
31
+ 그래서 실행기 생명주기 없이 단위 테스트할 수 있다.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ from dataclasses import dataclass, field
38
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ #: 잔고를 못 읽었을 때 쓰는 표식. **빈 dict 를 쓰면 안 된다** — "보유 0" 과 구분되지 않아
43
+ #: 멀쩡한 로트를 전부 유령으로 보고하고, 실제로 들고 있는 포지션을 밖에서 팔린 것으로
44
+ #: 기록해 버린다. 조회 실패는 반드시 이 값으로 넘겨 재조정을 **건너뛰게** 한다.
45
+ ACCOUNT_UNAVAILABLE: Optional[Dict[str, Any]] = None
46
+
47
+ #: 유령 판정에 쓰는 수량 허용오차. 소수점 보유(분할매수 등)에서 부동소수 오차로
48
+ #: 0.0000001 주가 "사라진" 것으로 잡히는 것을 막는다.
49
+ _QTY_EPSILON = 1e-6
50
+
51
+
52
+ @dataclass
53
+ class ReconcileReport:
54
+ """한 번의 재조정 결과. 수치는 전부 '무엇을 했는가' 이고 판정이 아니다."""
55
+
56
+ checked_orders: int = 0
57
+ recorded_fills: int = 0
58
+ still_unfilled: int = 0
59
+ mismatched_symbol: int = 0
60
+ phantom_positions: int = 0
61
+ phantom_quantity: float = 0.0
62
+ skipped_no_account: bool = False
63
+ errors: List[str] = field(default_factory=list)
64
+
65
+ def as_dict(self) -> Dict[str, Any]:
66
+ return {
67
+ "checked_orders": self.checked_orders,
68
+ "recorded_fills": self.recorded_fills,
69
+ "still_unfilled": self.still_unfilled,
70
+ "mismatched_symbol": self.mismatched_symbol,
71
+ "phantom_positions": self.phantom_positions,
72
+ "phantom_quantity": self.phantom_quantity,
73
+ "skipped_no_account": self.skipped_no_account,
74
+ "errors": list(self.errors),
75
+ }
76
+
77
+
78
+ def _normalize(symbol: str) -> str:
79
+ """종목코드 비교용 정규화 — 표기 차이(공백·대소문자)로 오탐하지 않게."""
80
+ return str(symbol or "").strip().upper()
81
+
82
+
83
+ def _account_avg_price(account_positions: Dict[str, Any], symbol: str) -> Optional[float]:
84
+ pos = account_positions.get(symbol)
85
+ if not isinstance(pos, dict):
86
+ return None
87
+ raw = pos.get("avg_price")
88
+ if raw is None:
89
+ return None
90
+ try:
91
+ value = float(raw)
92
+ except (TypeError, ValueError):
93
+ return None
94
+ return value if value > 0 else None
95
+
96
+
97
+ def _account_quantities(account_positions: Dict[str, Any]) -> Dict[str, float]:
98
+ out: Dict[str, float] = {}
99
+ for symbol, pos in account_positions.items():
100
+ if not isinstance(pos, dict):
101
+ continue
102
+ try:
103
+ out[symbol] = float(pos.get("quantity") or 0)
104
+ except (TypeError, ValueError):
105
+ out[symbol] = 0.0
106
+ return out
107
+
108
+
109
+ async def reconcile_workflow_fills(
110
+ tracker: Any,
111
+ *,
112
+ fetch_fills_by_date: Callable[[str], Awaitable[Dict[str, Dict[str, Any]]]],
113
+ account_positions: Optional[Dict[str, Any]],
114
+ min_age_seconds: float = 60.0,
115
+ limit: int = 50,
116
+ record_phantom: bool = True,
117
+ ) -> ReconcileReport:
118
+ """미확정 주문을 브로커에 확인해 원장에 넣고, 유령 포지션을 보고한다.
119
+
120
+ Args:
121
+ tracker: ``WorkflowPositionTracker``.
122
+ fetch_fills_by_date: ``async (order_date) -> {주문번호: {filled_qty, avg_price,
123
+ fill_time, ...}}``. 조회 실패는 **빈 dict** 를 돌려준다 — 그래서 이 함수는
124
+ "응답에 없음" 을 "체결 안 됨" 으로 단정하지 않고 미확정으로 남겨 둔다.
125
+ account_positions: ``{종목코드: {"quantity": float, "avg_price": float}}``.
126
+ 잔고를 못 읽었으면 반드시 ``None``(=``ACCOUNT_UNAVAILABLE``) — 빈 dict 가
127
+ 아니다. ``None`` 이면 유령 갈래를 통째로 건너뛴다.
128
+ min_age_seconds: 접수 직후 인라인 확인 창과 겹치지 않게 두는 유예.
129
+ limit: 한 주기에 처리할 최대 주문 수.
130
+ record_phantom: 유령 포지션을 보고만 할지(False) 여부. 현재는 보고만 한다 —
131
+ 로트 정리는 사용자에게 보이는 수치를 바꾸므로 별도 결정이 필요하다.
132
+
133
+ Returns:
134
+ ``ReconcileReport``. 아무것도 못 했으면 0 이 담긴 보고서이고 예외는 던지지 않는다.
135
+ """
136
+ report = ReconcileReport()
137
+
138
+ # ── ① 주문 갈래: 우리가 낸 주문인데 체결이 원장에 없는 것 ────────────────
139
+ try:
140
+ pending = tracker.get_unconfirmed_workflow_orders(
141
+ min_age_seconds=min_age_seconds, limit=limit
142
+ )
143
+ except Exception as exc: # 원장 조회 실패는 재조정 전체를 막지 않는다
144
+ report.errors.append(f"unconfirmed_query_failed: {exc}")
145
+ pending = []
146
+
147
+ report.checked_orders = len(pending)
148
+
149
+ # 날짜별로 묶어 브로커 호출을 날짜 수만큼으로 줄인다(앱키당 2초 1회 제한).
150
+ by_date: Dict[str, List[Dict[str, Any]]] = {}
151
+ for order in pending:
152
+ by_date.setdefault(str(order.get("order_date") or ""), []).append(order)
153
+
154
+ for order_date, orders in by_date.items():
155
+ if not order_date:
156
+ report.errors.append("order_without_date_skipped")
157
+ continue
158
+ try:
159
+ fills = await fetch_fills_by_date(order_date)
160
+ except Exception as exc:
161
+ report.errors.append(f"fill_query_failed[{order_date}]: {exc}")
162
+ continue
163
+ if not isinstance(fills, dict):
164
+ report.errors.append(f"fill_query_bad_shape[{order_date}]")
165
+ continue
166
+
167
+ # 주문번호는 앞자리 0 을 떼고 맞춘다 — 브로커 응답과 우리 원장의 표기가 갈린다.
168
+ normalized = {str(k).lstrip("0") or str(k): v for k, v in fills.items()}
169
+
170
+ for order in orders:
171
+ order_no = str(order.get("order_no") or "")
172
+ hit = fills.get(order_no) or normalized.get(order_no.lstrip("0") or order_no)
173
+ if not hit:
174
+ # 응답에 없다 = 아직 미체결이거나, 조회가 실패했거나, 취소됐다.
175
+ # 셋을 구분할 수 없으므로 아무것도 기록하지 않고 다음 주기로 넘긴다.
176
+ report.still_unfilled += 1
177
+ continue
178
+ try:
179
+ filled_qty = float(hit.get("filled_qty") or 0)
180
+ fill_price = float(hit.get("avg_price") or 0)
181
+ except (TypeError, ValueError):
182
+ report.errors.append(f"fill_row_unparsable[{order_no}]")
183
+ continue
184
+ if filled_qty <= 0 or fill_price <= 0:
185
+ report.still_unfilled += 1
186
+ continue
187
+
188
+ symbol = str(order.get("symbol") or hit.get("symbol") or "")
189
+ side = str(order.get("side") or "")
190
+ if not symbol or side not in ("buy", "sell"):
191
+ report.errors.append(f"order_missing_symbol_or_side[{order_no}]")
192
+ continue
193
+
194
+ # 🔴 주문번호만 믿으면 안 된다. 주문번호는 계좌 안에서만 유일하고, 원장에는
195
+ # **계좌를 바꾸기 전 주문이 그대로 남아 있다**(workflow_orders 에 계좌 컬럼이
196
+ # 없다). 새 계좌에서 같은 날 같은 번호가 나오면 남의 체결을 우리 주문으로
197
+ # 기록하게 된다. 브로커 응답이 종목을 실어 줄 때는 반드시 대조한다.
198
+ hit_symbol = str(hit.get("symbol") or "").strip()
199
+ if hit_symbol and _normalize(hit_symbol) != _normalize(symbol):
200
+ report.mismatched_symbol += 1
201
+ logger.warning(
202
+ "fill_reconcile_symbol_mismatch | order=%s date=%s ledger=%s broker=%s "
203
+ "— 같은 주문번호의 다른 종목이라 기록하지 않는다(계좌 교체 흔적일 수 있다)",
204
+ order_no, order_date, symbol, hit_symbol,
205
+ )
206
+ continue
207
+
208
+ # 🔴 매도 잔량 추정의 전제 — 계좌 평균매입가를 반드시 같이 넘긴다.
209
+ # 없으면 "자동매매가 사지 않은 물량을 팔았다" 가 추정으로 기록되지 않고
210
+ # 'Sell without enough position' 경고만 남고 조용히 사라진다.
211
+ avg_price = (
212
+ _account_avg_price(account_positions, symbol)
213
+ if isinstance(account_positions, dict) else None
214
+ )
215
+
216
+ try:
217
+ classification = await tracker.record_fill(
218
+ order_no=order_no,
219
+ order_date=order_date,
220
+ symbol=symbol,
221
+ exchange=str(order.get("exchange") or ""),
222
+ side=side,
223
+ quantity=int(filled_qty),
224
+ price=fill_price,
225
+ fill_time=str(hit.get("fill_time") or ""),
226
+ # 재조정은 우리 주문 원장에서 출발하므로 매체코드를 추측하지 않는다.
227
+ # 빈 값이면 트래커가 '우리 주문과 일치하는가' 로만 분류한다.
228
+ commda_code="",
229
+ account_avg_price=avg_price,
230
+ )
231
+ except Exception as exc:
232
+ report.errors.append(f"record_fill_failed[{order_no}]: {exc}")
233
+ continue
234
+
235
+ report.recorded_fills += 1
236
+ logger.info(
237
+ "fill_reconciled | order=%s date=%s %s %s x%s @%s class=%s avg_basis=%s",
238
+ order_no, order_date, symbol, side, int(filled_qty), fill_price,
239
+ classification, avg_price,
240
+ )
241
+
242
+ # ── ② 잔고 갈래: 로트는 남았는데 계좌엔 없는 것(밖에서 팔린 것으로 추정) ──
243
+ if account_positions is None:
244
+ # 잔고를 못 읽었다. 빈 잔고로 간주하면 보유 중인 로트를 전부 유령으로 보고한다.
245
+ report.skipped_no_account = True
246
+ return report
247
+
248
+ try:
249
+ phantoms = tracker.get_phantom_workflow_positions(_account_quantities(account_positions))
250
+ except Exception as exc:
251
+ report.errors.append(f"phantom_query_failed: {exc}")
252
+ return report
253
+
254
+ for p in phantoms:
255
+ missing = float(p.get("missing_quantity") or 0)
256
+ if missing <= _QTY_EPSILON:
257
+ continue
258
+ report.phantom_positions += 1
259
+ report.phantom_quantity += missing
260
+ if record_phantom:
261
+ # 지금은 보고만 한다. 사라진 이유는 밖에서 매도 / 잔고 반영 지연 /
262
+ # 종목코드 표기 차이 중 어느 것인지 구분할 수 없으므로 **추정**이고,
263
+ # 로트를 자동으로 지우면 사용자 수치가 근거 없이 바뀐다.
264
+ logger.warning(
265
+ "phantom_workflow_position | symbol=%s lot_qty=%s account_qty=%s "
266
+ "missing=%s avg_buy=%s — 자동매매 밖에서 매도된 것으로 추정됩니다",
267
+ p.get("symbol"), p.get("lot_quantity"), p.get("account_quantity"),
268
+ missing, p.get("avg_buy_price"),
269
+ )
270
+
271
+ return report
@@ -1484,6 +1484,124 @@ class WorkflowPositionTracker:
1484
1484
  commda_code=commda_code, received_at=received_at, quantity=quantity,
1485
1485
  ) is not None
1486
1486
 
1487
+ # ── 체결 재조정 (2026-09-14) ────────────────────────────────────────────
1488
+ # lot 과 trade_history 는 **체결 이벤트로만** 생긴다(_process_fill_internal 이 유일한
1489
+ # 입구). 주문 접수 직후의 인라인 확인 창은 4회 x 2초로 짧아서, 지정가가 몇 분 뒤에
1490
+ # 체결되면 그 사실이 원장에 영영 도착하지 않는다. 그러면:
1491
+ # · workflow_position_lots 가 비어 workflow_pnl_rate 가 NULL 로 발행되고
1492
+ # (분모 0) 커뮤니티 수익률이 통째로 빈다.
1493
+ # · 실제로 보유 중인 종목을 엔진은 모른다.
1494
+ # prod 실측(2026-09-14): MARA 매수(09-11)·NIO 매도(09-14) 둘 다 실제 체결됐는데
1495
+ # trade_history 0건, lot 0건이었다.
1496
+ #
1497
+ # 아래 두 메서드는 그 복구의 **조회 절반**이다. 실제 브로커 확인과 record_fill 호출은
1498
+ # 호출자(엔진 주기 태스크)가 한다 — 트래커는 브로커를 모른다.
1499
+
1500
+ def get_unconfirmed_workflow_orders(
1501
+ self,
1502
+ *,
1503
+ min_age_seconds: float = 60.0,
1504
+ limit: int = 50,
1505
+ now: Optional[datetime] = None,
1506
+ ) -> List[Dict[str, Any]]:
1507
+ """체결이 원장에 도착하지 않은 우리 주문 목록(오래된 것부터).
1508
+
1509
+ ``workflow_orders`` 에는 있는데 대응하는 ``trade_history`` 행이 없는 주문이다.
1510
+ 주문번호는 양쪽에서 앞자리 0 을 떼고 비교한다 — ``normalized_order_no`` 는
1511
+ execution_id 가 있을 때만 채워지므로 그 컬럼만 믿으면 안 된다.
1512
+
1513
+ Args:
1514
+ min_age_seconds: 접수 직후의 인라인 확인 창과 겹치지 않도록 두는 유예.
1515
+ 갓 낸 주문을 곧바로 재확인하면 같은 체결이 두 번 기록될 수 있다.
1516
+ limit: 한 번에 가져올 최대 건수(브로커 조회 속도 제한 때문에 필요).
1517
+ now: 테스트용 기준 시각.
1518
+
1519
+ Returns:
1520
+ ``{order_no, order_date, symbol, exchange, side, quantity, price,
1521
+ node_id, job_id, created_at}`` 목록. 체결 여부는 **모른다** —
1522
+ "원장에 없다" 만 말한다. 취소·거부된 주문도 여기 들어오므로, 호출자가
1523
+ 브로커에 확인한 뒤에만 원장에 넣어야 한다.
1524
+ """
1525
+ cutoff = ((now or datetime.now()) - timedelta(seconds=max(0.0, min_age_seconds))).isoformat()
1526
+ with sqlite3.connect(self.db_path) as conn:
1527
+ conn.row_factory = sqlite3.Row
1528
+ rows = conn.execute(
1529
+ """
1530
+ SELECT o.order_no, o.order_date, o.symbol, o.exchange, o.side,
1531
+ o.quantity, o.price, o.node_id, o.job_id, o.created_at
1532
+ FROM workflow_orders o
1533
+ WHERE o.product = ? AND o.provider = ? AND o.trading_mode = ?
1534
+ AND o.created_at < ?
1535
+ AND NOT EXISTS (
1536
+ SELECT 1 FROM trade_history t
1537
+ WHERE t.product = o.product
1538
+ AND t.provider = o.provider
1539
+ AND t.trading_mode = o.trading_mode
1540
+ AND t.order_date = o.order_date
1541
+ AND COALESCE(NULLIF(ltrim(t.order_no, '0'), ''), t.order_no)
1542
+ = COALESCE(NULLIF(ltrim(o.order_no, '0'), ''), o.order_no)
1543
+ )
1544
+ ORDER BY o.created_at ASC
1545
+ LIMIT ?
1546
+ """,
1547
+ (self.product, self.provider, self.trading_mode, cutoff, int(limit)),
1548
+ ).fetchall()
1549
+ return [dict(r) for r in rows]
1550
+
1551
+ def get_phantom_workflow_positions(
1552
+ self,
1553
+ account_quantities: Dict[str, float],
1554
+ ) -> List[Dict[str, Any]]:
1555
+ """워크플로우 lot 은 남아 있는데 계좌에는 없는(또는 모자란) 종목.
1556
+
1557
+ 🔴 대칭의 나머지 절반이다. 자동매매가 산 종목을 **자동매매 밖에서** 팔면
1558
+ (HTS·전화·다른 API) 그 체결 이벤트를 받아야 lot 이 줄어든다. 못 받으면 lot 이
1559
+ 그대로 남아 **계좌에 없는 물량을 보유 중으로 계산**하고 수익률을 과대 계상한다.
1560
+ 저장소에 lot↔잔고 대조가 없어서 한번 어긋나면 되맞출 방법이 없었다.
1561
+
1562
+ Args:
1563
+ account_quantities: 브로커 잔고 기준 ``{종목코드: 보유수량}``.
1564
+ **조회에 실패했으면 빈 dict 를 넘기지 마라** — 보유 0 과 구분되지 않아
1565
+ 멀쩡한 lot 을 전부 유령으로 보고한다. 호출자가 실패를 걸러야 한다.
1566
+
1567
+ Returns:
1568
+ ``{symbol, lot_quantity, account_quantity, missing_quantity,
1569
+ avg_buy_price}`` 목록. 판정은 하지 않는다 — "차이가 있다" 만 말한다.
1570
+ 사라진 이유(밖에서 매도 / 잔고 반영 지연 / 종목코드 표기 차이)는 호출자가
1571
+ **추정**으로 다루고, 사용자에게도 추정임을 밝혀야 한다.
1572
+ """
1573
+ with sqlite3.connect(self.db_path) as conn:
1574
+ conn.row_factory = sqlite3.Row
1575
+ rows = conn.execute(
1576
+ """
1577
+ SELECT symbol,
1578
+ SUM(remaining_qty) AS lot_quantity,
1579
+ SUM(remaining_qty * buy_price) / NULLIF(SUM(remaining_qty), 0) AS avg_buy_price
1580
+ FROM workflow_position_lots
1581
+ WHERE product = ? AND provider = ? AND trading_mode = ?
1582
+ AND classification = 'workflow' AND remaining_qty > 0
1583
+ GROUP BY symbol
1584
+ """,
1585
+ (self.product, self.provider, self.trading_mode),
1586
+ ).fetchall()
1587
+
1588
+ normalized = {self._normalize_symbol(k): float(v) for k, v in (account_quantities or {}).items()}
1589
+ phantoms: List[Dict[str, Any]] = []
1590
+ for r in rows:
1591
+ symbol = r["symbol"]
1592
+ lot_qty = float(r["lot_quantity"] or 0)
1593
+ held = normalized.get(self._normalize_symbol(symbol), 0.0)
1594
+ missing = lot_qty - held
1595
+ if missing > 0:
1596
+ phantoms.append({
1597
+ "symbol": symbol,
1598
+ "lot_quantity": lot_qty,
1599
+ "account_quantity": held,
1600
+ "missing_quantity": missing,
1601
+ "avg_buy_price": float(r["avg_buy_price"]) if r["avg_buy_price"] is not None else None,
1602
+ })
1603
+ return phantoms
1604
+
1487
1605
  def get_workflow_positions(
1488
1606
  self,
1489
1607
  start_date: Optional[str] = None,
@@ -27,6 +27,10 @@ from programgarden_core import (
27
27
  ValidationLimits,
28
28
  map_reject_code,
29
29
  diagnose_missing_order_no,
30
+ unsupported_market_reject,
31
+ looks_like_otc_ticker,
32
+ LS_OVERSEAS_DESK_PHONE,
33
+ OVERSEAS_STOCK_ORDER_MARKET_CODES,
30
34
  )
31
35
  from programgarden.context import ExecutionContext, WorkflowEvent
32
36
  from programgarden.order_lifecycle import (
@@ -71,6 +75,55 @@ OVERSEAS_STOCK_MARKET_CODES = {
71
75
  "83": "81", # 과거 우리가 쓰던 잘못된 AMEX 코드가 저장된 워크플로우 방어
72
76
  }
73
77
 
78
+ # 🔴 2026-09-14 미해결 — 여기에 없는 거래소 코드는 `.get(exchange, "82")` 로 **조용히 NASDAQ 이
79
+ # 된다**. prod 실계좌 관측: 보유 종목 ZOMDF(조메디카, 장외)의 잔고 응답 거래소가 `"85"` 로 오는데
80
+ # 그 키가 없어 82 로 떨어졌고, 현재가 조회(g3101)가 82 로 나가 실패 → 매도 주문이 "현재가 조회 실패"
81
+ # 로 끝났다(같은 주기의 NIO/NYSE 는 정상 접수). 종전엔 매도가 현재가 조회를 아예 안 타서 이 구멍이
82
+ # 가려져 있었다(대신 시장가라 00891 로 거부).
83
+ # 🔬 2026-09-14 라이브 실측으로 확정 — **두 값 모두 거부됐다**:
84
+ # OrdMktCode="82"(NASDAQ) → rsp_cd=03053 "해당 종목번호가 없습니다."
85
+ # OrdMktCode="81"(NYSE/AMEX) → rsp_cd=03053 "해당 종목번호가 없습니다."
86
+ # COSAT00301 이 받는 값은 Literal["81","82"] 뿐이므로, **시장 85 의 보유 종목은 이 TR 로는
87
+ # 주문할 수 없다**. 매핑을 어떻게 고쳐도 해결되지 않는다 — 다른 TR 이거나 다른 경로가 필요하다.
88
+ # 남은 일: ① LS 에서 시장 `85` 종목을 매도하는 경로가 무엇인지 확인 — 추측으로 TR/파라미터를
89
+ # 만들지 말 것 ([[feedback_ask_before_inventing_broker_tr_requests]], [[feedback_no_inference_about_ls_broker]])
90
+ # ② 매핑에 없는 코드는 조용히 82 로 떨어뜨리지 말고, 코드값을 담은 사유와 함께 실패시킬 것
91
+ # (지금은 "해당 종목번호가 없습니다" 라는 엉뚱한 브로커 메시지로 나타나 원인 파악을 가린다).
92
+
93
+
94
+ def resolve_overseas_stock_order_market(
95
+ exchange: Any, symbol: Any = ""
96
+ ) -> "tuple[Optional[str], Optional[str]]":
97
+ """해외주식 주문용 시장코드 판정. ``(ord_mkt_code, None)`` 또는 ``(None, 사유)``.
98
+
99
+ 🔴 종전엔 호출부마다 ``STOCK_MARKET_CODES.get(exchange, "82")`` 로 **모르는 코드를
100
+ 조용히 나스닥으로 바꿔** 보냈다. 그러면 브로커가 `03053 "해당 종목번호가 없습니다"` 로
101
+ 답하고, 사용자에게는 **종목 코드를 잘못 쓴 것처럼** 보인다 — 실제로는 그 종목이 이
102
+ API 로 거래할 수 없는 시장(장외/OTC 등)에 있다는 뜻이다(2026-09-14 실계좌 관측 +
103
+ LS증권 유선 확인: OTC 종목은 API 주문 불가, 전화 주문만 가능).
104
+
105
+ 그래서 매핑에 없으면 **요청을 보내지 않고** 실패시킨다. 보내 봐야 성공할 수 없고,
106
+ LS 주문 속도 제한(초당 1건)만 쓴다.
107
+ """
108
+ if exchange is None:
109
+ return (None, "거래소가 비어 있습니다.")
110
+ key = str(exchange).strip()
111
+ code = OVERSEAS_STOCK_ORDER_MARKET_CODES.get(key) or OVERSEAS_STOCK_ORDER_MARKET_CODES.get(key.upper())
112
+ if code:
113
+ return (code, None)
114
+ # LS 경험칙: 5자 티커가 F 로 끝나면 보통 OTC 로 넘어간 종목이다. LS 도 "항상 맞는
115
+ # 룰은 아니다" 라고 단서를 달았으므로 **차단 판단에는 쓰지 않고**(차단은 거래소 코드로
116
+ # 한다) 안내 문구를 구체적으로 만드는 데만 쓴다.
117
+ otc_hint = "티커가 5자이고 F 로 끝나 장외로 넘어간 종목으로 보입니다. " if looks_like_otc_ticker(symbol) else ""
118
+ return (
119
+ None,
120
+ f"이 종목이 속한 시장({key})은 자동매매가 주문할 수 없습니다. "
121
+ f"해외주식 주문은 뉴욕·아멕스·나스닥만 지원합니다. {otc_hint}"
122
+ "장외(OTC) 종목이 이 경우이며, 자동매매로는 매도할 수 없습니다. "
123
+ f"LS증권 해외주식 데스크({LS_OVERSEAS_DESK_PHONE})로 전화해 매도를 요청하세요 — "
124
+ "미국 정규장 시간에 현지 브로커를 찾아 매도해 줍니다.",
125
+ )
126
+
74
127
 
75
128
  def _qty_num(value: Any, default: int = 0):
76
129
  """수량 필드 정규화 — 정수 값이면 int, 소수점(fractional)이면 값 보존 float.
@@ -16331,7 +16384,21 @@ class NewOrderNodeExecutor(NodeExecutorBase):
16331
16384
  context.log("warning", "주문 타입 자동 변환: 해외주식 매수는 시장가 불가 → 현재가 기준 지정가 주문으로 전환", node_id)
16332
16385
  ordprc_ptn_code = "00"
16333
16386
 
16334
- ord_mkt_code = self.STOCK_MARKET_CODES.get(exchange, "82")
16387
+ # 🔴 브로커를 부르기 전에 시장을 판정한다 — 모르는 코드를 나스닥으로 바꿔 보내면
16388
+ # 03053 "해당 종목번호가 없습니다" 로 돌아와 종목 탓처럼 보인다(2026-09-14).
16389
+ ord_mkt_code, market_error = resolve_overseas_stock_order_market(exchange, symbol)
16390
+ if market_error:
16391
+ reject = unsupported_market_reject(exchange, symbol)
16392
+ logger.warning(
16393
+ "order_unsupported_market | node=%s symbol=%s exchange=%r — %s",
16394
+ node_id, symbol, exchange, market_error,
16395
+ )
16396
+ context.log("error", f"{symbol}: {market_error}", node_id)
16397
+ await self._notify_order_reject(context, node_id, symbol, reject)
16398
+ return self._order_result(
16399
+ False, symbol, exchange, side, qty, price,
16400
+ market_error, reject_info=reject,
16401
+ )
16335
16402
 
16336
16403
  # 지정가 주문인데 가격이 없으면 현재가 조회.
16337
16404
  # 🔴 2026-09-14 — 종전엔 **매수만** 이 경로를 탔다. 그래서 매도를 지정가로 두고 가격을
@@ -16850,6 +16917,75 @@ class NewOrderNodeExecutor(NodeExecutorBase):
16850
16917
  context.log("debug", f"COSAQ00102 fill query exception: {e}", node_id)
16851
16918
  return 0, 0.0
16852
16919
 
16920
+ async def _query_overseas_stock_fills_by_date(
16921
+ self,
16922
+ ls,
16923
+ order_date: str,
16924
+ context: ExecutionContext,
16925
+ node_id: str,
16926
+ ) -> Dict[str, Dict[str, Any]]:
16927
+ """그 날짜의 **모든** 체결을 주문번호별로 모아 돌려준다 (COSAQ00102).
16928
+
16929
+ ``_query_overseas_stock_fill`` 과 같은 TR 을 부르지만 주문 하나로 좁히지 않는다.
16930
+ 그 함수는 이미 ``IsuNo=""`` · ``SrtOrdNo=999999999`` 로 **그날 전체**를 받아 온 뒤
16931
+ 주문번호로 거르고 있어서, 미확정 주문이 N 건이면 같은 응답을 N 번 받게 된다.
16932
+ LS 주문체결내역 조회는 앱키당 2초에 1회이고 같은 앱키를 SDK 계좌 추적기가 60초
16933
+ 주기로 이미 쓴다 — 재조정이 건당 호출하면 그 예산을 그대로 먹는다. 날짜당 1회로
16934
+ 묶으면 미확정이 몇 건이든 호출 수는 날짜 수만큼이다.
16935
+
16936
+ Returns:
16937
+ ``{주문번호: {"filled_qty", "avg_price", "symbol", "fill_time"}}``.
16938
+ 조회 실패·응답 없음은 **빈 dict** 다 — "체결 0건" 과 구분되지 않으므로
16939
+ 호출자는 이 결과만으로 "체결 안 됐다" 고 단정하면 안 된다.
16940
+ """
16941
+ out: Dict[str, Dict[str, Any]] = {}
16942
+ try:
16943
+ from programgarden_finance import COSAQ00102
16944
+
16945
+ response = ls.overseas_stock().accno().cosaq00102(
16946
+ body=COSAQ00102.COSAQ00102InBlock1(
16947
+ RecCnt=1, QryTpCode="1", BkseqTpCode="1", OrdMktCode="00",
16948
+ BnsTpCode="0", IsuNo="", SrtOrdNo=999999999, OrdDt=order_date,
16949
+ ExecYn="1", CrcyCode="000", ThdayBnsAppYn="0", LoanBalHldYn="0",
16950
+ ),
16951
+ )
16952
+ result = await response.req_async()
16953
+ if not result or not getattr(result, "block3", None):
16954
+ return out
16955
+
16956
+ for item in result.block3:
16957
+ order_no = str(getattr(item, "OrdNo", "") or "").strip()
16958
+ if not order_no:
16959
+ continue
16960
+ q = _qty_num(getattr(item, "ExecQty", 0) or 0)
16961
+ if q <= 0:
16962
+ continue
16963
+ p = float(getattr(item, "OvrsExecPrc", 0) or getattr(item, "OvrsOrdPrc", 0) or 0)
16964
+ row = out.setdefault(order_no, {
16965
+ "filled_qty": 0, "_amount": 0.0, "symbol": "", "fill_time": "",
16966
+ })
16967
+ row["filled_qty"] += q
16968
+ row["_amount"] += q * p
16969
+ # 🔴 LS 는 TR 마다 같은 뜻의 필드 이름이 다르고, 이 블록에는 `ShtnIsuNo`
16970
+ # (단축종목번호)와 `IsuNo`(종목번호)가 **둘 다** 있다. 이 저장소의 관행은
16971
+ # ShtnIsuNo 우선·IsuNo 폴백이다(잔고·체결 파싱이 전부 그렇게 한다).
16972
+ # 이름을 틀리면 getattr 기본값 때문에 **예외 없이 빈 값**이 되고, 재조정의
16973
+ # 종목 대조 가드가 조용히 무력화된다(계좌 교체 후 주문번호가 겹칠 때 남의
16974
+ # 체결을 기록하게 됨). 필드명 계약은 test_cosaq00102_field_contract.py 가 잠근다.
16975
+ # 거래소는 싣지 않는다 — 우리 주문 원장의 exchange 가 권위 있는 값이다.
16976
+ row["symbol"] = row["symbol"] or str(
16977
+ getattr(item, "ShtnIsuNo", "") or getattr(item, "IsuNo", "") or ""
16978
+ ).strip()
16979
+ row["fill_time"] = row["fill_time"] or str(getattr(item, "ExecTime", "") or "").strip()
16980
+
16981
+ for row in out.values():
16982
+ qty = row["filled_qty"]
16983
+ row["avg_price"] = (row.pop("_amount") / qty) if qty > 0 else 0.0
16984
+ return out
16985
+ except Exception as e:
16986
+ context.log("debug", f"COSAQ00102 batch fill query exception: {e}", node_id)
16987
+ return {}
16988
+
16853
16989
  async def _query_overseas_futures_fill(
16854
16990
  self,
16855
16991
  ls,
@@ -17879,9 +18015,16 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
17879
18015
  context.log("error", f"Modify order blocked: {resolve_error}", node_id)
17880
18016
  return self._error_result(resolve_error)
17881
18017
 
17882
- # 시장 코드 결정
17883
- ord_mkt_code = self.STOCK_MARKET_CODES.get(exchange, "82")
17884
-
18018
+ # 시장 코드 결정 — 지원 밖이면 요청을 보내지 않는다(신규주문과 같은 규칙).
18019
+ ord_mkt_code, market_error = resolve_overseas_stock_order_market(exchange, symbol)
18020
+ if market_error:
18021
+ logger.warning(
18022
+ "modify_unsupported_market | node=%s symbol=%s exchange=%r — %s",
18023
+ node_id, symbol, exchange, market_error,
18024
+ )
18025
+ context.log("error", f"{symbol}: {market_error}", node_id)
18026
+ return self._error_result(market_error)
18027
+
17885
18028
  # 호가유형코드 (지정가)
17886
18029
  ordprc_ptn_code = config.get("price_type_code", "00")
17887
18030
 
@@ -18453,8 +18596,27 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
18453
18596
  from programgarden_finance.ls.overseas_stock.order.COSAT00301.blocks import COSAT00301InBlock1
18454
18597
 
18455
18598
 
18456
- # 시장 코드 결정
18457
- ord_mkt_code = self.STOCK_MARKET_CODES.get(exchange, "82")
18599
+ # 시장 코드 결정 — 지원 밖이면 요청을 보내지 않는다(신규·정정과 같은 규칙).
18600
+ # 애초에 주문이 나갈 수 없는 시장이라 취소할 원주문도 없지만, 조용히 나스닥으로
18601
+ # 바꿔 보내면 **다른 시장의 같은 번호** 를 취소하려 드는 셈이라 더 위험하다.
18602
+ ord_mkt_code, market_error = resolve_overseas_stock_order_market(exchange, symbol)
18603
+ if market_error:
18604
+ logger.warning(
18605
+ "cancel_unsupported_market | node=%s symbol=%s exchange=%r — %s",
18606
+ node_id, symbol, exchange, market_error,
18607
+ )
18608
+ context.log("error", f"{symbol}: {market_error}", node_id)
18609
+ return {
18610
+ "cancel_result": {
18611
+ "success": False,
18612
+ "error": market_error,
18613
+ "order_id": order_id,
18614
+ "product": "overseas_stock",
18615
+ "reject_info": unsupported_market_reject(exchange, symbol).model_dump(),
18616
+ },
18617
+ "cancelled_order_id": "",
18618
+ "cancelled_order": None,
18619
+ }
18458
18620
 
18459
18621
  try:
18460
18622
  order_api = ls.overseas_stock().주문().cosat00301(
@@ -21130,6 +21292,7 @@ class WorkflowJob:
21130
21292
  # Checkpoint support
21131
21293
  self._checkpoint_mgr = None # CheckpointManager (lazy init)
21132
21294
  self._checkpoint_task: Optional[asyncio.Task] = None # 실시간 주기 저장 태스크
21295
+ self._reconcile_task: Optional[asyncio.Task] = None # 체결 재조정 주기 태스크
21133
21296
  self._completed_node_ids: Set[str] = set() # 완료된 노드 ID 집합
21134
21297
 
21135
21298
  # Per-node diagnostic cache for get_state() (in-memory only, not persisted to checkpoint)
@@ -21379,6 +21542,7 @@ class WorkflowJob:
21379
21542
  )
21380
21543
  # 실시간 checkpoint 주기 저장 시작
21381
21544
  self._start_checkpoint_loop()
21545
+ self._start_reconcile_loop()
21382
21546
  await self._event_loop()
21383
21547
  elif has_event_sources and self.context.is_dry_run:
21384
21548
  logger.info(
@@ -21406,6 +21570,7 @@ class WorkflowJob:
21406
21570
  # 정상 완료 → checkpoint 삭제
21407
21571
  self._delete_checkpoint()
21408
21572
  await self._stop_checkpoint_loop()
21573
+ await self._stop_reconcile_loop()
21409
21574
 
21410
21575
  # 🆕 Job 완료 알림
21411
21576
  await self.context.notify_job_state(self.status, self.stats)
@@ -23781,6 +23946,7 @@ class WorkflowJob:
23781
23946
  # Checkpoint 저장 (cleanup 전에)
23782
23947
  await self._save_checkpoint()
23783
23948
  await self._stop_checkpoint_loop()
23949
+ await self._stop_reconcile_loop()
23784
23950
 
23785
23951
  self.context.stop()
23786
23952
 
@@ -24295,6 +24461,111 @@ class WorkflowJob:
24295
24461
  except asyncio.CancelledError:
24296
24462
  pass
24297
24463
 
24464
+ # ── 체결 재조정 주기 태스크 (2026-09-14) ────────────────────────────────
24465
+ #: 재조정 주기. 계좌 추적기가 60초마다 같은 앱키를 쓰므로 그보다 넉넉히 둔다.
24466
+ #: LS 주문체결내역 조회는 앱키당 2초 1회이고, 재조정은 **날짜당 1회**만 부른다.
24467
+ RECONCILE_INTERVAL_SEC = 180.0
24468
+ #: 접수 직후 인라인 확인 창(4회 x 2초)과 겹치지 않게 두는 유예.
24469
+ RECONCILE_MIN_ORDER_AGE_SEC = 60.0
24470
+
24471
+ def _find_account_tracker_entry(self) -> Optional[Dict[str, Any]]:
24472
+ """재조정에 쓸 해외주식 계좌 추적기 엔트리(있으면).
24473
+
24474
+ 추적기는 이미 60초 주기로 잔고를 들고 있다 — 유령 포지션 대조에 쓸 보유수량과
24475
+ 매도 추정에 쓸 평균매입가를 **추가 브로커 호출 없이** 여기서 얻는다.
24476
+ """
24477
+ for entry in self._active_trackers.values():
24478
+ if not isinstance(entry, dict):
24479
+ continue
24480
+ if entry.get("type") != "account_tracker":
24481
+ continue
24482
+ if entry.get("tracker") is not None and entry.get("ls") is not None:
24483
+ return entry
24484
+ return None
24485
+
24486
+ @staticmethod
24487
+ def _account_positions_snapshot(acct_tracker: Any) -> Optional[Dict[str, Any]]:
24488
+ """계좌 추적기 캐시 → ``{종목: {quantity, avg_price}}``.
24489
+
24490
+ 🔴 캐시를 아직 못 채웠으면 **None** 을 돌려준다(빈 dict 아님). 빈 dict 는 "보유 0"
24491
+ 과 구분되지 않아, 실제로 들고 있는 포지션을 전부 "자동매매 밖에서 팔렸다" 로
24492
+ 기록하게 만든다.
24493
+ """
24494
+ positions = getattr(acct_tracker, "_positions", None)
24495
+ if not isinstance(positions, dict) or not positions:
24496
+ return None
24497
+ out: Dict[str, Any] = {}
24498
+ for symbol, pos in positions.items():
24499
+ try:
24500
+ qty = float(getattr(pos, "quantity", 0) or 0)
24501
+ except (TypeError, ValueError):
24502
+ qty = 0.0
24503
+ avg = getattr(pos, "buy_price", None)
24504
+ try:
24505
+ avg = float(avg) if avg is not None else None
24506
+ except (TypeError, ValueError):
24507
+ avg = None
24508
+ out[str(symbol)] = {"quantity": qty, "avg_price": avg}
24509
+ return out
24510
+
24511
+ async def _reconcile_fills_once(self) -> Optional[Dict[str, Any]]:
24512
+ """한 주기 재조정. 준비가 안 됐으면 조용히 건너뛴다(None)."""
24513
+ from .database.fill_reconciler import reconcile_workflow_fills
24514
+
24515
+ tracker = getattr(self.context, "_workflow_position_tracker", None)
24516
+ if tracker is None:
24517
+ return None
24518
+ entry = self._find_account_tracker_entry()
24519
+ if entry is None:
24520
+ return None
24521
+
24522
+ ls = entry["ls"]
24523
+ node_id = "fill_reconciler"
24524
+
24525
+ async def _fetch(order_date: str) -> Dict[str, Dict[str, Any]]:
24526
+ return await self._query_overseas_stock_fills_by_date(
24527
+ ls, order_date, self.context, node_id
24528
+ )
24529
+
24530
+ report = await reconcile_workflow_fills(
24531
+ tracker,
24532
+ fetch_fills_by_date=_fetch,
24533
+ account_positions=self._account_positions_snapshot(entry["tracker"]),
24534
+ min_age_seconds=self.RECONCILE_MIN_ORDER_AGE_SEC,
24535
+ )
24536
+ if report.recorded_fills or report.phantom_positions or report.errors:
24537
+ logger.info("fill_reconcile | %s", report.as_dict())
24538
+ return report.as_dict()
24539
+
24540
+ async def _reconcile_loop(self) -> None:
24541
+ """주기 재조정. 한 주기의 실패가 루프를 죽이지 않는다."""
24542
+ try:
24543
+ while True:
24544
+ await asyncio.sleep(self.RECONCILE_INTERVAL_SEC)
24545
+ try:
24546
+ await self._reconcile_fills_once()
24547
+ except Exception as e:
24548
+ logger.warning("fill_reconcile_cycle_failed: %s", e)
24549
+ except asyncio.CancelledError:
24550
+ pass
24551
+
24552
+ def _start_reconcile_loop(self) -> None:
24553
+ """체결 재조정 주기 실행 시작."""
24554
+ if self._reconcile_task is not None:
24555
+ return
24556
+ self._reconcile_task = asyncio.ensure_future(self._reconcile_loop())
24557
+ logger.debug("Fill reconcile loop 시작 (%s초 주기)", self.RECONCILE_INTERVAL_SEC)
24558
+
24559
+ async def _stop_reconcile_loop(self) -> None:
24560
+ """체결 재조정 중단."""
24561
+ if self._reconcile_task and not self._reconcile_task.done():
24562
+ self._reconcile_task.cancel()
24563
+ try:
24564
+ await self._reconcile_task
24565
+ except asyncio.CancelledError:
24566
+ pass
24567
+ self._reconcile_task = None
24568
+
24298
24569
  def _start_checkpoint_loop(self) -> None:
24299
24570
  """실시간 워크플로우에서 checkpoint 주기 저장 시작."""
24300
24571
  if self._checkpoint_task is not None:
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.37.3"
8
+ version = "1.37.4"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -36,7 +36,7 @@ litellm = ">=1.40.0"
36
36
  # 로 통째 실패한다(1.35.1 의 조용한 TypeError 보다 강한 모듈 로드 실패).
37
37
  # (1.26.0 이후로도 context.py 는 WorkflowPnLEvent(**event_data) 에 personal_metrics
38
38
  # 를 무조건 실으므로 그 하한 근거도 이 상한에 포함된다.)
39
- programgarden-core = "^1.28.0"
39
+ programgarden-core = "^1.28.1"
40
40
  programgarden-finance = "^1.9.7"
41
41
  programgarden-community = "^1.15.3"
42
42
 
File without changes