programgarden 1.37.7__tar.gz → 1.37.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {programgarden-1.37.7 → programgarden-1.37.8}/PKG-INFO +1 -1
  2. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/context.py +17 -0
  3. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/database/fill_reconciler.py +1 -0
  4. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/database/workflow_position_tracker.py +85 -7
  5. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/executor.py +34 -1
  6. {programgarden-1.37.7 → programgarden-1.37.8}/pyproject.toml +1 -1
  7. {programgarden-1.37.7 → programgarden-1.37.8}/README.md +0 -0
  8. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/__init__.py +0 -0
  9. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/binding_validator.py +0 -0
  10. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/client.py +0 -0
  11. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/code_worker.py +0 -0
  12. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/database/__init__.py +0 -0
  13. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/database/checkpoint_manager.py +0 -0
  14. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/database/query_builder.py +0 -0
  15. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/database/workflow_risk_tracker.py +0 -0
  16. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/deep_fixtures.py +0 -0
  17. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/futures_pnl.py +0 -0
  18. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/node_runner.py +0 -0
  19. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/order_lifecycle.py +0 -0
  20. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/plugin/__init__.py +0 -0
  21. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/plugin/sandbox.py +0 -0
  22. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/providers/__init__.py +0 -0
  23. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/providers/llm_errors.py +0 -0
  24. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/providers/llm_provider.py +0 -0
  25. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/reconnect_handler.py +0 -0
  26. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/resolver.py +0 -0
  27. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/resource/__init__.py +0 -0
  28. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/resource/context.py +0 -0
  29. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/resource/limiter.py +0 -0
  30. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/resource/monitor.py +0 -0
  31. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/resource/throttle.py +0 -0
  32. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/semantic_rules.py +0 -0
  33. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/tools/__init__.py +0 -0
  34. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/tools/credential_tools.py +0 -0
  35. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/tools/definition_tools.py +0 -0
  36. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/tools/event_tools.py +0 -0
  37. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/tools/job_tools.py +0 -0
  38. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/tools/registry_tools.py +0 -0
  39. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/tools/sqlite_tools.py +0 -0
  40. {programgarden-1.37.7 → programgarden-1.37.8}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.37.7
3
+ Version: 1.37.8
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -2865,6 +2865,19 @@ class ExecutionContext:
2865
2865
  logger.warning(f"Failed to update order fill price: {e}")
2866
2866
  return False
2867
2867
 
2868
+ async def _annotate_workflow_fill_currency(self, *, currency: str, **fill_kwargs: Any) -> bool:
2869
+ """Preserve post-refresh currency without replaying the AS1 execution."""
2870
+ tracker = self._workflow_position_tracker
2871
+ if tracker is None:
2872
+ return False
2873
+ fill_kwargs["fill_time"] = fill_kwargs.get("fill_time") or UNKNOWN_FILL_TIME
2874
+ try:
2875
+ return await tracker.annotate_fill_currency(currency=currency, **fill_kwargs)
2876
+ except ValueError:
2877
+ # Unusable/conflicting identity must never cause another fill write.
2878
+ logger.warning("Cannot attach currency evidence to the retained execution")
2879
+ return False
2880
+
2868
2881
  async def record_workflow_fill(
2869
2882
  self,
2870
2883
  order_no: str,
@@ -2879,6 +2892,7 @@ class ExecutionContext:
2879
2892
  *,
2880
2893
  execution_id: Optional[str | int] = None,
2881
2894
  account_avg_price: Optional[float] = None,
2895
+ currency: Optional[str] = None,
2882
2896
  ) -> str:
2883
2897
  """Record fill event for FIFO position tracking.
2884
2898
 
@@ -2904,6 +2918,7 @@ class ExecutionContext:
2904
2918
  execution_id: Optional broker execution number, preserved for durable replay detection.
2905
2919
  account_avg_price: Optional account average purchase price for this
2906
2920
  symbol at fill time; only a workflow sell's residual tail uses it.
2921
+ currency: Explicit broker unit at fill time; no product default.
2907
2922
 
2908
2923
  Returns:
2909
2924
  분류 결과: "workflow" | "manual" | "unknown_api" | "pending"
@@ -2976,6 +2991,8 @@ class ExecutionContext:
2976
2991
  # 재시도에도 유지한다 — identity 축이 아니고, 매도 잔량 추정의
2977
2992
  # 유일한 근거다.
2978
2993
  base_kwargs["account_avg_price"] = account_avg_price
2994
+ if currency is not None:
2995
+ base_kwargs["currency"] = currency
2979
2996
 
2980
2997
  try:
2981
2998
  result = await self._workflow_position_tracker.record_fill(
@@ -227,6 +227,7 @@ async def reconcile_workflow_fills(
227
227
  # 빈 값이면 트래커가 '우리 주문과 일치하는가' 로만 분류한다.
228
228
  commda_code="",
229
229
  account_avg_price=avg_price,
230
+ currency=hit.get("currency"),
230
231
  )
231
232
  except Exception as exc:
232
233
  report.errors.append(f"record_fill_failed[{order_no}]: {exc}")
@@ -183,6 +183,9 @@ class PendingFill:
183
183
  # read from the broker snapshot before it refreshes. Used only to estimate a
184
184
  # workflow sell's residual tail (see _process_sell_fifo); None → no estimate.
185
185
  account_avg_price: Optional[float] = None
186
+ # An explicit broker unit, never inferred from the product or exchange.
187
+ currency: Optional[str] = None
188
+ currency_conflict: bool = False
186
189
 
187
190
 
188
191
  class ExecutionIdentityError(ValueError):
@@ -535,6 +538,12 @@ class WorkflowPositionTracker:
535
538
  for column in ("execution_id", "normalized_order_no", "execution_payload"):
536
539
  if column not in columns:
537
540
  cursor.execute(f"ALTER TABLE trade_history ADD COLUMN {column} TEXT")
541
+ if "currency" not in columns:
542
+ cursor.execute("ALTER TABLE trade_history ADD COLUMN currency TEXT")
543
+ if "currency_conflict" not in columns:
544
+ cursor.execute(
545
+ "ALTER TABLE trade_history ADD COLUMN currency_conflict INTEGER NOT NULL DEFAULT 0"
546
+ )
538
547
  # Additive columns for the account-average-price sell estimate. A
539
548
  # workflow sell that empties the strategy's own lots and still has a
540
549
  # tail records that tail here (the residual quantity, the account
@@ -722,6 +731,7 @@ class WorkflowPositionTracker:
722
731
  *,
723
732
  execution_id: Optional[str | int] = None,
724
733
  account_avg_price: Optional[float] = None,
734
+ currency: Optional[str] = None,
725
735
  ) -> str:
726
736
  """
727
737
  체결 기록 및 FIFO 처리
@@ -743,6 +753,8 @@ class WorkflowPositionTracker:
743
753
  account_avg_price: Optional account average purchase price for this
744
754
  symbol at fill time. Only a workflow sell whose own lots run out
745
755
  uses it, to estimate the residual tail. None → no estimate.
756
+ currency: Explicit broker currency for this fill. Missing/invalid
757
+ values remain unknown and never change fill identity or amounts.
746
758
  execution_id: Optional broker execution identity. Positive numeric
747
759
  strings/integers ignore padding; opaque strings retain case.
748
760
  None, blank, and zero mean no identity and retain legacy replay
@@ -765,6 +777,7 @@ class WorkflowPositionTracker:
765
777
  exchange=exchange, side=side, quantity=quantity, price=price,
766
778
  fill_time=fill_time, commda_code=commda_code, received_at=datetime.now(),
767
779
  execution_id=execution_id, account_avg_price=account_avg_price,
780
+ currency=self._currency_code(currency),
768
781
  )
769
782
  identity = self._execution_key(fill)
770
783
  notifications: list = []
@@ -879,6 +892,59 @@ class WorkflowPositionTracker:
879
892
  "commda_code": fill.commda_code,
880
893
  }
881
894
 
895
+ @staticmethod
896
+ def _currency_code(value: Any) -> Optional[str]:
897
+ if not isinstance(value, str):
898
+ return None
899
+ value = value.strip().upper()
900
+ return value if re.fullmatch(r"[A-Z]{3}", value) else None
901
+
902
+ async def annotate_fill_currency(self, *, currency: str, **fill_kwargs: Any) -> bool:
903
+ """Attach a contemporaneous broker unit to an exact retained execution.
904
+
905
+ Called after the existing AS1 account refresh, never to reconstruct old
906
+ history. No identity means no annotation. Check all canonical facts and
907
+ hold the ledger write lock, but do not create a fill, consume a lot, emit
908
+ another fill notification or alter the deduplication payload. Conflicting
909
+ units permanently make the unit unknown until separately investigated.
910
+ """
911
+ unit = self._currency_code(currency)
912
+ if unit is None or self.product != "overseas_stock":
913
+ return False
914
+ fill = PendingFill(**fill_kwargs, received_at=datetime.now(), currency=unit)
915
+ identity = self._execution_key(fill)
916
+ if identity is None:
917
+ return False
918
+ changed = False
919
+ async with self._buffer_lock:
920
+ for pending in self._pending_fills.values():
921
+ if self._execution_key(pending) == identity:
922
+ self._assert_same_execution(pending, fill)
923
+ if pending.currency_conflict or pending.currency == unit:
924
+ return False
925
+ pending.currency_conflict = pending.currency is not None
926
+ pending.currency = None if pending.currency_conflict else unit
927
+ return True
928
+ with sqlite3.connect(self.db_path) as conn:
929
+ conn.execute("BEGIN IMMEDIATE")
930
+ cursor = conn.cursor()
931
+ if self._find_execution(cursor, fill) is None:
932
+ return False
933
+ where = """product=? AND provider=? AND trading_mode=? AND order_date=?
934
+ AND normalized_order_no=? AND execution_id=?"""
935
+ stored, conflict = cursor.execute(
936
+ f"SELECT currency, currency_conflict FROM trade_history WHERE {where}", identity
937
+ ).fetchone()
938
+ if not conflict and stored != unit:
939
+ cursor.execute(
940
+ f"UPDATE trade_history SET currency=?, currency_conflict=? WHERE {where}",
941
+ (unit if stored is None else None, int(stored is not None), *identity),
942
+ )
943
+ changed = True
944
+ if changed:
945
+ self.fill_revision += 1
946
+ return changed
947
+
882
948
  def _assert_same_execution(self, previous: PendingFill, fill: PendingFill) -> None:
883
949
  if self._execution_facts(previous) != self._execution_facts(fill):
884
950
  raise ExecutionIdentityConflictError("Conflicting fill facts for an existing execution identity")
@@ -984,8 +1050,9 @@ class WorkflowPositionTracker:
984
1050
  (product, provider, order_no, order_date, symbol, exchange, side, quantity, price,
985
1051
  fill_datetime, classification, commda_code, realized_pnl, trading_mode, created_at,
986
1052
  execution_id, normalized_order_no, execution_payload,
987
- unmatched_qty, estimate_basis_price, estimate_source, estimated_pnl)
988
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1053
+ unmatched_qty, estimate_basis_price, estimate_source, estimated_pnl,
1054
+ currency, currency_conflict)
1055
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
989
1056
  """, (
990
1057
  self.product, self.provider,
991
1058
  fill.order_no, fill.order_date, fill.symbol, fill.exchange,
@@ -996,6 +1063,8 @@ class WorkflowPositionTracker:
996
1063
  estimate["estimate_basis_price"] if estimate else None,
997
1064
  estimate["estimate_source"] if estimate else None,
998
1065
  estimate["estimated_pnl"] if estimate else None,
1066
+ fill.currency,
1067
+ int(fill.currency_conflict),
999
1068
  ))
1000
1069
 
1001
1070
  conn.commit()
@@ -1960,8 +2029,9 @@ class WorkflowPositionTracker:
1960
2029
  def personal_metrics(self) -> Dict[str, Any]:
1961
2030
  """Read retained workflow executions without claiming account performance.
1962
2031
 
1963
- Historical FIFO rows have no currency or fee evidence. Keep their amounts
1964
- per symbol/exchange and reject mixed ownership or an incomplete FIFO basis.
2032
+ Historical FIFO rows have no currency or fee evidence. New rows can carry
2033
+ an explicit broker unit; old units are never backfilled. Keep amounts
2034
+ per symbol/exchange and reject contradictory units or an incomplete basis.
1965
2035
  Futures FIFO is not a monetary ledger. No equity/MDD basis is inferred.
1966
2036
 
1967
2037
  Version 2 adds closed-trade outcomes. One closed trade = one sell fill,
@@ -1986,8 +2056,8 @@ class WorkflowPositionTracker:
1986
2056
  plus the estimated tail). Any other residual is an incomplete basis.
1987
2057
 
1988
2058
  Counts aggregate across symbols because a count carries no currency.
1989
- Amounts do not: with `currency` unproven per row, summing gross profit
1990
- across symbols would invent the very unit this method refuses to claim.
2059
+ Amounts do not: the API may sum groups only when every group carries the
2060
+ same explicit currency. Missing evidence never inherits an account unit.
1991
2061
  So gross amounts stay inside each group, and the top-level profit/loss
1992
2062
  ratio is published only when a single group carries the whole ledger —
1993
2063
  the one case where "the currency is the same" needs no evidence.
@@ -2025,6 +2095,8 @@ class WorkflowPositionTracker:
2025
2095
 
2026
2096
  realized = []
2027
2097
  for (symbol, exchange), fills in sorted(groups.items()):
2098
+ units = {self._currency_code(fill.get("currency")) for fill in fills}
2099
+ unit_conflict = any(fill.get("currency_conflict") for fill in fills)
2028
2100
  reason = None
2029
2101
  amount = None
2030
2102
  outcome = None
@@ -2036,6 +2108,9 @@ class WorkflowPositionTracker:
2036
2108
  reason = "futures_fifo_not_monetary"
2037
2109
  elif not symbol:
2038
2110
  reason = "missing_symbol"
2111
+ elif len(units - {None}) > 1 or unit_conflict:
2112
+ # Contradictory units cannot establish a comparable FIFO basis.
2113
+ reason = "incomplete_fifo_basis"
2039
2114
  else:
2040
2115
  # The old mixed_fifo_ownership pre-check is gone: a workflow sell
2041
2116
  # now consumes only workflow lots, so it can never have realized a
@@ -2137,7 +2212,10 @@ class WorkflowPositionTracker:
2137
2212
  status = "available"
2138
2213
  basis = "fifo"
2139
2214
  estimated_quantity = 0
2140
- entry = {"symbol": symbol, "exchange": exchange, "currency": None,
2215
+ unit = (next(iter(units)) if len(units) == 1 and None not in units
2216
+ and not unit_conflict
2217
+ and self.product != "overseas_futures" else None)
2218
+ entry = {"symbol": symbol, "exchange": exchange, "currency": unit,
2141
2219
  "amount": amount, "status": status, "reason": reason,
2142
2220
  "basis": basis, "estimated_quantity": estimated_quantity}
2143
2221
  # A rejected group publishes no outcome: its trades are unknown, and
@@ -165,6 +165,24 @@ def _whole_unit_quantity(value: Any) -> Optional[int]:
165
165
  return int(number) if number.is_integer() else None
166
166
 
167
167
 
168
+ def _stock_position_fill_currency(position: Any, symbol: str, market_code: str) -> Optional[str]:
169
+ """Use only explicitly populated COSOQ00201 position fields for this symbol.
170
+
171
+ AS1 has no currency. StockPositionItem defaults to USD, so a model default
172
+ is insufficient evidence. No quote currency or account-wide default is used.
173
+ """
174
+ from programgarden_finance.ls.overseas_stock.extension.models import StockPositionItem
175
+
176
+ if not isinstance(position, StockPositionItem):
177
+ return None
178
+ if not {"symbol", "market_code", "currency_code"} <= position.model_fields_set:
179
+ return None
180
+ if position.symbol != symbol or not market_code or position.market_code != market_code:
181
+ return None
182
+ unit = position.currency_code.strip().upper()
183
+ return unit if re.fullmatch(r"[A-Z]{3}", unit) else None
184
+
185
+
168
186
  def _usable_execution_identity(order_no: Any, order_date: Any) -> bool:
169
187
  """체결 프레임의 (주문번호, 주문일자)가 원장 execution identity 키로 쓸 수 있나.
170
188
 
@@ -4881,10 +4899,12 @@ class BrokerNodeExecutor(NodeExecutorBase):
4881
4899
  # 읽는다. 캐시는 아직 이번 매도가 반영되기 전이라 '매도 직전 평단'.
4882
4900
  # 캐시에 없으면 None → 추정 없음(오늘처럼 unavailable).
4883
4901
  account_avg_price = None
4902
+ currency = None
4884
4903
  if tracker_info and "tracker" in tracker_info:
4885
4904
  acct_tracker = tracker_info["tracker"]
4886
4905
  positions = getattr(acct_tracker, "_positions", None)
4887
4906
  pos = positions.get(symbol) if isinstance(positions, dict) else None
4907
+ currency = _stock_position_fill_currency(pos, symbol, market_code)
4888
4908
  buy_price = getattr(pos, "buy_price", None) if pos is not None else None
4889
4909
  if buy_price is not None:
4890
4910
  try:
@@ -4892,7 +4912,7 @@ class BrokerNodeExecutor(NodeExecutorBase):
4892
4912
  except (TypeError, ValueError):
4893
4913
  account_avg_price = None
4894
4914
 
4895
- await context.record_workflow_fill(
4915
+ fill_kwargs = dict(
4896
4916
  order_no=order_no,
4897
4917
  order_date=order_date,
4898
4918
  symbol=symbol,
@@ -4912,12 +4932,20 @@ class BrokerNodeExecutor(NodeExecutorBase):
4912
4932
  ),
4913
4933
  account_avg_price=account_avg_price,
4914
4934
  )
4935
+ await context.record_workflow_fill(currency=currency, **fill_kwargs)
4915
4936
 
4916
4937
  # AccountTracker refresh로 PnL 이벤트 강제 트리거
4917
4938
  if tracker_info and "tracker" in tracker_info:
4918
4939
  tracker = tracker_info["tracker"]
4919
4940
  if hasattr(tracker, 'refresh_now'):
4920
4941
  await tracker.refresh_now()
4942
+ positions = getattr(tracker, "_positions", None)
4943
+ pos = positions.get(symbol) if isinstance(positions, dict) else None
4944
+ refreshed_currency = _stock_position_fill_currency(pos, symbol, market_code)
4945
+ if refreshed_currency is not None:
4946
+ await context._annotate_workflow_fill_currency(
4947
+ currency=refreshed_currency, **fill_kwargs
4948
+ )
4921
4949
  logger.debug(f"📊 AccountTracker refreshed after fill")
4922
4950
 
4923
4951
  asyncio.run_coroutine_threadsafe(record_and_refresh(), loop)
@@ -17005,9 +17033,11 @@ class NewOrderNodeExecutor(NodeExecutorBase):
17005
17033
  p = float(getattr(item, "OvrsExecPrc", 0) or getattr(item, "OvrsOrdPrc", 0) or 0)
17006
17034
  row = out.setdefault(order_no, {
17007
17035
  "filled_qty": 0, "_amount": 0.0, "symbol": "", "fill_time": "",
17036
+ "_currencies": set(),
17008
17037
  })
17009
17038
  row["filled_qty"] += q
17010
17039
  row["_amount"] += q * p
17040
+ row["_currencies"].add(str(item.CrcyCode or "").strip().upper())
17011
17041
  # 🔴 LS 는 TR 마다 같은 뜻의 필드 이름이 다르고, 이 블록에는 `ShtnIsuNo`
17012
17042
  # (단축종목번호)와 `IsuNo`(종목번호)가 **둘 다** 있다. 이 저장소의 관행은
17013
17043
  # ShtnIsuNo 우선·IsuNo 폴백이다(잔고·체결 파싱이 전부 그렇게 한다).
@@ -17023,6 +17053,9 @@ class NewOrderNodeExecutor(NodeExecutorBase):
17023
17053
  for row in out.values():
17024
17054
  qty = row["filled_qty"]
17025
17055
  row["avg_price"] = (row.pop("_amount") / qty) if qty > 0 else 0.0
17056
+ units = row.pop("_currencies")
17057
+ unit = next(iter(units)) if len(units) == 1 else ""
17058
+ row["currency"] = unit if re.fullmatch(r"[A-Z]{3}", unit) else None
17026
17059
  return out
17027
17060
  except Exception as e:
17028
17061
  context.log("debug", f"COSAQ00102 batch fill query exception: {e}", node_id)
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.37.7"
8
+ version = "1.37.8"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
File without changes