programgarden 1.37.2__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.
- {programgarden-1.37.2 → programgarden-1.37.4}/PKG-INFO +2 -2
- programgarden-1.37.4/programgarden/database/fill_reconciler.py +271 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/database/workflow_position_tracker.py +118 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/executor.py +341 -12
- {programgarden-1.37.2 → programgarden-1.37.4}/pyproject.toml +2 -2
- {programgarden-1.37.2 → programgarden-1.37.4}/README.md +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/__init__.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/binding_validator.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/client.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/code_worker.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/context.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/database/__init__.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/futures_pnl.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/node_runner.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/order_lifecycle.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/resolver.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/resource/context.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.37.2 → programgarden-1.37.4}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.37.2 → 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
|
+
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.
|
|
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
|
{programgarden-1.37.2 → programgarden-1.37.4}/programgarden/database/workflow_position_tracker.py
RENAMED
|
@@ -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.
|
|
@@ -15523,7 +15576,7 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
15523
15576
|
# 을 낸 정상 무신호 날이 매 실행 노드 FAILED + 사용자 알림으로 승격된다
|
|
15524
15577
|
# (D2, 벤치 2026-09-06 이 없애려던 바로 그 증상). 그래서 분류를 먼저
|
|
15525
15578
|
# 돌리고, **확실한 배선 고장**(`hard`)일 때만 NO_SYMBOL 로 승격한다.
|
|
15526
|
-
unfilled = self._describe_unfilled_order_fields(order, context, node_type)
|
|
15579
|
+
unfilled = self._describe_unfilled_order_fields(order, context, node_type, node_id)
|
|
15527
15580
|
reason, detail = self._diagnose_empty_reason(
|
|
15528
15581
|
order, config, raw_order_expr, context, node_id=node_id
|
|
15529
15582
|
)
|
|
@@ -15912,6 +15965,7 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
15912
15965
|
order: Any,
|
|
15913
15966
|
context: Optional["ExecutionContext"] = None,
|
|
15914
15967
|
node_type: str = "",
|
|
15968
|
+
node_id: Optional[str] = None,
|
|
15915
15969
|
) -> Optional[Dict[str, Any]]:
|
|
15916
15970
|
"""주문 입력이 **채워지지 않아서** 주문을 못 만든 경우의 구체 사유.
|
|
15917
15971
|
|
|
@@ -15990,9 +16044,42 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
15990
16044
|
# 수량이 0/음수인 것만으로는 승격하지 않는다 — 자금 부족·소수점 잔량 등
|
|
15991
16045
|
# **정상적으로 오늘 못 사는 날**이 있고, 그건 `_diagnose_empty_reason` 이
|
|
15992
16046
|
# no_signal / fractional_only 로 더 정확히 분류한다.
|
|
15993
|
-
|
|
15994
|
-
|
|
15995
|
-
)
|
|
16047
|
+
# 🔴 2026-09-14 prod 실관측(엔진 1.37.2 첫 판단) — 거짓 경보 교정.
|
|
16048
|
+
# 매수 노드가 `{{ item.symbol }}` 을 쓰는데 상류(SymbolFilter)가 **정상적으로**
|
|
16049
|
+
# 빈 목록을 냈다(관심종목을 이미 보유 → 오늘 살 것 없음). 반복이 아예 일어나지
|
|
16050
|
+
# 않아 템플릿이 리터럴로 남았는데, 그걸 `unresolved_template` = 배선 고장으로
|
|
16051
|
+
# 승격해 **매 실행 노드 FAILED + 사용자 알림**이 됐다. "오늘 살 게 없다" 는
|
|
16052
|
+
# 정상이다.
|
|
16053
|
+
# → 반복 중이 아닐 때 남은 **아이템 바인딩**(`{{ item… }}` / `{{ index }}`)은
|
|
16054
|
+
# 승격하지 않고 `_diagnose_empty_reason` 에 넘긴다. 그쪽은 상류 리스트 포트로
|
|
16055
|
+
# "상류가 비어 반복이 안 됨(no_signal)" 과 "배열 소스 없이 item 을 씀(unbound)"
|
|
16056
|
+
# 을 이미 가른다 — 이 함수의 계약대로 분류는 그쪽 몫이다.
|
|
16057
|
+
# 반복 **중인데도** 안 풀린 템플릿은 종전대로 고장이다(항목이 있는데 못 읽었다).
|
|
16058
|
+
def _is_item_binding(value: Any) -> bool:
|
|
16059
|
+
if not isinstance(value, str):
|
|
16060
|
+
return False
|
|
16061
|
+
return bool(re.search(r"\{\{\s*(item|index|total)\b", value))
|
|
16062
|
+
|
|
16063
|
+
# 판정 축은 "반복 중인가" 가 아니라 **상류 배열이 있는데 비었는가** 다.
|
|
16064
|
+
# · 상류 리스트 포트가 있고 비었다 → 오늘 줄 게 없어 반복이 안 된 것(정상)
|
|
16065
|
+
# · 리스트 포트가 아예 없다 → 배열 소스 없이 item 을 쓴 배선 고장
|
|
16066
|
+
# `_upstream_array_is_empty` 가 이미 그 셋(True/False/None)을 가른다.
|
|
16067
|
+
upstream_empty = False
|
|
16068
|
+
if not iterating and context is not None and node_id:
|
|
16069
|
+
try:
|
|
16070
|
+
upstream_empty = _upstream_array_is_empty(_input_namespace(context, node_id)) is True
|
|
16071
|
+
except Exception: # noqa: BLE001 — 진단이 실행을 막지 않는다
|
|
16072
|
+
upstream_empty = False
|
|
16073
|
+
hard_kinds = {
|
|
16074
|
+
key
|
|
16075
|
+
for key, kind in kinds.items()
|
|
16076
|
+
if kind == "invalid"
|
|
16077
|
+
or (
|
|
16078
|
+
kind == "unresolved_template"
|
|
16079
|
+
and not (upstream_empty and _is_item_binding(order.get(key)))
|
|
16080
|
+
)
|
|
16081
|
+
}
|
|
16082
|
+
hard = item_shape is not None or bool(hard_kinds)
|
|
15996
16083
|
|
|
15997
16084
|
labels = NewOrderNodeExecutor._ORDER_FIELD_LABELS
|
|
15998
16085
|
kind_ko = {
|
|
@@ -16297,10 +16384,32 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
16297
16384
|
context.log("warning", "주문 타입 자동 변환: 해외주식 매수는 시장가 불가 → 현재가 기준 지정가 주문으로 전환", node_id)
|
|
16298
16385
|
ordprc_ptn_code = "00"
|
|
16299
16386
|
|
|
16300
|
-
|
|
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
|
+
)
|
|
16301
16402
|
|
|
16302
|
-
#
|
|
16303
|
-
|
|
16403
|
+
# 지정가 주문인데 가격이 없으면 현재가 조회.
|
|
16404
|
+
# 🔴 2026-09-14 — 종전엔 **매수만** 이 경로를 탔다. 그래서 매도를 지정가로 두고 가격을
|
|
16405
|
+
# 비우면 price=0 이 그대로 나가 거부됐다. 미국주식 **주간거래(Blue Ocean) 세션은
|
|
16406
|
+
# 지정가만 받는다**(rsp_cd 00891 "주간거래는 지정가로만 주문이 가능합니다", 실계좌
|
|
16407
|
+
# 관측) — 그 시간대에 손절·익절을 쓰려면 매도도 지정가여야 하는데, 사람이 종목마다
|
|
16408
|
+
# 가격을 적을 수는 없다(손절 대상은 조건이 고른다). 매수와 같은 규칙으로 맞춘다.
|
|
16409
|
+
# ⚠️ 지정가는 **체결을 보장하지 않는다** — 손절인데 안 팔릴 수 있다. 그건 워크플로우가
|
|
16410
|
+
# 지정가를 선택한 결과이고, 노드 설정에 그대로 드러난다(엔진이 몰래 유형을 바꾸지
|
|
16411
|
+
# 않는다 — 오너 지시 2026-09-14: "다시 보낼 때는 노드로 워크플로우에 있어야 사용자가 안다").
|
|
16412
|
+
if ordprc_ptn_code == "00" and price <= 0:
|
|
16304
16413
|
try:
|
|
16305
16414
|
current_price = await self._get_current_price(ls, symbol, ord_mkt_code, context, node_id)
|
|
16306
16415
|
if current_price and current_price > 0:
|
|
@@ -16334,6 +16443,14 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
16334
16443
|
|
|
16335
16444
|
# 디버그: 응답 전체 출력
|
|
16336
16445
|
context.log("debug", f"COSAT00301 response: rsp_cd={response.rsp_cd}, rsp_msg={response.rsp_msg}", node_id)
|
|
16446
|
+
# 🔴 2026-09-14 — `context.log` 는 리스너 통지 전용이라 **stdout 에 안 나온다**.
|
|
16447
|
+
# 그래서 파드 로그만 보면 접수됐는지 거부됐는지 구분이 안 됐다(주문 결과 0줄).
|
|
16448
|
+
# 증권사 응답 코드는 사고 조사의 1차 근거라 모듈 로거로도 남긴다
|
|
16449
|
+
# (`kubectl logs | grep broker_order_response`).
|
|
16450
|
+
logger.info(
|
|
16451
|
+
"broker_order_response | node=%s tr=COSAT00301 symbol=%s rsp_cd=%s rsp_msg=%s",
|
|
16452
|
+
node_id, symbol, response.rsp_cd, response.rsp_msg,
|
|
16453
|
+
)
|
|
16337
16454
|
|
|
16338
16455
|
if response.error_msg:
|
|
16339
16456
|
reject = map_reject_code(
|
|
@@ -16344,6 +16461,10 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
16344
16461
|
f"Order failed: {symbol} - {response.error_msg} ({reject.cause})",
|
|
16345
16462
|
node_id,
|
|
16346
16463
|
)
|
|
16464
|
+
logger.warning(
|
|
16465
|
+
"broker_order_rejected | node=%s symbol=%s rsp_cd=%s cause=%s msg=%s",
|
|
16466
|
+
node_id, symbol, getattr(response, "rsp_cd", None), reject.cause, response.error_msg,
|
|
16467
|
+
)
|
|
16347
16468
|
await self._notify_order_reject(context, node_id, symbol, reject)
|
|
16348
16469
|
return self._order_result(
|
|
16349
16470
|
False, symbol, exchange, side, qty, price,
|
|
@@ -16373,6 +16494,10 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
16373
16494
|
)
|
|
16374
16495
|
|
|
16375
16496
|
context.log("info", f"Order submitted: {symbol} {side} {qty}@{price} → order_id={order_no}", node_id)
|
|
16497
|
+
logger.info(
|
|
16498
|
+
"broker_order_submitted | node=%s symbol=%s side=%s qty=%s price=%s order_id=%s",
|
|
16499
|
+
node_id, symbol, side, qty, price, order_no,
|
|
16500
|
+
)
|
|
16376
16501
|
|
|
16377
16502
|
# Record workflow order for FIFO tracking (OrderNo가 있는 경우만)
|
|
16378
16503
|
context.record_workflow_order(
|
|
@@ -16792,6 +16917,75 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
16792
16917
|
context.log("debug", f"COSAQ00102 fill query exception: {e}", node_id)
|
|
16793
16918
|
return 0, 0.0
|
|
16794
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
|
+
|
|
16795
16989
|
async def _query_overseas_futures_fill(
|
|
16796
16990
|
self,
|
|
16797
16991
|
ls,
|
|
@@ -17821,9 +18015,16 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
|
|
|
17821
18015
|
context.log("error", f"Modify order blocked: {resolve_error}", node_id)
|
|
17822
18016
|
return self._error_result(resolve_error)
|
|
17823
18017
|
|
|
17824
|
-
# 시장 코드 결정
|
|
17825
|
-
ord_mkt_code =
|
|
17826
|
-
|
|
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
|
+
|
|
17827
18028
|
# 호가유형코드 (지정가)
|
|
17828
18029
|
ordprc_ptn_code = config.get("price_type_code", "00")
|
|
17829
18030
|
|
|
@@ -18395,8 +18596,27 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
|
|
|
18395
18596
|
from programgarden_finance.ls.overseas_stock.order.COSAT00301.blocks import COSAT00301InBlock1
|
|
18396
18597
|
|
|
18397
18598
|
|
|
18398
|
-
# 시장 코드 결정
|
|
18399
|
-
|
|
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
|
+
}
|
|
18400
18620
|
|
|
18401
18621
|
try:
|
|
18402
18622
|
order_api = ls.overseas_stock().주문().cosat00301(
|
|
@@ -21072,6 +21292,7 @@ class WorkflowJob:
|
|
|
21072
21292
|
# Checkpoint support
|
|
21073
21293
|
self._checkpoint_mgr = None # CheckpointManager (lazy init)
|
|
21074
21294
|
self._checkpoint_task: Optional[asyncio.Task] = None # 실시간 주기 저장 태스크
|
|
21295
|
+
self._reconcile_task: Optional[asyncio.Task] = None # 체결 재조정 주기 태스크
|
|
21075
21296
|
self._completed_node_ids: Set[str] = set() # 완료된 노드 ID 집합
|
|
21076
21297
|
|
|
21077
21298
|
# Per-node diagnostic cache for get_state() (in-memory only, not persisted to checkpoint)
|
|
@@ -21321,6 +21542,7 @@ class WorkflowJob:
|
|
|
21321
21542
|
)
|
|
21322
21543
|
# 실시간 checkpoint 주기 저장 시작
|
|
21323
21544
|
self._start_checkpoint_loop()
|
|
21545
|
+
self._start_reconcile_loop()
|
|
21324
21546
|
await self._event_loop()
|
|
21325
21547
|
elif has_event_sources and self.context.is_dry_run:
|
|
21326
21548
|
logger.info(
|
|
@@ -21348,6 +21570,7 @@ class WorkflowJob:
|
|
|
21348
21570
|
# 정상 완료 → checkpoint 삭제
|
|
21349
21571
|
self._delete_checkpoint()
|
|
21350
21572
|
await self._stop_checkpoint_loop()
|
|
21573
|
+
await self._stop_reconcile_loop()
|
|
21351
21574
|
|
|
21352
21575
|
# 🆕 Job 완료 알림
|
|
21353
21576
|
await self.context.notify_job_state(self.status, self.stats)
|
|
@@ -23723,6 +23946,7 @@ class WorkflowJob:
|
|
|
23723
23946
|
# Checkpoint 저장 (cleanup 전에)
|
|
23724
23947
|
await self._save_checkpoint()
|
|
23725
23948
|
await self._stop_checkpoint_loop()
|
|
23949
|
+
await self._stop_reconcile_loop()
|
|
23726
23950
|
|
|
23727
23951
|
self.context.stop()
|
|
23728
23952
|
|
|
@@ -24237,6 +24461,111 @@ class WorkflowJob:
|
|
|
24237
24461
|
except asyncio.CancelledError:
|
|
24238
24462
|
pass
|
|
24239
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
|
+
|
|
24240
24569
|
def _start_checkpoint_loop(self) -> None:
|
|
24241
24570
|
"""실시간 워크플로우에서 checkpoint 주기 저장 시작."""
|
|
24242
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.
|
|
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.
|
|
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
|
|
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.37.2 → programgarden-1.37.4}/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
|