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.
Files changed (39) hide show
  1. {programgarden-1.34.0 → programgarden-1.35.0}/PKG-INFO +1 -1
  2. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/context.py +28 -7
  3. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/database/workflow_position_tracker.py +422 -56
  4. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/executor.py +35 -6
  5. {programgarden-1.34.0 → programgarden-1.35.0}/pyproject.toml +1 -1
  6. {programgarden-1.34.0 → programgarden-1.35.0}/README.md +0 -0
  7. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/__init__.py +0 -0
  8. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/binding_validator.py +0 -0
  9. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/client.py +0 -0
  10. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/code_worker.py +0 -0
  11. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/database/__init__.py +0 -0
  12. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/database/checkpoint_manager.py +0 -0
  13. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/database/query_builder.py +0 -0
  14. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  15. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/deep_fixtures.py +0 -0
  16. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/futures_pnl.py +0 -0
  17. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/node_runner.py +0 -0
  18. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/order_lifecycle.py +0 -0
  19. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/plugin/__init__.py +0 -0
  20. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/plugin/sandbox.py +0 -0
  21. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/providers/__init__.py +0 -0
  22. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/providers/llm_errors.py +0 -0
  23. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/providers/llm_provider.py +0 -0
  24. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/reconnect_handler.py +0 -0
  25. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resolver.py +0 -0
  26. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resource/__init__.py +0 -0
  27. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resource/context.py +0 -0
  28. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resource/limiter.py +0 -0
  29. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resource/monitor.py +0 -0
  30. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/resource/throttle.py +0 -0
  31. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/semantic_rules.py +0 -0
  32. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/__init__.py +0 -0
  33. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/credential_tools.py +0 -0
  34. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/definition_tools.py +0 -0
  35. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/event_tools.py +0 -0
  36. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/job_tools.py +0 -0
  37. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/registry_tools.py +0 -0
  38. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/tools/sqlite_tools.py +0 -0
  39. {programgarden-1.34.0 → programgarden-1.35.0}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.34.0
3
+ Version: 1.35.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -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
- "workflow_pnl_rate": 0.0,
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
- logger.warning(f"calculate_workflow_pnl failed: {e}")
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": 0.0,
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": 0.0,
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": 0.0,
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,
@@ -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
- if commda_code != "40":
441
- return await self._process_fill_internal(fill, "manual")
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
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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
- ) -> float:
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
- cursor.execute("""
629
- SELECT id, buy_price, remaining_qty, classification
630
- FROM workflow_position_lots
631
- WHERE symbol = ? AND remaining_qty > 0 AND trading_mode = ?
632
- ORDER BY fill_datetime ASC
633
- """, (symbol, self.trading_mode))
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
- logger.warning(f"Sell without enough position: {symbol} remaining={remaining_to_sell}")
662
-
663
- return total_realized_pnl
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
- current_price = prices.get(symbol, pos.avg_price)
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
- wf_rate = (wf_pnl / wf_buy * 100) if wf_buy else Decimal(0)
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
- current_price = prices.get(symbol, pos.avg_price)
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
- other_rate = (other_pnl / other_buy * 100) if other_buy else Decimal(0)
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
- total_rate = (total_pnl / total_buy * 100) if total_buy else Decimal(0)
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
- if remaining > 0 or abs(stored - expected) > tolerance:
1055
- raise ValueError("Incomplete or inconsistent FIFO basis")
1056
- # Preserve the stored amount; replay arithmetic only
1057
- # validates the basis and never replaces its value.
1058
- total += stored
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
- realized.append({"symbol": symbol, "exchange": exchange, "currency": None,
1067
- "amount": amount, "status": "available" if reason is None else "unavailable",
1068
- "reason": reason})
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": 1,
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
- commda_code='40', # OPEN API
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
- commda_code='40',
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 이벤트 강제 트리거
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.34.0"
8
+ version = "1.35.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
File without changes