programgarden 1.34.0__tar.gz → 1.35.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {programgarden-1.34.0 → programgarden-1.35.0}/PKG-INFO +1 -1
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/context.py +28 -7
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/database/workflow_position_tracker.py +422 -56
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/executor.py +35 -6
- {programgarden-1.34.0 → programgarden-1.35.0}/pyproject.toml +1 -1
- {programgarden-1.34.0 → programgarden-1.35.0}/README.md +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/__init__.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/binding_validator.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/client.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/code_worker.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/database/__init__.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/futures_pnl.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/node_runner.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/order_lifecycle.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resolver.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resource/context.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/validation_recommender.py +0 -0
|
@@ -2011,7 +2011,14 @@ class ExecutionContext:
|
|
|
2011
2011
|
"timestamp": now,
|
|
2012
2012
|
|
|
2013
2013
|
# 워크플로우 기본 (전체)
|
|
2014
|
+
# 🔴 `.get(..., 0.0)` 의 기본값은 **키가 아예 없을 때만** 쓰인다.
|
|
2015
|
+
# 계산부는 값을 못 낼 때 키를 빼지 않고 `None` 을 담는다 — 그래서
|
|
2016
|
+
# None 이 그대로 흐른다. **키를 빼는 순간 0.0 이 되살아나** "모름" 이
|
|
2017
|
+
# 다시 "실측 0%" 가 된다. 계산부를 고칠 때 이 규약을 깨지 말 것.
|
|
2014
2018
|
"workflow_pnl_rate": base_workflow_result.get("workflow_pnl_rate", 0.0),
|
|
2019
|
+
"workflow_rate_unavailable_reason": base_workflow_result.get(
|
|
2020
|
+
"workflow_rate_unavailable_reason"
|
|
2021
|
+
),
|
|
2015
2022
|
"workflow_eval_amount": base_workflow_result.get("workflow_eval_amount", 0.0),
|
|
2016
2023
|
"workflow_buy_amount": base_workflow_result.get("workflow_buy_amount", 0.0),
|
|
2017
2024
|
"workflow_pnl_amount": base_workflow_result.get("workflow_pnl_amount", 0.0),
|
|
@@ -2184,7 +2191,11 @@ class ExecutionContext:
|
|
|
2184
2191
|
total_pnl_rate = ((total_eval - total_buy) / total_buy * 100) if total_buy > 0 else 0.0
|
|
2185
2192
|
|
|
2186
2193
|
return {
|
|
2187
|
-
|
|
2194
|
+
# 🔴 트래커가 없다 = **어느 포지션이 이 워크플로우 것인지 판정할 근거가 0**.
|
|
2195
|
+
# 종전엔 그 상태에서 `0.0` 을 발행해 "이 워크플로우는 0% 로 측정됐다" 로
|
|
2196
|
+
# 읽히게 했다. 워크플로우 몫을 모르는 것이지 0인 게 아니다.
|
|
2197
|
+
"workflow_pnl_rate": None,
|
|
2198
|
+
"workflow_rate_unavailable_reason": "no_tracker",
|
|
2188
2199
|
"workflow_eval_amount": 0.0,
|
|
2189
2200
|
"workflow_buy_amount": 0.0,
|
|
2190
2201
|
"workflow_pnl_amount": 0.0,
|
|
@@ -2217,17 +2228,22 @@ class ExecutionContext:
|
|
|
2217
2228
|
start_date=start_date, # 날짜 필터
|
|
2218
2229
|
)
|
|
2219
2230
|
except Exception as e:
|
|
2220
|
-
|
|
2231
|
+
# 🔴 계산이 실패한 것을 **"수익률 0%" 라는 측정값**으로 바꿔 내보내던 자리다.
|
|
2232
|
+
# 가장 조용한 거짓말 경로 — 예외를 삼키고 0 을 발행하면 아래 계층 어디에서도
|
|
2233
|
+
# 실패였다는 사실을 알 수 없다. 비율은 None(모름), 금액만 0 으로 남긴다.
|
|
2234
|
+
# (금액 0 은 "포지션 없음" 과 같은 모양이라 하류 합산을 깨지 않는다.)
|
|
2235
|
+
logger.exception("calculate_workflow_pnl failed: %s", e)
|
|
2221
2236
|
return {
|
|
2222
|
-
"workflow_pnl_rate":
|
|
2237
|
+
"workflow_pnl_rate": None,
|
|
2238
|
+
"workflow_rate_unavailable_reason": "computation_failed",
|
|
2223
2239
|
"workflow_eval_amount": 0.0,
|
|
2224
2240
|
"workflow_buy_amount": 0.0,
|
|
2225
2241
|
"workflow_pnl_amount": 0.0,
|
|
2226
|
-
"other_pnl_rate":
|
|
2242
|
+
"other_pnl_rate": None,
|
|
2227
2243
|
"other_eval_amount": 0.0,
|
|
2228
2244
|
"other_buy_amount": 0.0,
|
|
2229
2245
|
"other_pnl_amount": 0.0,
|
|
2230
|
-
"total_pnl_rate":
|
|
2246
|
+
"total_pnl_rate": None,
|
|
2231
2247
|
"total_eval_amount": 0.0,
|
|
2232
2248
|
"total_buy_amount": 0.0,
|
|
2233
2249
|
"total_pnl_amount": 0.0,
|
|
@@ -2596,6 +2612,7 @@ class ExecutionContext:
|
|
|
2596
2612
|
commda_code: str = "40",
|
|
2597
2613
|
*,
|
|
2598
2614
|
execution_id: Optional[str | int] = None,
|
|
2615
|
+
account_avg_price: Optional[float] = None,
|
|
2599
2616
|
) -> str:
|
|
2600
2617
|
"""Record fill event for FIFO position tracking.
|
|
2601
2618
|
|
|
@@ -2610,9 +2627,11 @@ class ExecutionContext:
|
|
|
2610
2627
|
quantity: 체결 수량
|
|
2611
2628
|
price: 체결 가격
|
|
2612
2629
|
fill_time: 체결시각 (HHMMSSsss)
|
|
2613
|
-
commda_code: 매체구분코드 ("40"=OPEN API,
|
|
2630
|
+
commda_code: 매체구분코드 (프레임 값 그대로; "40"=OPEN API, 기타=수동/HTS)
|
|
2614
2631
|
execution_id: Optional broker execution number, preserved for durable replay detection.
|
|
2615
|
-
|
|
2632
|
+
account_avg_price: Optional account average purchase price for this
|
|
2633
|
+
symbol at fill time; only a workflow sell's residual tail uses it.
|
|
2634
|
+
|
|
2616
2635
|
Returns:
|
|
2617
2636
|
분류 결과: "workflow" | "manual" | "unknown_api" | "pending"
|
|
2618
2637
|
"""
|
|
@@ -2622,6 +2641,8 @@ class ExecutionContext:
|
|
|
2622
2641
|
|
|
2623
2642
|
try:
|
|
2624
2643
|
identity_kwargs = {"execution_id": execution_id} if execution_id is not None else {}
|
|
2644
|
+
if account_avg_price is not None:
|
|
2645
|
+
identity_kwargs["account_avg_price"] = account_avg_price
|
|
2625
2646
|
result = await self._workflow_position_tracker.record_fill(
|
|
2626
2647
|
order_no=order_no,
|
|
2627
2648
|
order_date=order_date,
|
{programgarden-1.34.0 → programgarden-1.35.0}/programgarden/database/workflow_position_tracker.py
RENAMED
|
@@ -87,12 +87,47 @@ class PendingFill:
|
|
|
87
87
|
commda_code: str
|
|
88
88
|
received_at: datetime
|
|
89
89
|
execution_id: Optional[str | int] = None
|
|
90
|
+
# Account average purchase price for this symbol at the moment of the fill,
|
|
91
|
+
# read from the broker snapshot before it refreshes. Used only to estimate a
|
|
92
|
+
# workflow sell's residual tail (see _process_sell_fifo); None → no estimate.
|
|
93
|
+
account_avg_price: Optional[float] = None
|
|
90
94
|
|
|
91
95
|
|
|
92
96
|
class ExecutionIdentityConflictError(ValueError):
|
|
93
97
|
"""An explicit execution identity was replayed with different fill facts."""
|
|
94
98
|
|
|
95
99
|
|
|
100
|
+
# LS 통신매체코드(CommdaCode / MdaCode) — 출처: src/finance/docs/cidbq02400_contract.md:74-77
|
|
101
|
+
# "The published media map is 00=branch, 22=iPhone, 23=Android, 41=API, 43=Robo API,
|
|
102
|
+
# 85=HTS, 96=final settlement, LP=loss cut, SK=CashCall and SO=conditional order.
|
|
103
|
+
# The example uses 40, which the table does not map. Preserve it as returned;
|
|
104
|
+
# do not classify it by guessing a nearby code."
|
|
105
|
+
# '40' 은 표에 없지만 우리 OPEN API 주문이 실제로 받는 값이다(dev verified_fills 실측 4건 +
|
|
106
|
+
# 2026-09-12 해외주식 AS1 실측: 우리 매도 주문 244 → 40) — 표의 41/43 과 함께 API 묶음으로 둔다.
|
|
107
|
+
# 🔴 해외주식 AS1 실측(2026-09-12, 실계좌 NIO 1주씩): HTS → 85(표와 일치) · iPhone 투혼앱 → **51**
|
|
108
|
+
# (표의 22 아님) · 투혼 웹 → **03**(표에 없음). 표는 해외선물 TR 문서라 해외주식 푸시와 앱/웹 코드가
|
|
109
|
+
# 다르다. 관측된 값만 사람 채널에 추가한다 — 표에 없고 관측도 안 된 값은 여전히 "other".
|
|
110
|
+
MEDIA_CODES_HUMAN = frozenset({"85", "22", "23", "00", "51", "03"}) # HTS · iPhone(표) · Android(표) · 지점 · 투혼앱(실측) · 투혼웹(실측)
|
|
111
|
+
MEDIA_CODES_API = frozenset({"40", "41", "43"}) # OPEN API(실측) · API · Robo API
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def media_channel(commda_code: str | None) -> str:
|
|
115
|
+
"""Map a broker media code to "human" | "api" | "unknown" | "other".
|
|
116
|
+
|
|
117
|
+
unknown = blank/None (the frame said nothing); other = a code the published
|
|
118
|
+
table does not attribute to a person or an API client (96/LP/SK/SO …) or one
|
|
119
|
+
the table does not list at all. Neither is ever asserted to be a person.
|
|
120
|
+
"""
|
|
121
|
+
code = (commda_code or "").strip()
|
|
122
|
+
if not code:
|
|
123
|
+
return "unknown"
|
|
124
|
+
if code in MEDIA_CODES_HUMAN:
|
|
125
|
+
return "human"
|
|
126
|
+
if code in MEDIA_CODES_API:
|
|
127
|
+
return "api"
|
|
128
|
+
return "other"
|
|
129
|
+
|
|
130
|
+
|
|
96
131
|
class WorkflowPositionTracker:
|
|
97
132
|
"""
|
|
98
133
|
워크플로우 포지션 FIFO 추적기
|
|
@@ -251,6 +286,19 @@ class WorkflowPositionTracker:
|
|
|
251
286
|
for column in ("execution_id", "normalized_order_no", "execution_payload"):
|
|
252
287
|
if column not in columns:
|
|
253
288
|
cursor.execute(f"ALTER TABLE trade_history ADD COLUMN {column} TEXT")
|
|
289
|
+
# Additive columns for the account-average-price sell estimate. A
|
|
290
|
+
# workflow sell that empties the strategy's own lots and still has a
|
|
291
|
+
# tail records that tail here (the residual quantity, the account
|
|
292
|
+
# average purchase price it was estimated against, the source label,
|
|
293
|
+
# and the estimated PnL). realized_pnl stays FIFO-matched only; these
|
|
294
|
+
# never enter _execution_facts / conflict detection. NULL on every
|
|
295
|
+
# row that carries no estimate (buys, fully matched sells, old rows).
|
|
296
|
+
for column, coltype in (("unmatched_qty", "REAL"),
|
|
297
|
+
("estimate_basis_price", "REAL"),
|
|
298
|
+
("estimate_source", "TEXT"),
|
|
299
|
+
("estimated_pnl", "REAL")):
|
|
300
|
+
if column not in columns:
|
|
301
|
+
cursor.execute(f"ALTER TABLE trade_history ADD COLUMN {column} {coltype}")
|
|
254
302
|
cursor.execute("""
|
|
255
303
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_trade_history_execution_v1
|
|
256
304
|
ON trade_history(product, provider, trading_mode, order_date,
|
|
@@ -386,6 +434,7 @@ class WorkflowPositionTracker:
|
|
|
386
434
|
commda_code: str,
|
|
387
435
|
*,
|
|
388
436
|
execution_id: Optional[str | int] = None,
|
|
437
|
+
account_avg_price: Optional[float] = None,
|
|
389
438
|
) -> str:
|
|
390
439
|
"""
|
|
391
440
|
체결 기록 및 FIFO 처리
|
|
@@ -401,7 +450,12 @@ class WorkflowPositionTracker:
|
|
|
401
450
|
quantity: 수량
|
|
402
451
|
price: 체결가
|
|
403
452
|
fill_time: 체결시각 (HHMMSSsss)
|
|
404
|
-
commda_code: 매체구분코드 (40=OPEN API)
|
|
453
|
+
commda_code: 매체구분코드 (40=OPEN API). 프레임에서 온 값을 그대로
|
|
454
|
+
받는다 — 우리 주문과 일치하면 매체코드와 무관하게 workflow 로,
|
|
455
|
+
일치하지 않는 fill 만 매체코드로 manual(HTS)/unknown_api 를 가른다.
|
|
456
|
+
account_avg_price: Optional account average purchase price for this
|
|
457
|
+
symbol at fill time. Only a workflow sell whose own lots run out
|
|
458
|
+
uses it, to estimate the residual tail. None → no estimate.
|
|
405
459
|
execution_id: Optional broker execution identity. Positive numeric
|
|
406
460
|
strings/integers ignore padding; opaque strings retain case.
|
|
407
461
|
None, blank, and zero mean no identity and retain legacy replay
|
|
@@ -417,13 +471,13 @@ class WorkflowPositionTracker:
|
|
|
417
471
|
This is a persistence prerequisite, not broker-history/TC3 integration.
|
|
418
472
|
|
|
419
473
|
Returns:
|
|
420
|
-
분류 결과: "workflow" | "manual" | "unknown_api" | "pending"
|
|
474
|
+
분류 결과: "workflow" | "manual" | "unknown_api" | "other" | "pending"
|
|
421
475
|
"""
|
|
422
476
|
fill = PendingFill(
|
|
423
477
|
order_no=order_no, order_date=order_date, symbol=symbol,
|
|
424
478
|
exchange=exchange, side=side, quantity=quantity, price=price,
|
|
425
479
|
fill_time=fill_time, commda_code=commda_code, received_at=datetime.now(),
|
|
426
|
-
execution_id=execution_id,
|
|
480
|
+
execution_id=execution_id, account_avg_price=account_avg_price,
|
|
427
481
|
)
|
|
428
482
|
identity = self._execution_key(fill)
|
|
429
483
|
async with self._buffer_lock:
|
|
@@ -437,11 +491,35 @@ class WorkflowPositionTracker:
|
|
|
437
491
|
if previous is not None:
|
|
438
492
|
return previous
|
|
439
493
|
|
|
440
|
-
|
|
441
|
-
|
|
494
|
+
# A fill that matches one of our recorded workflow orders is ours,
|
|
495
|
+
# whatever communication-media code the broker stamped on it — the
|
|
496
|
+
# recorded order is the authoritative ownership signal, so it is
|
|
497
|
+
# checked first. (Now that the real media code is forwarded instead
|
|
498
|
+
# of a hardcoded '40', our own OPEN-API fills may not carry '40'; the
|
|
499
|
+
# old media-first ordering would then misfile them as manual.)
|
|
442
500
|
if self._is_workflow_fill(fill):
|
|
443
501
|
return await self._process_fill_internal(fill, "workflow")
|
|
444
|
-
|
|
502
|
+
# No matching order. Classify by the published media table
|
|
503
|
+
# (MEDIA_CODES_* above) — never by "not 40":
|
|
504
|
+
# human (85 HTS / 22 iPhone / 23 Android / 00 branch) → "manual"
|
|
505
|
+
# other (96/LP/SK/SO or unlisted) → "other"
|
|
506
|
+
# api (40/41/43) or blank → buffer below
|
|
507
|
+
channel = media_channel(commda_code)
|
|
508
|
+
if channel == "human":
|
|
509
|
+
return await self._process_fill_internal(fill, "manual")
|
|
510
|
+
if channel == "other":
|
|
511
|
+
# A code the table does not attribute — more of these exist than
|
|
512
|
+
# we have measured (only 40/51/85/03 are live-observed so far).
|
|
513
|
+
# Log it so the table can grow from evidence, never from guesses.
|
|
514
|
+
logger.info("Unlisted media code %r on fill %s/%s — classified other",
|
|
515
|
+
commda_code, order_date, order_no)
|
|
516
|
+
return await self._process_fill_internal(fill, "other")
|
|
517
|
+
|
|
518
|
+
# An API code or an empty media code ("medium unknown"): buffer to
|
|
519
|
+
# give a lagging order record a chance to arrive, then
|
|
520
|
+
# _process_timeout_fill files it as workflow (matched) or unknown_api
|
|
521
|
+
# (unmatched). An unknown medium is never asserted to be a person's
|
|
522
|
+
# HTS trade, so it floors to unknown_api rather than manual.
|
|
445
523
|
self._next_pending_fill += 1
|
|
446
524
|
key = self._next_pending_fill
|
|
447
525
|
self._pending_fills[key] = fill
|
|
@@ -554,6 +632,7 @@ class WorkflowPositionTracker:
|
|
|
554
632
|
"reported_execution_id": fill.execution_id,
|
|
555
633
|
}, sort_keys=True)
|
|
556
634
|
realized_pnl = 0.0
|
|
635
|
+
estimate = None
|
|
557
636
|
|
|
558
637
|
if fill.side == "buy":
|
|
559
638
|
# 매수: 새 로트 생성
|
|
@@ -570,8 +649,9 @@ class WorkflowPositionTracker:
|
|
|
570
649
|
))
|
|
571
650
|
else:
|
|
572
651
|
# 매도: FIFO 청산 (현재 trading_mode 내에서만)
|
|
573
|
-
realized_pnl = self._process_sell_fifo(
|
|
574
|
-
cursor, fill.symbol, fill.quantity, fill.price, classification
|
|
652
|
+
realized_pnl, estimate = self._process_sell_fifo(
|
|
653
|
+
cursor, fill.symbol, fill.quantity, fill.price, classification,
|
|
654
|
+
account_avg_price=fill.account_avg_price,
|
|
575
655
|
)
|
|
576
656
|
|
|
577
657
|
# 체결 내역 저장
|
|
@@ -579,14 +659,19 @@ class WorkflowPositionTracker:
|
|
|
579
659
|
INSERT INTO trade_history
|
|
580
660
|
(product, provider, order_no, order_date, symbol, exchange, side, quantity, price,
|
|
581
661
|
fill_datetime, classification, commda_code, realized_pnl, trading_mode, created_at,
|
|
582
|
-
execution_id, normalized_order_no, execution_payload
|
|
583
|
-
|
|
662
|
+
execution_id, normalized_order_no, execution_payload,
|
|
663
|
+
unmatched_qty, estimate_basis_price, estimate_source, estimated_pnl)
|
|
664
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
584
665
|
""", (
|
|
585
666
|
self.product, self.provider,
|
|
586
667
|
fill.order_no, fill.order_date, fill.symbol, fill.exchange,
|
|
587
668
|
fill.side, fill.quantity, fill.price, fill_datetime,
|
|
588
669
|
classification, fill.commda_code, realized_pnl, self.trading_mode, datetime.now().isoformat(),
|
|
589
670
|
identity[5] if identity else None, identity[4] if identity else None, payload,
|
|
671
|
+
estimate["unmatched_qty"] if estimate else None,
|
|
672
|
+
estimate["estimate_basis_price"] if estimate else None,
|
|
673
|
+
estimate["estimate_source"] if estimate else None,
|
|
674
|
+
estimate["estimated_pnl"] if estimate else None,
|
|
590
675
|
))
|
|
591
676
|
|
|
592
677
|
conn.commit()
|
|
@@ -605,62 +690,97 @@ class WorkflowPositionTracker:
|
|
|
605
690
|
quantity: int,
|
|
606
691
|
sell_price: float,
|
|
607
692
|
classification: str,
|
|
608
|
-
|
|
693
|
+
account_avg_price: Optional[float] = None,
|
|
694
|
+
) -> Tuple[float, Optional[Dict[str, Any]]]:
|
|
609
695
|
"""
|
|
610
696
|
FIFO 매도 처리
|
|
611
|
-
|
|
697
|
+
|
|
612
698
|
fill_datetime 순으로 정렬하여 선입선출 방식으로 청산합니다.
|
|
613
|
-
|
|
699
|
+
|
|
700
|
+
classification 이 'workflow' 인 매도는 **workflow 로트만** 소진한다. 수동/타
|
|
701
|
+
API 로트를 먹으면 이 전략이 열지 않은 포지션의 손익까지 실현한 셈이 되기
|
|
702
|
+
때문이다. workflow 로트가 바닥난 뒤 남는 수량(remaining)은 이 전략이 여기서
|
|
703
|
+
사지 않은 물량을 판 것이므로, 계좌 평균매입가(account_avg_price)가 주어지면
|
|
704
|
+
그 잔량을 추정손익으로 매도 행에 기록한다(realized_pnl 은 FIFO 매칭분만 유지).
|
|
705
|
+
non-workflow 매도는 종전과 동일하게 분류 무관 로트를 FIFO 소진한다.
|
|
706
|
+
|
|
614
707
|
Args:
|
|
615
708
|
cursor: DB 커서
|
|
616
709
|
symbol: 종목코드
|
|
617
710
|
quantity: 매도 수량
|
|
618
711
|
sell_price: 매도가
|
|
619
|
-
classification: 분류 (
|
|
620
|
-
|
|
712
|
+
classification: 분류 ("workflow" 면 workflow 로트만 소진)
|
|
713
|
+
account_avg_price: 계좌 평균매입가 (workflow 매도의 잔량 추정용, 없으면 추정 없음)
|
|
714
|
+
|
|
621
715
|
Returns:
|
|
622
|
-
실현 손익
|
|
716
|
+
(실현 손익 FIFO 매칭분, 추정 정보 dict 또는 None)
|
|
623
717
|
"""
|
|
624
718
|
remaining_to_sell = quantity
|
|
625
719
|
total_realized_pnl = 0.0
|
|
626
|
-
|
|
627
|
-
# fill_datetime 순으로 정렬 (FIFO, 현재 trading_mode 내에서만)
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
720
|
+
|
|
721
|
+
# fill_datetime 순으로 정렬 (FIFO, 현재 trading_mode 내에서만).
|
|
722
|
+
# workflow 매도는 workflow 로트로 소진 대상을 좁힌다.
|
|
723
|
+
if classification == "workflow":
|
|
724
|
+
cursor.execute("""
|
|
725
|
+
SELECT id, buy_price, remaining_qty, classification
|
|
726
|
+
FROM workflow_position_lots
|
|
727
|
+
WHERE symbol = ? AND remaining_qty > 0 AND trading_mode = ?
|
|
728
|
+
AND classification = 'workflow'
|
|
729
|
+
ORDER BY fill_datetime ASC
|
|
730
|
+
""", (symbol, self.trading_mode))
|
|
731
|
+
else:
|
|
732
|
+
cursor.execute("""
|
|
733
|
+
SELECT id, buy_price, remaining_qty, classification
|
|
734
|
+
FROM workflow_position_lots
|
|
735
|
+
WHERE symbol = ? AND remaining_qty > 0 AND trading_mode = ?
|
|
736
|
+
ORDER BY fill_datetime ASC
|
|
737
|
+
""", (symbol, self.trading_mode))
|
|
738
|
+
|
|
635
739
|
lots = cursor.fetchall()
|
|
636
|
-
|
|
740
|
+
|
|
637
741
|
for lot_id, buy_price, remaining_qty, lot_classification in lots:
|
|
638
742
|
if remaining_to_sell <= 0:
|
|
639
743
|
break
|
|
640
|
-
|
|
744
|
+
|
|
641
745
|
sell_qty = min(remaining_to_sell, remaining_qty)
|
|
642
746
|
new_remaining = remaining_qty - sell_qty
|
|
643
|
-
|
|
747
|
+
|
|
644
748
|
# 로트 업데이트
|
|
645
749
|
cursor.execute("""
|
|
646
750
|
UPDATE workflow_position_lots
|
|
647
751
|
SET remaining_qty = ?
|
|
648
752
|
WHERE id = ?
|
|
649
753
|
""", (new_remaining, lot_id))
|
|
650
|
-
|
|
754
|
+
|
|
651
755
|
# 실현 손익 계산
|
|
652
756
|
pnl = (sell_price - buy_price) * sell_qty
|
|
653
757
|
total_realized_pnl += pnl
|
|
654
|
-
|
|
758
|
+
|
|
655
759
|
remaining_to_sell -= sell_qty
|
|
656
|
-
|
|
760
|
+
|
|
657
761
|
logger.debug(f"FIFO sell: lot {lot_id} ({lot_classification}), "
|
|
658
762
|
f"qty={sell_qty}, pnl={pnl:.2f}")
|
|
659
|
-
|
|
763
|
+
|
|
764
|
+
estimate: Optional[Dict[str, Any]] = None
|
|
660
765
|
if remaining_to_sell > 0:
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
766
|
+
if (classification == "workflow" and account_avg_price is not None
|
|
767
|
+
and account_avg_price > 0):
|
|
768
|
+
# 이 전략이 여기서 사지 않은 잔량 — 같은 종목을 계좌의 다른 경로로
|
|
769
|
+
# 사둔 것으로 보고 계좌 평균매입가 기준 추정손익을 기록한다.
|
|
770
|
+
# realized_pnl(FIFO 매칭분)에는 넣지 않고 별도 컬럼으로만 남긴다.
|
|
771
|
+
estimated_pnl = (sell_price - account_avg_price) * remaining_to_sell
|
|
772
|
+
estimate = {
|
|
773
|
+
"unmatched_qty": float(remaining_to_sell),
|
|
774
|
+
"estimate_basis_price": float(account_avg_price),
|
|
775
|
+
"estimate_source": "account_balance_avg_price",
|
|
776
|
+
"estimated_pnl": float(estimated_pnl),
|
|
777
|
+
}
|
|
778
|
+
logger.debug(f"Estimated sell tail: {symbol} qty={remaining_to_sell} "
|
|
779
|
+
f"@avg={account_avg_price} est_pnl={estimated_pnl:.2f}")
|
|
780
|
+
else:
|
|
781
|
+
logger.warning(f"Sell without enough position: {symbol} remaining={remaining_to_sell}")
|
|
782
|
+
|
|
783
|
+
return total_realized_pnl, estimate
|
|
664
784
|
|
|
665
785
|
def _check_workflow_order(self, order_no: str, order_date: str) -> bool:
|
|
666
786
|
"""워크플로우 주문 여부 확인 (현재 trading_mode 기준)"""
|
|
@@ -877,9 +997,20 @@ class WorkflowPositionTracker:
|
|
|
877
997
|
wf_buy = Decimal(0)
|
|
878
998
|
wf_pnl = Decimal(0)
|
|
879
999
|
wf_details: List[PositionDetail] = []
|
|
1000
|
+
# 증거 카운터 — 비율을 **발행해도 되는지** 를 결정한다(금액은 영향 없음).
|
|
1001
|
+
wf_unpriced = 0 # 현재가를 관측하지 못해 평단으로 대체한 종목 수
|
|
1002
|
+
wf_unbased = 0 # 평단이 0 이하라 매수근거가 없는 종목 수
|
|
880
1003
|
|
|
881
1004
|
for symbol, pos in workflow_positions.items():
|
|
882
|
-
|
|
1005
|
+
observed_price = prices.get(symbol)
|
|
1006
|
+
if observed_price is None or observed_price <= 0:
|
|
1007
|
+
wf_unpriced += 1
|
|
1008
|
+
if pos.avg_price <= 0:
|
|
1009
|
+
wf_unbased += 1
|
|
1010
|
+
# 🔴 금액 계산은 **현행 그대로** 둔다(평단 폴백 포함) — 금액을 비우면 모니터링
|
|
1011
|
+
# KPI 합산이 통째로 접히고, 전량 청산한 날의 0 은 진짜 0 이다.
|
|
1012
|
+
# 달라지는 것은 "그 금액으로 비율을 발행할 자격이 있는가" 뿐이다.
|
|
1013
|
+
current_price = observed_price if (observed_price and observed_price > 0) else pos.avg_price
|
|
883
1014
|
mult, side_sign = multipliers.get(symbol, (Decimal(1), Decimal(1)))
|
|
884
1015
|
eval_amount = current_price * pos.quantity * mult
|
|
885
1016
|
buy_amount = pos.avg_price * pos.quantity * mult
|
|
@@ -900,16 +1031,43 @@ class WorkflowPositionTracker:
|
|
|
900
1031
|
pnl_rate=pnl_rate,
|
|
901
1032
|
))
|
|
902
1033
|
|
|
903
|
-
|
|
1034
|
+
# 🔴 계산할 수 없으면 0 이 아니라 **아무것도 발행하지 않는다**(None).
|
|
1035
|
+
#
|
|
1036
|
+
# 종전 `else Decimal(0)` 는 "아무것도 안 샀다" 를 **"0% 로 측정됐다"** 로 바꿔 발행했다.
|
|
1037
|
+
# 그러면 워크플로우를 시작만 해도 스냅샷이 생겨 공유 카드가 「참고 데이터」 배지 달린
|
|
1038
|
+
# 정직한 0.0% 에서 **배지 없는 "실측 0.0%"** 로 바뀐다(승률 0%·손익비 0.00 까지).
|
|
1039
|
+
# 선물은 이미 이 원칙으로 고쳐져 있다(`futures_pnl.unavailable_workflow_pnl`) —
|
|
1040
|
+
# 주식만 안 따라갔다.
|
|
1041
|
+
#
|
|
1042
|
+
# 한 종목이라도 현재가를 못 봤거나(`wf_unpriced`) 평단이 없으면(`wf_unbased`)
|
|
1043
|
+
# 합계 비율이 **부풀려진다**(분모만 줄거나 평가액이 매수액으로 눌린다). 그건 0 이
|
|
1044
|
+
# 아닌 채로 틀리므로 값 자체를 내지 않는다.
|
|
1045
|
+
wf_rate_reason: Optional[str] = None
|
|
1046
|
+
if not workflow_positions:
|
|
1047
|
+
wf_rate_reason = "no_workflow_positions"
|
|
1048
|
+
elif wf_unbased:
|
|
1049
|
+
wf_rate_reason = "basis_unreported"
|
|
1050
|
+
elif wf_unpriced:
|
|
1051
|
+
wf_rate_reason = "price_unreported"
|
|
1052
|
+
elif not wf_buy:
|
|
1053
|
+
wf_rate_reason = "no_workflow_positions"
|
|
1054
|
+
wf_rate = None if wf_rate_reason else (wf_pnl / wf_buy * 100)
|
|
904
1055
|
|
|
905
1056
|
# 그 외 포지션 계산
|
|
906
1057
|
other_eval = Decimal(0)
|
|
907
1058
|
other_buy = Decimal(0)
|
|
908
1059
|
other_pnl = Decimal(0)
|
|
909
1060
|
other_details: List[PositionDetail] = []
|
|
1061
|
+
other_unpriced = 0
|
|
1062
|
+
other_unbased = 0
|
|
910
1063
|
|
|
911
1064
|
for symbol, pos in other_positions.items():
|
|
912
|
-
|
|
1065
|
+
observed_price = prices.get(symbol)
|
|
1066
|
+
if observed_price is None or observed_price <= 0:
|
|
1067
|
+
other_unpriced += 1
|
|
1068
|
+
if pos.avg_price <= 0:
|
|
1069
|
+
other_unbased += 1
|
|
1070
|
+
current_price = observed_price if (observed_price and observed_price > 0) else pos.avg_price
|
|
913
1071
|
mult, side_sign = multipliers.get(symbol, (Decimal(1), Decimal(1)))
|
|
914
1072
|
eval_amount = current_price * pos.quantity * mult
|
|
915
1073
|
buy_amount = pos.avg_price * pos.quantity * mult
|
|
@@ -930,13 +1088,26 @@ class WorkflowPositionTracker:
|
|
|
930
1088
|
pnl_rate=pnl_rate,
|
|
931
1089
|
))
|
|
932
1090
|
|
|
933
|
-
|
|
1091
|
+
# wf_rate 와 같은 규율 — 근거가 없으면 비율을 발행하지 않는다.
|
|
1092
|
+
other_rate = (
|
|
1093
|
+
None
|
|
1094
|
+
if (not other_positions or other_unbased or other_unpriced or not other_buy)
|
|
1095
|
+
else (other_pnl / other_buy * 100)
|
|
1096
|
+
)
|
|
934
1097
|
|
|
935
1098
|
# 전체 계산 — pnl 은 부호 보정된 per-position 합, eval/buy 는 명목가 합.
|
|
936
1099
|
total_eval = wf_eval + other_eval
|
|
937
1100
|
total_buy = wf_buy + other_buy
|
|
938
1101
|
total_pnl = wf_pnl + other_pnl
|
|
939
|
-
|
|
1102
|
+
# 전체 비율은 **양쪽 다 증거가 성립할 때만** 낸다. 한쪽이 모름이면 합계도 모름이다
|
|
1103
|
+
# (모르는 쪽을 0 으로 치고 더하면 없는 정확도를 만들어낸다).
|
|
1104
|
+
total_unpriced = wf_unpriced + other_unpriced
|
|
1105
|
+
total_unbased = wf_unbased + other_unbased
|
|
1106
|
+
total_rate = (
|
|
1107
|
+
None
|
|
1108
|
+
if (not total_buy or total_unbased or total_unpriced)
|
|
1109
|
+
else (total_pnl / total_buy * 100)
|
|
1110
|
+
)
|
|
940
1111
|
|
|
941
1112
|
# 신뢰도 계산
|
|
942
1113
|
anomalies = self.detect_anomalies()
|
|
@@ -948,6 +1119,10 @@ class WorkflowPositionTracker:
|
|
|
948
1119
|
"product": self.product,
|
|
949
1120
|
|
|
950
1121
|
"workflow_pnl_rate": wf_rate,
|
|
1122
|
+
# 비율을 못 낸 **이유**. 값을 비우는 것만으로는 "안 샀다" 와 "증권사가 평단을
|
|
1123
|
+
# 안 보냈다" 가 구분되지 않는다 — 서버는 이 사유를 raw_event 에 그대로 싣는다.
|
|
1124
|
+
# 어휘는 dsl-api `app/utils/pnl_rate_evidence.RATE_UNAVAILABLE_REASONS` 와 공용.
|
|
1125
|
+
"workflow_rate_unavailable_reason": wf_rate_reason,
|
|
951
1126
|
"workflow_eval_amount": wf_eval,
|
|
952
1127
|
"workflow_buy_amount": wf_buy,
|
|
953
1128
|
"workflow_pnl_amount": wf_pnl,
|
|
@@ -978,6 +1153,34 @@ class WorkflowPositionTracker:
|
|
|
978
1153
|
Historical FIFO rows have no currency or fee evidence. Keep their amounts
|
|
979
1154
|
per symbol/exchange and reject mixed ownership or an incomplete FIFO basis.
|
|
980
1155
|
Futures FIFO is not a monetary ledger. No equity/MDD basis is inferred.
|
|
1156
|
+
|
|
1157
|
+
Version 2 adds closed-trade outcomes. One closed trade = one sell fill,
|
|
1158
|
+
using its *stored* realized amount — the replay below only validates the
|
|
1159
|
+
basis and never replaces a stored value, so the outcome is read from the
|
|
1160
|
+
same number the ledger committed.
|
|
1161
|
+
|
|
1162
|
+
A workflow sell now consumes only workflow lots (_process_sell_fifo), so
|
|
1163
|
+
the old ``mixed_fifo_ownership`` pre-check is gone: a workflow sell can
|
|
1164
|
+
no longer have realized a non-workflow lot, so there is nothing to guard
|
|
1165
|
+
against. Old ledger rows written under the previous (classification-blind)
|
|
1166
|
+
sell replay with ``stored != expected`` and are rejected below as
|
|
1167
|
+
``incomplete_fifo_basis`` — the honest outcome for a basis we can no
|
|
1168
|
+
longer reconstruct.
|
|
1169
|
+
|
|
1170
|
+
A workflow sell whose own lots run out leaves a residual. When that
|
|
1171
|
+
residual was recorded with an account-average-price estimate covering
|
|
1172
|
+
exactly it (``estimate_basis_price`` present and ``unmatched_qty`` equal
|
|
1173
|
+
to the residual), the group is not rejected: its status becomes
|
|
1174
|
+
``estimated``, its basis ``fifo_with_account_avg_price_estimate``, and
|
|
1175
|
+
the sell scores on ``stored + estimated_pnl`` (the FIFO-matched amount
|
|
1176
|
+
plus the estimated tail). Any other residual is an incomplete basis.
|
|
1177
|
+
|
|
1178
|
+
Counts aggregate across symbols because a count carries no currency.
|
|
1179
|
+
Amounts do not: with `currency` unproven per row, summing gross profit
|
|
1180
|
+
across symbols would invent the very unit this method refuses to claim.
|
|
1181
|
+
So gross amounts stay inside each group, and the top-level profit/loss
|
|
1182
|
+
ratio is published only when a single group carries the whole ledger —
|
|
1183
|
+
the one case where "the currency is the same" needs no evidence.
|
|
981
1184
|
"""
|
|
982
1185
|
with sqlite3.connect(self.db_path) as conn:
|
|
983
1186
|
conn.row_factory = sqlite3.Row
|
|
@@ -1014,21 +1217,28 @@ class WorkflowPositionTracker:
|
|
|
1014
1217
|
for (symbol, exchange), fills in sorted(groups.items()):
|
|
1015
1218
|
reason = None
|
|
1016
1219
|
amount = None
|
|
1220
|
+
outcome = None
|
|
1221
|
+
# Whether this group's amount includes an account-avg-price estimate,
|
|
1222
|
+
# and how much quantity was estimated (Σ unmatched over its sells).
|
|
1223
|
+
group_estimated = False
|
|
1224
|
+
group_est_qty = Decimal(0)
|
|
1017
1225
|
if self.product == "overseas_futures":
|
|
1018
1226
|
reason = "futures_fifo_not_monetary"
|
|
1019
1227
|
elif not symbol:
|
|
1020
1228
|
reason = "missing_symbol"
|
|
1021
|
-
elif any(row["symbol"] == symbol and (
|
|
1022
|
-
row["product"] != self.product or row["provider"] != self.provider
|
|
1023
|
-
or row["classification"] != "workflow" or (row["exchange"] or "") != exchange
|
|
1024
|
-
) for row in rows):
|
|
1025
|
-
# The historical sell writer matches symbol/mode across all lots.
|
|
1026
|
-
# It cannot prove workflow ownership when those domains overlap.
|
|
1027
|
-
reason = "mixed_fifo_ownership"
|
|
1028
1229
|
else:
|
|
1230
|
+
# The old mixed_fifo_ownership pre-check is gone: a workflow sell
|
|
1231
|
+
# now consumes only workflow lots, so it can never have realized a
|
|
1232
|
+
# non-workflow lot. Old rows written under the previous replay
|
|
1233
|
+
# fall out below as incomplete_fifo_basis (stored != expected).
|
|
1029
1234
|
try:
|
|
1030
1235
|
lots = []
|
|
1031
1236
|
total = Decimal(0)
|
|
1237
|
+
# NB: `closed` below is the matched lot quantity — do not
|
|
1238
|
+
# reuse that name for a counter.
|
|
1239
|
+
trades = wins = losses = evens = 0
|
|
1240
|
+
gross_profit = Decimal(0)
|
|
1241
|
+
gross_loss = Decimal(0)
|
|
1032
1242
|
for fill in fills:
|
|
1033
1243
|
quantity = Decimal(str(fill["quantity"]))
|
|
1034
1244
|
price = Decimal(str(fill["price"]))
|
|
@@ -1051,23 +1261,166 @@ class WorkflowPositionTracker:
|
|
|
1051
1261
|
if remaining == 0:
|
|
1052
1262
|
break
|
|
1053
1263
|
tolerance = max(Decimal("0.000001"), abs(expected) * Decimal("0.000000001"))
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1264
|
+
# The FIFO-matched portion must reproduce the stored
|
|
1265
|
+
# amount, whether or not a tail remains.
|
|
1266
|
+
if abs(stored - expected) > tolerance:
|
|
1267
|
+
raise ValueError("Inconsistent FIFO basis")
|
|
1268
|
+
# trade_amount is what this one closed trade scores on;
|
|
1269
|
+
# it starts as the stored (matched) value and grows by
|
|
1270
|
+
# the recorded estimate when a tail remains.
|
|
1271
|
+
trade_amount = stored
|
|
1272
|
+
if remaining > 0:
|
|
1273
|
+
# A residual after the workflow lots run out is
|
|
1274
|
+
# accepted only as a recorded account-avg-price
|
|
1275
|
+
# estimate covering exactly this residual; anything
|
|
1276
|
+
# else is an incomplete basis.
|
|
1277
|
+
est_basis = fill["estimate_basis_price"]
|
|
1278
|
+
est_unmatched = fill["unmatched_qty"]
|
|
1279
|
+
est_pnl = fill["estimated_pnl"]
|
|
1280
|
+
if est_basis is None or est_unmatched is None or est_pnl is None:
|
|
1281
|
+
raise ValueError("Incomplete FIFO basis without estimate")
|
|
1282
|
+
est_unmatched_d = Decimal(str(est_unmatched))
|
|
1283
|
+
est_pnl_d = Decimal(str(est_pnl))
|
|
1284
|
+
if est_unmatched_d != remaining or not est_pnl_d.is_finite():
|
|
1285
|
+
raise ValueError("Estimate does not cover the residual")
|
|
1286
|
+
# Add (never replace) the stored estimate to the
|
|
1287
|
+
# matched amount; one sell fill is still one trade.
|
|
1288
|
+
trade_amount = stored + est_pnl_d
|
|
1289
|
+
group_estimated = True
|
|
1290
|
+
group_est_qty += remaining
|
|
1291
|
+
total += trade_amount
|
|
1292
|
+
trades += 1
|
|
1293
|
+
if trade_amount > 0:
|
|
1294
|
+
wins += 1
|
|
1295
|
+
gross_profit += trade_amount
|
|
1296
|
+
elif trade_amount < 0:
|
|
1297
|
+
losses += 1
|
|
1298
|
+
gross_loss += -trade_amount
|
|
1299
|
+
else:
|
|
1300
|
+
evens += 1
|
|
1059
1301
|
else:
|
|
1060
1302
|
raise ValueError("Unknown fill side")
|
|
1061
1303
|
amount = float(total)
|
|
1062
1304
|
if not Decimal(str(amount)).is_finite():
|
|
1063
1305
|
raise ValueError("Non-finite total")
|
|
1306
|
+
gross_profit_f = float(gross_profit)
|
|
1307
|
+
gross_loss_f = float(gross_loss)
|
|
1308
|
+
if not all(Decimal(str(v)).is_finite() for v in (gross_profit_f, gross_loss_f)):
|
|
1309
|
+
raise ValueError("Non-finite gross amount")
|
|
1310
|
+
outcome = {"closed_trades": trades, "winning_trades": wins,
|
|
1311
|
+
"losing_trades": losses, "breakeven_trades": evens,
|
|
1312
|
+
"gross_profit": gross_profit_f, "gross_loss": gross_loss_f}
|
|
1064
1313
|
except (ValueError, TypeError, ArithmeticError, OverflowError):
|
|
1065
1314
|
reason = "incomplete_fifo_basis"
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1315
|
+
outcome = None
|
|
1316
|
+
group_estimated = False
|
|
1317
|
+
group_est_qty = Decimal(0)
|
|
1318
|
+
if reason is not None:
|
|
1319
|
+
status = "unavailable"
|
|
1320
|
+
basis = None
|
|
1321
|
+
estimated_quantity = None
|
|
1322
|
+
elif group_estimated:
|
|
1323
|
+
status = "estimated"
|
|
1324
|
+
basis = "fifo_with_account_avg_price_estimate"
|
|
1325
|
+
estimated_quantity = float(group_est_qty)
|
|
1326
|
+
else:
|
|
1327
|
+
status = "available"
|
|
1328
|
+
basis = "fifo"
|
|
1329
|
+
estimated_quantity = 0
|
|
1330
|
+
entry = {"symbol": symbol, "exchange": exchange, "currency": None,
|
|
1331
|
+
"amount": amount, "status": status, "reason": reason,
|
|
1332
|
+
"basis": basis, "estimated_quantity": estimated_quantity}
|
|
1333
|
+
# A rejected group publishes no outcome: its trades are unknown, and
|
|
1334
|
+
# unknown trades must not be counted as zero of anything.
|
|
1335
|
+
entry.update(outcome or {"closed_trades": None, "winning_trades": None,
|
|
1336
|
+
"losing_trades": None, "breakeven_trades": None,
|
|
1337
|
+
"gross_profit": None, "gross_loss": None})
|
|
1338
|
+
realized.append(entry)
|
|
1339
|
+
# Counts carry no currency, so they aggregate across symbols. A group the
|
|
1340
|
+
# replay rejected is excluded and downgrades the status to "partial" —
|
|
1341
|
+
# "some of this strategy's trades are not in these numbers" is a different
|
|
1342
|
+
# claim from "these are all of them", and the caller must be able to tell.
|
|
1343
|
+
scored = [g for g in realized if g["closed_trades"] is not None]
|
|
1344
|
+
counted = [g for g in scored if g["closed_trades"] > 0]
|
|
1345
|
+
if not scored:
|
|
1346
|
+
trade_status, trade_reason = "unavailable", (
|
|
1347
|
+
"futures_fifo_not_monetary" if self.product == "overseas_futures"
|
|
1348
|
+
else "no_scorable_fifo_basis")
|
|
1349
|
+
closed_total = winning_total = losing_total = breakeven_total = None
|
|
1350
|
+
else:
|
|
1351
|
+
trade_status = "available" if len(scored) == len(realized) else "partial"
|
|
1352
|
+
trade_reason = None if trade_status == "available" else "some_groups_unscorable"
|
|
1353
|
+
closed_total = sum(g["closed_trades"] for g in scored)
|
|
1354
|
+
winning_total = sum(g["winning_trades"] for g in scored)
|
|
1355
|
+
losing_total = sum(g["losing_trades"] for g in scored)
|
|
1356
|
+
breakeven_total = sum(g["breakeven_trades"] for g in scored)
|
|
1357
|
+
|
|
1358
|
+
# The ratio is money, and money needs a unit. One group means one symbol,
|
|
1359
|
+
# hence one currency — the only case where the unit is provable without
|
|
1360
|
+
# currency evidence. Anything else stays per group.
|
|
1361
|
+
ratio = None
|
|
1362
|
+
ratio_status = "unavailable"
|
|
1363
|
+
if len(counted) != 1:
|
|
1364
|
+
ratio_reason = ("no_closed_trades" if not counted
|
|
1365
|
+
else "multi_symbol_currency_unknown")
|
|
1366
|
+
else:
|
|
1367
|
+
group = counted[0]
|
|
1368
|
+
if group["gross_loss"] > 0:
|
|
1369
|
+
candidate = group["gross_profit"] / group["gross_loss"]
|
|
1370
|
+
if Decimal(str(candidate)).is_finite():
|
|
1371
|
+
ratio, ratio_status, ratio_reason = candidate, "available", None
|
|
1372
|
+
else:
|
|
1373
|
+
ratio_reason = "non_finite_ratio"
|
|
1374
|
+
else:
|
|
1375
|
+
# No losing trade means no denominator. Publishing gross profit
|
|
1376
|
+
# here (the server's day-based metric does) reads as a ratio of
|
|
1377
|
+
# that size and overstates the strategy.
|
|
1378
|
+
ratio_reason = "no_losing_trades"
|
|
1379
|
+
|
|
1380
|
+
# How many groups carry an account-avg-price estimate, and whether the
|
|
1381
|
+
# aggregated figures rest on any estimate. A count is null when it is
|
|
1382
|
+
# itself unavailable; otherwise it is "measured" unless a scored group
|
|
1383
|
+
# was estimated. The ratio comes from a single group, so its basis is
|
|
1384
|
+
# that one group's.
|
|
1385
|
+
estimated_group_count = sum(1 for g in realized if g["status"] == "estimated")
|
|
1386
|
+
if trade_status == "unavailable":
|
|
1387
|
+
closed_trade_basis = None
|
|
1388
|
+
else:
|
|
1389
|
+
closed_trade_basis = ("includes_estimates"
|
|
1390
|
+
if any(g["status"] == "estimated" for g in scored)
|
|
1391
|
+
else "measured")
|
|
1392
|
+
if ratio_status != "available":
|
|
1393
|
+
profit_loss_ratio_basis = None
|
|
1394
|
+
else:
|
|
1395
|
+
profit_loss_ratio_basis = ("includes_estimates"
|
|
1396
|
+
if counted[0]["status"] == "estimated"
|
|
1397
|
+
else "measured")
|
|
1398
|
+
|
|
1399
|
+
# Fills on this same account placed outside this strategy, by classification:
|
|
1400
|
+
# manual (a person via HTS/app/branch) → hts, unknown_api (another API
|
|
1401
|
+
# client) → other_api, other (broker-side codes 96/LP/SK/SO or unlisted)
|
|
1402
|
+
# → other. Futures fill frames carry no communication-media field, so
|
|
1403
|
+
# the channels cannot be told apart for them — unavailable.
|
|
1404
|
+
if self.product == "overseas_futures":
|
|
1405
|
+
off_strategy_fills = {"hts": None, "other_api": None, "other": None, "other_codes": None,
|
|
1406
|
+
"status": "unavailable",
|
|
1407
|
+
"reason": "futures_fills_have_no_media_code"}
|
|
1408
|
+
else:
|
|
1409
|
+
in_scope = [r for r in rows if r["product"] == self.product
|
|
1410
|
+
and r["provider"] == self.provider]
|
|
1411
|
+
off_strategy_fills = {
|
|
1412
|
+
"hts": sum(1 for r in in_scope if r["classification"] == "manual"),
|
|
1413
|
+
"other_api": sum(1 for r in in_scope if r["classification"] == "unknown_api"),
|
|
1414
|
+
"other": sum(1 for r in in_scope if r["classification"] == "other"),
|
|
1415
|
+
# Distinct unlisted codes behind "other" — the evidence trail for
|
|
1416
|
+
# extending MEDIA_CODES_* (consumers may ignore this key).
|
|
1417
|
+
"other_codes": sorted({str(r["commda_code"]) for r in in_scope
|
|
1418
|
+
if r["classification"] == "other" and r["commda_code"]}),
|
|
1419
|
+
"status": "available", "reason": None,
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1069
1422
|
return {
|
|
1070
|
-
"version":
|
|
1423
|
+
"version": 2,
|
|
1071
1424
|
"scope": {"kind": "local_workflow_ledger", "product": self.product,
|
|
1072
1425
|
"provider": self.provider, "trading_mode": self.trading_mode},
|
|
1073
1426
|
"as_of": datetime.now(timezone.utc).isoformat(),
|
|
@@ -1076,6 +1429,19 @@ class WorkflowPositionTracker:
|
|
|
1076
1429
|
"executed_order_count_status": "unavailable" if invalid_count else "available",
|
|
1077
1430
|
"executed_order_count_reason": "invalid_execution_identity" if invalid_count else None,
|
|
1078
1431
|
"realized_pnl": realized,
|
|
1432
|
+
"closed_trade_count": closed_total,
|
|
1433
|
+
"winning_trade_count": winning_total,
|
|
1434
|
+
"losing_trade_count": losing_total,
|
|
1435
|
+
"breakeven_trade_count": breakeven_total,
|
|
1436
|
+
"closed_trade_status": trade_status,
|
|
1437
|
+
"closed_trade_reason": trade_reason,
|
|
1438
|
+
"closed_trade_basis": closed_trade_basis,
|
|
1439
|
+
"estimated_group_count": estimated_group_count,
|
|
1440
|
+
"profit_loss_ratio": ratio,
|
|
1441
|
+
"profit_loss_ratio_status": ratio_status,
|
|
1442
|
+
"profit_loss_ratio_reason": ratio_reason,
|
|
1443
|
+
"profit_loss_ratio_basis": profit_loss_ratio_basis,
|
|
1444
|
+
"off_strategy_fills": off_strategy_fills,
|
|
1079
1445
|
"max_drawdown": None,
|
|
1080
1446
|
"max_drawdown_status": "unavailable",
|
|
1081
1447
|
"max_drawdown_reason": "equity_history_unavailable",
|
|
@@ -4567,6 +4567,25 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
4567
4567
|
|
|
4568
4568
|
async def record_and_refresh():
|
|
4569
4569
|
"""체결 기록 후 AccountTracker refresh"""
|
|
4570
|
+
tracker_key = f"{context.job_id}_{node_id}"
|
|
4571
|
+
tracker_info = self._active_trackers.get(tracker_key)
|
|
4572
|
+
|
|
4573
|
+
# 매도 잔량 추정용 계좌 평균매입가 — record 전에(그래서 refresh
|
|
4574
|
+
# 전에) StockAccountTracker 캐시(_positions[symbol].buy_price)에서
|
|
4575
|
+
# 읽는다. 캐시는 아직 이번 매도가 반영되기 전이라 '매도 직전 평단'.
|
|
4576
|
+
# 캐시에 없으면 None → 추정 없음(오늘처럼 unavailable).
|
|
4577
|
+
account_avg_price = None
|
|
4578
|
+
if tracker_info and "tracker" in tracker_info:
|
|
4579
|
+
acct_tracker = tracker_info["tracker"]
|
|
4580
|
+
positions = getattr(acct_tracker, "_positions", None)
|
|
4581
|
+
pos = positions.get(symbol) if isinstance(positions, dict) else None
|
|
4582
|
+
buy_price = getattr(pos, "buy_price", None) if pos is not None else None
|
|
4583
|
+
if buy_price is not None:
|
|
4584
|
+
try:
|
|
4585
|
+
account_avg_price = float(buy_price)
|
|
4586
|
+
except (TypeError, ValueError):
|
|
4587
|
+
account_avg_price = None
|
|
4588
|
+
|
|
4570
4589
|
await context.record_workflow_fill(
|
|
4571
4590
|
order_no=order_no,
|
|
4572
4591
|
order_date=order_date,
|
|
@@ -4576,19 +4595,21 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
4576
4595
|
quantity=exec_qty,
|
|
4577
4596
|
price=exec_price,
|
|
4578
4597
|
fill_time=fill_time,
|
|
4579
|
-
|
|
4598
|
+
# 프레임의 통신매체코드를 그대로 넘긴다(하드코딩 '40' 폐기).
|
|
4599
|
+
# 우리 주문과 일치하면 매체코드 무관하게 workflow 로 분류되고,
|
|
4600
|
+
# 일치하지 않는 계좌 내 타 체결만 이 코드로 HTS/타 API 를 가른다.
|
|
4601
|
+
commda_code=getattr(body, 'sCommdaCode', ''),
|
|
4580
4602
|
execution_id=execution_id,
|
|
4603
|
+
account_avg_price=account_avg_price,
|
|
4581
4604
|
)
|
|
4582
|
-
|
|
4605
|
+
|
|
4583
4606
|
# AccountTracker refresh로 PnL 이벤트 강제 트리거
|
|
4584
|
-
tracker_key = f"{context.job_id}_{node_id}"
|
|
4585
|
-
tracker_info = self._active_trackers.get(tracker_key)
|
|
4586
4607
|
if tracker_info and "tracker" in tracker_info:
|
|
4587
4608
|
tracker = tracker_info["tracker"]
|
|
4588
4609
|
if hasattr(tracker, 'refresh_now'):
|
|
4589
4610
|
await tracker.refresh_now()
|
|
4590
4611
|
logger.debug(f"📊 AccountTracker refreshed after fill")
|
|
4591
|
-
|
|
4612
|
+
|
|
4592
4613
|
asyncio.run_coroutine_threadsafe(record_and_refresh(), loop)
|
|
4593
4614
|
logger.info(f"📌 Workflow fill recorded: {symbol} {side} {exec_qty}@{exec_price}")
|
|
4594
4615
|
|
|
@@ -4742,6 +4763,9 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
4742
4763
|
quantity=quantity,
|
|
4743
4764
|
price=price,
|
|
4744
4765
|
fill_time=fill_time,
|
|
4766
|
+
# 선물 체결 프레임(TC3)에는 통신매체코드 필드가 없다
|
|
4767
|
+
# (blocks.py 에 commda/media 없음). 이 경로는 우리
|
|
4768
|
+
# 주문의 체결이므로 OPEN API 기본값 '40' 을 유지한다.
|
|
4745
4769
|
commda_code='40',
|
|
4746
4770
|
),
|
|
4747
4771
|
loop
|
|
@@ -4815,6 +4839,8 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
4815
4839
|
side = 'buy' if bns_tp == '2' else 'sell'
|
|
4816
4840
|
fill_time = getattr(body, 'exectime', datetime.now().strftime('%H%M%S000'))
|
|
4817
4841
|
|
|
4842
|
+
media_code = getattr(body, 'commdacode', '')
|
|
4843
|
+
|
|
4818
4844
|
async def record_and_refresh():
|
|
4819
4845
|
"""체결 기록 후 AccountTracker refresh"""
|
|
4820
4846
|
await context.record_workflow_fill(
|
|
@@ -4826,7 +4852,10 @@ class BrokerNodeExecutor(NodeExecutorBase):
|
|
|
4826
4852
|
quantity=exec_qty,
|
|
4827
4853
|
price=exec_price,
|
|
4828
4854
|
fill_time=fill_time,
|
|
4829
|
-
|
|
4855
|
+
# SC1 프레임의 통신매체코드(commdacode)를 그대로 넘긴다.
|
|
4856
|
+
# 우리 주문과 일치하면 workflow, 아니면 이 코드로
|
|
4857
|
+
# HTS(manual)/타 API(unknown_api)를 가른다.
|
|
4858
|
+
commda_code=media_code,
|
|
4830
4859
|
)
|
|
4831
4860
|
|
|
4832
4861
|
# AccountTracker refresh로 PnL 이벤트 강제 트리거
|
|
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.34.0 → programgarden-1.35.0}/programgarden/database/workflow_risk_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|