programgarden 1.36.0__tar.gz → 1.37.1__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.36.0 → programgarden-1.37.1}/PKG-INFO +3 -3
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/context.py +351 -12
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/database/workflow_position_tracker.py +707 -169
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/database/workflow_risk_tracker.py +114 -20
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/executor.py +1632 -215
- {programgarden-1.36.0 → programgarden-1.37.1}/pyproject.toml +3 -3
- {programgarden-1.36.0 → programgarden-1.37.1}/README.md +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/__init__.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/binding_validator.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/client.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/code_worker.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/database/__init__.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/futures_pnl.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/node_runner.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/order_lifecycle.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/resolver.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/resource/context.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.36.0 → programgarden-1.37.1}/programgarden/validation_recommender.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: programgarden
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.37.1
|
|
4
4
|
Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
|
|
5
5
|
License-Expression: AGPL-3.0-or-later
|
|
6
6
|
Author: 프로그램동산
|
|
@@ -15,9 +15,9 @@ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
|
|
|
15
15
|
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
|
-
Requires-Dist: programgarden-community (>=1.15.
|
|
18
|
+
Requires-Dist: programgarden-community (>=1.15.3,<2.0.0)
|
|
19
19
|
Requires-Dist: programgarden-core (>=1.28.0,<2.0.0)
|
|
20
|
-
Requires-Dist: programgarden-finance (>=1.9.
|
|
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)
|
|
23
23
|
Requires-Dist: pydantic (>=2.0.0,<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, timezone
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
15
|
from enum import Enum
|
|
16
16
|
from collections import deque
|
|
17
17
|
import asyncio
|
|
@@ -44,6 +44,25 @@ from programgarden_core.models.resilience import RetryEvent
|
|
|
44
44
|
|
|
45
45
|
logger = logging.getLogger("programgarden.context")
|
|
46
46
|
|
|
47
|
+
# 체결시각을 브로커 프레임이 싣지 않았을 때 **원장에만** 쓰는 표식.
|
|
48
|
+
#
|
|
49
|
+
# 서버로 나가는 OrderFillEvent.fill_time 은 이때 빈 문자열이어야 한다 — 소비자
|
|
50
|
+
# (pg-worker app/order_fill_updates.py)가 `order_date + fill_time` 을 시장 세션
|
|
51
|
+
# 타임존으로 읽어 executed_at 을 **계산된 체결시각**으로 확정하기 때문이다. 값이
|
|
52
|
+
# 없는데 무언가를 채우면 그게 체결시각으로 승격된다(엔진 <= v1.36.0 이
|
|
53
|
+
# `datetime.now().strftime('%H%M%S000')` 를 합성하던 자리가 정확히 그 사고였다).
|
|
54
|
+
#
|
|
55
|
+
# 그런데 원장(workflow_position_lots.fill_datetime = f"{order_date}_{fill_time}")
|
|
56
|
+
# 에 빈 값을 그대로 넣으면 다른 결함이 생긴다: 날짜 필터가
|
|
57
|
+
# `fill_datetime >= f"{start_date}_000000000"` 문자열 비교라
|
|
58
|
+
# ("20260912_" < "20260912_000000000") 그 로트가 **대회 구간 PnL 에서 통째로
|
|
59
|
+
# 사라진다**(workflow_position_tracker.get_workflow_positions / calculate_pnl).
|
|
60
|
+
# 그래서 원장에는 숫자가 아닌 표식을 쓴다 — 시계 값으로 오독될 수 없고(비숫자),
|
|
61
|
+
# 날짜 필터에 포함되며("u" > "0"), FIFO 정렬에서는 그날 알려진 시각들 뒤에 온다.
|
|
62
|
+
# 이 값은 컨텍스트 밖으로 나가지 않는다: _on_tracker_fill_classified 가 이벤트를
|
|
63
|
+
# 만들 때 다시 빈 문자열로 되돌린다.
|
|
64
|
+
UNKNOWN_FILL_TIME = "unknown"
|
|
65
|
+
|
|
47
66
|
|
|
48
67
|
def _honest_execution_count(metrics, product, order_lifecycle_handler):
|
|
49
68
|
"""Refuse to report a futures execution count the ledger cannot know.
|
|
@@ -244,6 +263,10 @@ class ExecutionContext:
|
|
|
244
263
|
# {product: {tr_cd: [(node_id, event_filter, handler)]}}
|
|
245
264
|
self._order_event_handlers: Dict[str, Dict[str, List[tuple]]] = {}
|
|
246
265
|
self._order_event_real_client: Dict[str, Any] = {} # {product: real_client}
|
|
266
|
+
# {product: {stream(TR): master_callable}} — 주문이벤트 노드가 SDK 에 건 마스터 콜백.
|
|
267
|
+
# 정리 때 **이 콜러블만** 떼야 같은 Real 객체를 공유하는 체결 원장 구독(on_sc1_event 등)이
|
|
268
|
+
# 살아남는다(finance 1.9.7 키당 다중 리스너 + on_remove_*_message(listener)).
|
|
269
|
+
self._order_event_masters: Dict[str, Dict[str, Callable]] = {}
|
|
247
270
|
|
|
248
271
|
# === New: Cleanup on flow end (stay_connected=False) ===
|
|
249
272
|
# Stores trackers that should be cleaned up after each flow execution
|
|
@@ -1191,6 +1214,18 @@ class ExecutionContext:
|
|
|
1191
1214
|
"""Get the real_client for a product"""
|
|
1192
1215
|
return self._order_event_real_client.get(product)
|
|
1193
1216
|
|
|
1217
|
+
def set_order_event_masters(self, product: str, masters: Dict[str, Callable]) -> None:
|
|
1218
|
+
"""주문이벤트 노드가 SDK 에 등록한 마스터 콜백을 스트림(TR)별로 기억한다.
|
|
1219
|
+
|
|
1220
|
+
정리(`cleanup_persistent_nodes`)가 `on_remove_<tr>_message(master)` 로 **이 콜러블만** 떼어,
|
|
1221
|
+
같은 Real 객체에 걸린 체결 원장 리스너를 지우지 않게 한다. 같은 product 에 다시 부르면 갱신.
|
|
1222
|
+
"""
|
|
1223
|
+
self._order_event_masters[product] = dict(masters)
|
|
1224
|
+
|
|
1225
|
+
def get_order_event_masters(self, product: str) -> Dict[str, Callable]:
|
|
1226
|
+
"""등록된 마스터 콜백 {stream: callable} (없으면 빈 dict)."""
|
|
1227
|
+
return dict(self._order_event_masters.get(product, {}))
|
|
1228
|
+
|
|
1194
1229
|
async def cleanup_persistent_nodes(self) -> None:
|
|
1195
1230
|
"""
|
|
1196
1231
|
Cleanup all persistent nodes (call on job stop)
|
|
@@ -1277,6 +1312,28 @@ class ExecutionContext:
|
|
|
1277
1312
|
|
|
1278
1313
|
# 4. Order event master callback SDK 레벨 해제
|
|
1279
1314
|
for product, real_client in self._order_event_real_client.items():
|
|
1315
|
+
masters = self._order_event_masters.get(product) or {}
|
|
1316
|
+
if masters:
|
|
1317
|
+
# 스트림별로 **우리 마스터만** 뗀다(리스너 인자). 리스너를 안 받는 구 SDK(<1.9.7)면
|
|
1318
|
+
# 그 키 전체가 떨어지는 옛 동작으로 폴백 — 원장 구독이 같이 사라지므로 warning.
|
|
1319
|
+
for stream, master in masters.items():
|
|
1320
|
+
try:
|
|
1321
|
+
client = getattr(real_client, stream)()
|
|
1322
|
+
remover = getattr(client, f"on_remove_{stream.lower()}_message")
|
|
1323
|
+
try:
|
|
1324
|
+
remover(master)
|
|
1325
|
+
except TypeError:
|
|
1326
|
+
remover()
|
|
1327
|
+
logger.warning(
|
|
1328
|
+
f"SDK on_remove_{stream.lower()}_message accepts no listener — "
|
|
1329
|
+
f"removed the whole {stream} key for {product} (ledger listener may be gone)"
|
|
1330
|
+
)
|
|
1331
|
+
logger.debug(f"Removed {stream} master callback for {product}")
|
|
1332
|
+
except RuntimeError:
|
|
1333
|
+
logger.debug(f"{stream} order event callback already detached for {product}")
|
|
1334
|
+
except (AttributeError, Exception) as e:
|
|
1335
|
+
logger.warning(f"Failed to remove {stream} order event callback for {product}: {e}")
|
|
1336
|
+
continue
|
|
1280
1337
|
try:
|
|
1281
1338
|
if product == "overseas_stock":
|
|
1282
1339
|
real_client.AS0().on_remove_as0_message()
|
|
@@ -1302,6 +1359,7 @@ class ExecutionContext:
|
|
|
1302
1359
|
|
|
1303
1360
|
self._order_event_handlers.clear()
|
|
1304
1361
|
self._order_event_real_client.clear()
|
|
1362
|
+
self._order_event_masters.clear()
|
|
1305
1363
|
|
|
1306
1364
|
# 5. Stop/Close all trackers (WebSocket clients 등)
|
|
1307
1365
|
for node_id, tracker in self._persistent_nodes.items():
|
|
@@ -2512,6 +2570,22 @@ class ExecutionContext:
|
|
|
2512
2570
|
logger.warning(f"Failed to init risk tracker: {e}")
|
|
2513
2571
|
self._workflow_risk_tracker = None
|
|
2514
2572
|
|
|
2573
|
+
def has_workflow_order_ledger(self) -> bool:
|
|
2574
|
+
"""이 실행에 **워크플로우 주문 원장이 존재하는가**(행이 있는지가 아니다).
|
|
2575
|
+
|
|
2576
|
+
트래커는 워크플로우에 PnL 리스너가 하나라도 있을 때만 만들어진다
|
|
2577
|
+
(init_workflow_position_tracker 의
|
|
2578
|
+
``any(hasattr(l, 'on_workflow_pnl_update') ...)`` 게이트). 리스너 0개로
|
|
2579
|
+
엔진을 라이브러리처럼 직접 구동하거나 초기화가 예외로 끝나면 트래커는
|
|
2580
|
+
None 이고, 그러면 record_workflow_order 는 **no-op** 이라 원장에 행이
|
|
2581
|
+
생길 수 없고 find_workflow_order 는 영원히 None 이다.
|
|
2582
|
+
|
|
2583
|
+
정정(ModifyOrder) 경로가 '원장에 행이 없다' 와 '원장 자체가 없다' 를
|
|
2584
|
+
갈라 말하려고 쓴다 — 앞은 "그 주문을 우리가 기록한 적 없다", 뒤는
|
|
2585
|
+
"이 구성에서는 어떤 주문도 조회할 수 없다" 로 운영자 조치가 다르다.
|
|
2586
|
+
"""
|
|
2587
|
+
return self._workflow_position_tracker is not None
|
|
2588
|
+
|
|
2515
2589
|
def record_workflow_order(
|
|
2516
2590
|
self,
|
|
2517
2591
|
order_no: str,
|
|
@@ -2522,6 +2596,8 @@ class ExecutionContext:
|
|
|
2522
2596
|
quantity: int,
|
|
2523
2597
|
price: float,
|
|
2524
2598
|
node_id: str,
|
|
2599
|
+
*,
|
|
2600
|
+
register_risk: bool = True,
|
|
2525
2601
|
) -> None:
|
|
2526
2602
|
"""Record workflow order for FIFO tracking.
|
|
2527
2603
|
|
|
@@ -2537,8 +2613,24 @@ class ExecutionContext:
|
|
|
2537
2613
|
quantity: 수량
|
|
2538
2614
|
price: 가격
|
|
2539
2615
|
node_id: 주문을 실행한 노드 ID
|
|
2616
|
+
register_risk: 리스크 트래커(HWM) 부수효과를 낼지 여부.
|
|
2617
|
+
기본 True 는 **신규 주문** 의미다 — buy 면 HWM 을 등록하고
|
|
2618
|
+
(register_symbol: 수량 누적 + 평단 갱신), 전량 매도면 해제한다.
|
|
2619
|
+
정정(ModifyOrder)처럼 *이미 접수된 같은 주문의 새 주문번호* 를
|
|
2620
|
+
남기는 경로는 False 로 불러야 한다. 정정은 새 포지션이 아니므로
|
|
2621
|
+
HWM 에 수량을 한 번 더 더하거나(같은 물량 이중계상),
|
|
2622
|
+
아직 체결되지도 않은 매도 정정으로 HWM 을 풀어버리면 안 된다.
|
|
2623
|
+
원장 행 자체는 체결 분류((order_no, order_date) 대조)에 필요하므로
|
|
2624
|
+
기록은 그대로 하고 리스크 부수효과만 끈다.
|
|
2540
2625
|
"""
|
|
2541
2626
|
if self._workflow_position_tracker is None:
|
|
2627
|
+
# 원장 자체가 없는 구성(PnL 리스너 0개 / 초기화 실패)이다. 조용히
|
|
2628
|
+
# 지나가면 나중에 find_workflow_order 가 영원히 None 인 이유를 알 수
|
|
2629
|
+
# 없으므로 최소한 흔적은 남긴다(주문마다 나므로 debug).
|
|
2630
|
+
logger.debug(
|
|
2631
|
+
"Workflow order ledger unavailable; not recording order %s (%s %s %s)",
|
|
2632
|
+
order_no, symbol, side, quantity,
|
|
2633
|
+
)
|
|
2542
2634
|
return
|
|
2543
2635
|
|
|
2544
2636
|
try:
|
|
@@ -2556,7 +2648,8 @@ class ExecutionContext:
|
|
|
2556
2648
|
logger.debug(f"Recorded workflow order: {order_no} ({symbol} {side} {quantity})")
|
|
2557
2649
|
|
|
2558
2650
|
# ━━━ Risk Tracker 체결 연동 ━━━
|
|
2559
|
-
|
|
2651
|
+
# register_risk=False 는 "이 행은 새 포지션이 아니다"(정정 경로).
|
|
2652
|
+
if register_risk and self._workflow_risk_tracker:
|
|
2560
2653
|
if side == "buy":
|
|
2561
2654
|
self._workflow_risk_tracker.register_symbol(
|
|
2562
2655
|
symbol=symbol,
|
|
@@ -2574,6 +2667,172 @@ class ExecutionContext:
|
|
|
2574
2667
|
except Exception as e:
|
|
2575
2668
|
logger.warning(f"Failed to record workflow order: {e}")
|
|
2576
2669
|
|
|
2670
|
+
def find_workflow_order(
|
|
2671
|
+
self,
|
|
2672
|
+
order_no: str,
|
|
2673
|
+
order_date: Optional[str] = None,
|
|
2674
|
+
*,
|
|
2675
|
+
symbol: Optional[str] = None,
|
|
2676
|
+
) -> Optional[Dict[str, Any]]:
|
|
2677
|
+
"""원장에 기록된 워크플로우 주문 1건을 **읽기 전용**으로 조회한다.
|
|
2678
|
+
|
|
2679
|
+
정정(ModifyOrder) 경로가 원 주문의 방향(side)·종목·수량·가격을 승계하기
|
|
2680
|
+
위해 쓴다. ModifyOrder 계열 노드 스키마에는 side 필드가 아예 없어
|
|
2681
|
+
(core/programgarden_core/nodes/order.py:245-263 = connection /
|
|
2682
|
+
original_order_id / symbol / exchange), config 에서 읽으면 사실상 항상
|
|
2683
|
+
"buy" 가 된다. 그 값으로 record_workflow_order 를 부르면 보유하지도 않은
|
|
2684
|
+
종목에 롱 HWM 이 등록되고(register_symbol), 이후 그 종목 신규 매수가
|
|
2685
|
+
drawdown 게이트에 막힌다. 방향은 **추측하지 않고 원장에서 승계**한다.
|
|
2686
|
+
|
|
2687
|
+
조회 범위는 이 원장의 product/provider/trading_mode 로 한정한다.
|
|
2688
|
+
주문번호는 정확히 일치하는 행을 먼저 보고, 없으면 0-패딩 차이를 흡수한
|
|
2689
|
+
정규화 형태로 맞춘다(ACK 의 "0000123" 과 체결 프레임의 "123").
|
|
2690
|
+
|
|
2691
|
+
order_date 를 주면 **같은 날짜의 행을 먼저** 본다 — 브로커 주문번호는
|
|
2692
|
+
날짜 안에서만 유일하므로, 날짜가 다른 행을 아무렇게나 집으면 어제 다른
|
|
2693
|
+
주문의 방향을 승계한다.
|
|
2694
|
+
|
|
2695
|
+
그래도 자정을 관통하는 정정(장 마감 직전 접수 → 자정 이후 정정)은 살려야
|
|
2696
|
+
한다. 종전에는 그걸 **전 기간 스캔 + 후보 정확히 1건**으로 풀었는데, 그건
|
|
2697
|
+
워크플로우 DB 가 며칠~몇 주 누적되는 한 거의 항상 실패한다 — 브로커
|
|
2698
|
+
주문번호는 **영업일마다 리셋**되므로(근거: database/workflow_position_tracker.py
|
|
2699
|
+
모듈 독스트링 — 창 가드 수용조건 ④의 근거로 쓰이는 사실) 같은 번호가
|
|
2700
|
+
과거 날짜에 또 있는 순간 후보가 2건이 돼 None → side 미해결 → 브로커 호출
|
|
2701
|
+
0회 거부가 된다.
|
|
2702
|
+
|
|
2703
|
+
그래서 전 기간 폴백을 버리고, 트래커가 이미 세운 **D±1 창 규약**을 그대로
|
|
2704
|
+
쓴다(_order_date_window 와 같은 조건: D-1·D+1 두 칸만, D±2 이상은 보지
|
|
2705
|
+
않는다). 창 후보가 여러 건이면 **종목 일치**로 가른다(tracker 의 창 수용
|
|
2706
|
+
조건 ② 와 같은 조건 — 종목이 다른 후보는 남의 주문번호 재발급이다).
|
|
2707
|
+
종목까지 같은 후보가 둘 이상이면 모호하므로 None — '방향을 추측하지
|
|
2708
|
+
않는다' 는 안전성은 그대로다. 정확일치(같은 날짜) 경로의 의미는 건들지
|
|
2709
|
+
않는다 — 거긴 (주문번호, 주문일자) 동시 일치가 더 강한 증거다(tracker 모듈
|
|
2710
|
+
독스트링과 같은 분배).
|
|
2711
|
+
|
|
2712
|
+
Args:
|
|
2713
|
+
symbol: 주어지면 **창 경로의 후보 판정에만** 쓰인다(정확일치 경로는
|
|
2714
|
+
종전 그대로). 비워두면 창 후보가 정확히 1건일 때만 수용한다.
|
|
2715
|
+
|
|
2716
|
+
Returns:
|
|
2717
|
+
{"order_no", "order_date", "symbol", "exchange", "side",
|
|
2718
|
+
"quantity", "price", "node_id", "job_id"} 또는 None(미발견/조회 실패).
|
|
2719
|
+
"""
|
|
2720
|
+
tracker = self._workflow_position_tracker
|
|
2721
|
+
if tracker is None:
|
|
2722
|
+
return None
|
|
2723
|
+
|
|
2724
|
+
def _norm(value: Any) -> Optional[str]:
|
|
2725
|
+
# workflow_position_tracker._normalize_identifier 의 숫자 규칙과 같은
|
|
2726
|
+
# 접음(0-패딩 제거). 사설 메서드를 건드리지 않으려고 여기서 최소 구현.
|
|
2727
|
+
text = str(value).strip() if value is not None else ""
|
|
2728
|
+
if not text:
|
|
2729
|
+
return None
|
|
2730
|
+
return (text.lstrip("0") or None) if text.isdigit() else text
|
|
2731
|
+
|
|
2732
|
+
def _window(date: Any) -> List[str]:
|
|
2733
|
+
"""정확일치가 실패했을 때만 훑는 후보 날짜 — **D-1 과 D+1, 두 칸**.
|
|
2734
|
+
|
|
2735
|
+
workflow_position_tracker._order_date_window 와 **같은 규약**이다(근거는
|
|
2736
|
+
그 모듈 독스트링: 실시간 체결 프레임의 로컬 날짜 채움 → D-1, LS 야간
|
|
2737
|
+
세션의 전 영업일 파일링 → D+1). 사설 메서드를 건드리지 않으려고 여기
|
|
2738
|
+
최소 구현한다(위 _norm 과 같은 이유). 날짜가 YYYYMMDD 가 아니면 창을
|
|
2739
|
+
넓히지 않는다 — 근거 없는 매칭을 만들지 않기 위해서다.
|
|
2740
|
+
"""
|
|
2741
|
+
try:
|
|
2742
|
+
base = datetime.strptime(str(date or "").strip(), "%Y%m%d")
|
|
2743
|
+
except (TypeError, ValueError):
|
|
2744
|
+
return []
|
|
2745
|
+
return [
|
|
2746
|
+
(base - timedelta(days=1)).strftime("%Y%m%d"),
|
|
2747
|
+
(base + timedelta(days=1)).strftime("%Y%m%d"),
|
|
2748
|
+
]
|
|
2749
|
+
|
|
2750
|
+
def _symbol_matches(row_symbol: Any) -> bool:
|
|
2751
|
+
# tracker._normalize_symbol 과 같은 규칙(strip + upper).
|
|
2752
|
+
return (str(row_symbol or "").strip().upper()
|
|
2753
|
+
== str(symbol or "").strip().upper())
|
|
2754
|
+
|
|
2755
|
+
wanted = _norm(order_no)
|
|
2756
|
+
if wanted is None:
|
|
2757
|
+
return None
|
|
2758
|
+
|
|
2759
|
+
want_symbol = bool(str(symbol or "").strip())
|
|
2760
|
+
|
|
2761
|
+
import sqlite3
|
|
2762
|
+
|
|
2763
|
+
columns = ("order_no, order_date, symbol, exchange, side, "
|
|
2764
|
+
"quantity, price, node_id, job_id")
|
|
2765
|
+
|
|
2766
|
+
def _matching_rows(scope: Tuple[Any, ...], db_path: str,
|
|
2767
|
+
date: Optional[str]) -> list:
|
|
2768
|
+
# 0-패딩 차이는 SQL 로 접을 수 없어(정규화가 파이썬 규칙) 후보를 읽어
|
|
2769
|
+
# 와서 거른다. 날짜를 주면 그날 주문으로 범위를 묶어 스캔을 줄인다.
|
|
2770
|
+
sql = (f"SELECT {columns} FROM workflow_orders "
|
|
2771
|
+
"WHERE product = ? AND provider = ? AND trading_mode = ?")
|
|
2772
|
+
params: Tuple[Any, ...] = scope
|
|
2773
|
+
if date:
|
|
2774
|
+
sql += " AND order_date = ?"
|
|
2775
|
+
params = scope + (date,)
|
|
2776
|
+
sql += " ORDER BY created_at DESC"
|
|
2777
|
+
with sqlite3.connect(db_path) as conn:
|
|
2778
|
+
return [r for r in conn.execute(sql, params).fetchall()
|
|
2779
|
+
if _norm(r[0]) == wanted]
|
|
2780
|
+
|
|
2781
|
+
try:
|
|
2782
|
+
# 🔴 트래커 속성 독출도 try 안에 둔다. 독스트링이 'None(미발견/조회
|
|
2783
|
+
# 실패)' 를 약속하는데, product/provider/trading_mode/db_path 중 하나라도
|
|
2784
|
+
# 없는 트래커(테스트 더블·구판 트래커)를 물면 AttributeError 가 호출자
|
|
2785
|
+
# (정정 경로)로 그대로 올라가 정정 자체를 죽인다. 조회 실패는 None 이다.
|
|
2786
|
+
scope = (tracker.product, tracker.provider, tracker.trading_mode)
|
|
2787
|
+
db_path = tracker.db_path
|
|
2788
|
+
|
|
2789
|
+
def _rows(date: Optional[str]) -> list:
|
|
2790
|
+
return _matching_rows(scope, db_path, date)
|
|
2791
|
+
|
|
2792
|
+
row = None
|
|
2793
|
+
if order_date:
|
|
2794
|
+
same_date = _rows(order_date)
|
|
2795
|
+
if same_date:
|
|
2796
|
+
row = same_date[0]
|
|
2797
|
+
else:
|
|
2798
|
+
# 장 마감 직전 주문 → 자정 이후 정정(자정 관통). 전 기간을 훑지
|
|
2799
|
+
# 않고 **D±1 두 칸**만 본다 — 주문번호는 영업일마다 리셋되므로
|
|
2800
|
+
# 누적된 DB 에서 전체 유일성을 요구하면 멀지않아 항상 거부된다.
|
|
2801
|
+
widened = [r for d in _window(order_date) for r in _rows(d)]
|
|
2802
|
+
if want_symbol:
|
|
2803
|
+
# 창 후보는 '날짜가 한 칸 어긋났을 것' 이라는 추론이다.
|
|
2804
|
+
# 종목이 다른 후보는 남의 주문번호 재발급이므로 버린다
|
|
2805
|
+
# (tracker 창 수용조건 ② 와 같은 조건).
|
|
2806
|
+
widened = [r for r in widened if _symbol_matches(r[2])]
|
|
2807
|
+
if len(widened) != 1:
|
|
2808
|
+
return None
|
|
2809
|
+
row = widened[0]
|
|
2810
|
+
logger.warning(
|
|
2811
|
+
"find_workflow_order: order %s recorded on %s but looked up "
|
|
2812
|
+
"for %s; using the single D±1 match (symbol=%s)",
|
|
2813
|
+
order_no, row[1], order_date, row[2],
|
|
2814
|
+
)
|
|
2815
|
+
else:
|
|
2816
|
+
anywhere = _rows(None)
|
|
2817
|
+
if not anywhere:
|
|
2818
|
+
return None
|
|
2819
|
+
row = anywhere[0]
|
|
2820
|
+
except Exception as e:
|
|
2821
|
+
logger.warning(f"find_workflow_order failed: {e}")
|
|
2822
|
+
return None
|
|
2823
|
+
|
|
2824
|
+
return {
|
|
2825
|
+
"order_no": row[0],
|
|
2826
|
+
"order_date": row[1],
|
|
2827
|
+
"symbol": row[2],
|
|
2828
|
+
"exchange": row[3],
|
|
2829
|
+
"side": row[4],
|
|
2830
|
+
"quantity": row[5],
|
|
2831
|
+
"price": row[6],
|
|
2832
|
+
"node_id": row[7],
|
|
2833
|
+
"job_id": row[8],
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2577
2836
|
def update_workflow_order_fill_price(
|
|
2578
2837
|
self,
|
|
2579
2838
|
order_no: str,
|
|
@@ -2616,7 +2875,7 @@ class ExecutionContext:
|
|
|
2616
2875
|
quantity: int,
|
|
2617
2876
|
price: float,
|
|
2618
2877
|
fill_time: str,
|
|
2619
|
-
commda_code: str = "
|
|
2878
|
+
commda_code: str = "",
|
|
2620
2879
|
*,
|
|
2621
2880
|
execution_id: Optional[str | int] = None,
|
|
2622
2881
|
account_avg_price: Optional[float] = None,
|
|
@@ -2633,8 +2892,15 @@ class ExecutionContext:
|
|
|
2633
2892
|
side: 매매구분 ("buy" | "sell")
|
|
2634
2893
|
quantity: 체결 수량
|
|
2635
2894
|
price: 체결 가격
|
|
2636
|
-
fill_time: 체결시각 (HHMMSSsss)
|
|
2637
|
-
|
|
2895
|
+
fill_time: 체결시각 (HHMMSSsss). 프레임이 체결시각을 싣지 않았으면
|
|
2896
|
+
**빈 문자열**을 넘긴다 — 값을 합성하면 하류(pg-worker)가 그걸
|
|
2897
|
+
거래소 체결시각으로 승격시킨다. 원장 쪽 표기는 이 메서드가
|
|
2898
|
+
UNKNOWN_FILL_TIME 으로 바꿔 넣고, 서버로 나가는 이벤트에서는
|
|
2899
|
+
다시 빈 문자열로 되돌린다(_on_tracker_fill_classified).
|
|
2900
|
+
commda_code: 매체구분코드 — **프레임 값 그대로**. 기본값은 ""(= 프레임이
|
|
2901
|
+
말하지 않음)이다. 종전 기본값 "40"(OPEN API)은 관측되지 않은 값을
|
|
2902
|
+
원장에 증거처럼 남기는 조작값이었다(tracker.detect_anomalies 가
|
|
2903
|
+
`WHERE commda_code='40'` 을 unknown_api 비율 분모로 쓴다).
|
|
2638
2904
|
execution_id: Optional broker execution number, preserved for durable replay detection.
|
|
2639
2905
|
account_avg_price: Optional account average purchase price for this
|
|
2640
2906
|
symbol at fill time; only a workflow sell's residual tail uses it.
|
|
@@ -2646,11 +2912,56 @@ class ExecutionContext:
|
|
|
2646
2912
|
logger.debug("Workflow position tracker not initialized, skipping record_fill")
|
|
2647
2913
|
return "skipped"
|
|
2648
2914
|
|
|
2915
|
+
# 빈 체결시각은 원장 정렬/날짜필터에서 위험하다(UNKNOWN_FILL_TIME 주석 참고).
|
|
2916
|
+
# 원장에만 비숫자 표식을 넣고, 서버로 나가는 값은 비워둔다.
|
|
2917
|
+
ledger_fill_time = fill_time if str(fill_time or "").strip() else UNKNOWN_FILL_TIME
|
|
2918
|
+
|
|
2649
2919
|
try:
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2920
|
+
# 🔴 중복방지 키(execution_id)는 **체결 보존보다 우선하지 않는다**.
|
|
2921
|
+
# tracker.record_fill 은 본문 첫 줄에서 _execution_key(fill) 를 계산하고
|
|
2922
|
+
# (workflow_position_tracker.py 의 record_fill — PendingFill 생성 직후, 버퍼
|
|
2923
|
+
# 락도 DB 연결도 잡기 전), 그 안의 _normalize_identifier 가
|
|
2924
|
+
# · 체결번호가 정수가 아닌 수치 문자열("0.0"/"-0"/"12.5")이면
|
|
2925
|
+
# ValueError("Numeric execution/order identity must be a positive integer")
|
|
2926
|
+
# · str/int 가 아니면 ValueError("Execution/order identity must be a string or integer")
|
|
2927
|
+
# · 체결번호는 쓸 만한데 주문번호/주문일자가 비면
|
|
2928
|
+
# ValueError("Explicit execution identity requires an order number and date")
|
|
2929
|
+
# 를 올린다. 종전에는 그 예외를 아래 광역 except 가 삼켜 "error" 만
|
|
2930
|
+
# 반환했고 — trade_history · workflow_position_lots 에 **아무것도 남지
|
|
2931
|
+
# 않았다**. 체결 한 건이 통째로 사라지는 것보다 중복방지를 포기하는 쪽이
|
|
2932
|
+
# 언제나 낫다. 그래서 identity 축이 깨지면 체결번호 없이 한 번 더 부른다.
|
|
2933
|
+
# (executor._usable_execution_identity 는 '가능하면 미리 거른다' 는 최적화로
|
|
2934
|
+
# 남아 있지만, 그건 (주문번호, 주문일자) 축만 본다 — 체결번호 자체가
|
|
2935
|
+
# 비정수 수치 문자열인 경우는 여기서만 닫힌다.)
|
|
2936
|
+
#
|
|
2937
|
+
# ⚠️ 재시도가 이중 기록을 만들지 않는 근거(코드 확인,
|
|
2938
|
+
# database/workflow_position_tracker.py):
|
|
2939
|
+
# · record_fill 은 `fill = PendingFill(...)` 다음 줄이 곧바로
|
|
2940
|
+
# `identity = self._execution_key(fill)` 다. 그 사이에 쓰기가 없다.
|
|
2941
|
+
# · 그 뒤의 ValueError 발생 지점(_find_execution → _execution_facts,
|
|
2942
|
+
# _process_fill_internal 의 payload 구성)도 전부 INSERT/UPDATE 보다
|
|
2943
|
+
# 앞이고, _process_fill_internal 의 쓰기는 `BEGIN IMMEDIATE` 트랜잭션
|
|
2944
|
+
# 안이라 예외 시 롤백된다. 버퍼(_pending_fills) 등록도 그 모든
|
|
2945
|
+
# 분기보다 뒤다. 즉 첫 호출이 ValueError 로 끝났다면 이 체결은
|
|
2946
|
+
# 원장에도 버퍼에도 흔적이 없다.
|
|
2947
|
+
# · 단 하나의 예외가 ExecutionIdentityConflictError(ValueError 서브클래스)다.
|
|
2948
|
+
# 이건 "같은 체결번호가 **이미 기록돼 있는데** 체결 사실이 다르다" 는
|
|
2949
|
+
# 신호라, 체결번호를 떼고 다시 넣으면 같은 브로커 체결이 두 번
|
|
2950
|
+
# 기록된다. 그래서 재시도 대상에서 제외하고 종전대로 "error" 로 둔다.
|
|
2951
|
+
#
|
|
2952
|
+
# 🔴 잡는 범위는 **ExecutionIdentityError 하나**다(모든 ValueError 가
|
|
2953
|
+
# 아니다). 종전에는 광역 `except ValueError` 였는데, 그러면
|
|
2954
|
+
# execution_id 가 실려 있기만 하면 identity 와 무관한 tracker 예외까지
|
|
2955
|
+
# 삼켜 체결번호 없이 재기록했다 — 실제로 닿는 경로가 있다:
|
|
2956
|
+
# _execution_facts 의 유한성 가드(수량/가격이 inf·nan 이면 plain
|
|
2957
|
+
# ValueError). 그건 체결번호를 뗀다고 나아지지 않는 **체결 사실의
|
|
2958
|
+
# 문제**라 원장에 비유한 값을 남기는 대신 "error" 로 끝나야 한다.
|
|
2959
|
+
from programgarden.database.workflow_position_tracker import (
|
|
2960
|
+
ExecutionIdentityConflictError,
|
|
2961
|
+
ExecutionIdentityError,
|
|
2962
|
+
)
|
|
2963
|
+
|
|
2964
|
+
base_kwargs: Dict[str, Any] = dict(
|
|
2654
2965
|
order_no=order_no,
|
|
2655
2966
|
order_date=order_date,
|
|
2656
2967
|
symbol=symbol,
|
|
@@ -2658,10 +2969,35 @@ class ExecutionContext:
|
|
|
2658
2969
|
side=side,
|
|
2659
2970
|
quantity=quantity,
|
|
2660
2971
|
price=price,
|
|
2661
|
-
fill_time=
|
|
2972
|
+
fill_time=ledger_fill_time,
|
|
2662
2973
|
commda_code=commda_code,
|
|
2663
|
-
**identity_kwargs,
|
|
2664
2974
|
)
|
|
2975
|
+
if account_avg_price is not None:
|
|
2976
|
+
# 재시도에도 유지한다 — identity 축이 아니고, 매도 잔량 추정의
|
|
2977
|
+
# 유일한 근거다.
|
|
2978
|
+
base_kwargs["account_avg_price"] = account_avg_price
|
|
2979
|
+
|
|
2980
|
+
try:
|
|
2981
|
+
result = await self._workflow_position_tracker.record_fill(
|
|
2982
|
+
**base_kwargs,
|
|
2983
|
+
**({"execution_id": execution_id} if execution_id is not None else {}),
|
|
2984
|
+
)
|
|
2985
|
+
except ExecutionIdentityConflictError:
|
|
2986
|
+
# ExecutionIdentityError 의 서브클래스가 아니라 아래 절에 걸리지
|
|
2987
|
+
# 않지만, "충돌은 재시도하지 않는다" 는 의도를 코드로 못박아 둔다
|
|
2988
|
+
# (누군가 상속 관계를 바꾸면 이 절이 이중 기록을 막는다).
|
|
2989
|
+
raise
|
|
2990
|
+
except ExecutionIdentityError as identity_err:
|
|
2991
|
+
if execution_id is None:
|
|
2992
|
+
# 체결번호를 싣지도 않았는데 난 ValueError 는 identity 축이
|
|
2993
|
+
# 아니다 — 떼어낼 것이 없으므로 종전대로 처리한다.
|
|
2994
|
+
raise
|
|
2995
|
+
logger.warning(
|
|
2996
|
+
"Execution identity unusable for fill %s/%s (execution_id=%r): %s "
|
|
2997
|
+
"— retrying without it (replay protection dropped, fill preserved)",
|
|
2998
|
+
order_date, order_no, execution_id, identity_err,
|
|
2999
|
+
)
|
|
3000
|
+
result = await self._workflow_position_tracker.record_fill(**base_kwargs)
|
|
2665
3001
|
logger.info(f"Recorded workflow fill: {order_no} ({symbol} {side} {quantity}@{price}) → {result}")
|
|
2666
3002
|
|
|
2667
3003
|
# 체결 후 PnL refresh 트리거
|
|
@@ -2732,7 +3068,10 @@ class ExecutionContext:
|
|
|
2732
3068
|
side=fill.side,
|
|
2733
3069
|
quantity=fill.quantity,
|
|
2734
3070
|
price=fill.price,
|
|
2735
|
-
fill_time
|
|
3071
|
+
# 원장 전용 표식은 서버로 내보내지 않는다 — 빈 fill_time 이어야
|
|
3072
|
+
# pg-worker 의 `and fill_time` 가드가 걸려 executed_at 이 정직한
|
|
3073
|
+
# received_at 폴백(+ executed_at_source 태그)이 된다.
|
|
3074
|
+
fill_time=("" if fill.fill_time == UNKNOWN_FILL_TIME else fill.fill_time),
|
|
2736
3075
|
product=tracker.product,
|
|
2737
3076
|
provider=tracker.provider,
|
|
2738
3077
|
classification=classification,
|