programgarden 2.6.7__tar.gz → 2.7.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 (75) hide show
  1. {programgarden-2.6.7 → programgarden-2.7.0}/PKG-INFO +4 -4
  2. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/workflow_position_tracker.py +2 -1
  3. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/deep_fixtures.py +12 -6
  4. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/executor.py +230 -38
  5. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/replay_external.py +60 -1
  6. programgarden-2.7.0/programgarden/replay_responders.py +148 -0
  7. programgarden-2.7.0/programgarden/replay_tape.py +182 -0
  8. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/resolver.py +3 -3
  9. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/validation_replay.py +28 -1
  10. {programgarden-2.6.7 → programgarden-2.7.0}/pyproject.toml +4 -4
  11. {programgarden-2.6.7 → programgarden-2.7.0}/README.md +0 -0
  12. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/__init__.py +0 -0
  13. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/binding_provenance.py +0 -0
  14. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/binding_validator.py +0 -0
  15. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/client.py +0 -0
  16. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/code_worker.py +0 -0
  17. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/context.py +0 -0
  18. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/__init__.py +0 -0
  19. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/adjustment_delivery.py +0 -0
  20. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/broker_evidence.py +0 -0
  21. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/broker_order_totals.py +0 -0
  22. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/broker_snapshot.py +0 -0
  23. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/checkpoint_manager.py +0 -0
  24. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/db_naming.py +0 -0
  25. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/execution_storage.py +0 -0
  26. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/fill_reconciler.py +0 -0
  27. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/order_cancellation.py +0 -0
  28. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/order_recovery.py +0 -0
  29. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/owned_order_cancellation.py +0 -0
  30. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/position_reconciliation.py +0 -0
  31. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/query_builder.py +0 -0
  32. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/sqlite_scope.py +0 -0
  33. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  34. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/futures_contract_info.py +0 -0
  35. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/futures_exchange.py +0 -0
  36. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/futures_history.py +0 -0
  37. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/futures_orderable.py +0 -0
  38. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/futures_pnl.py +0 -0
  39. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/futures_read_evidence.py +0 -0
  40. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/incremental_build.py +0 -0
  41. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/managed_order_control.py +0 -0
  42. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/node_runner.py +0 -0
  43. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/order_lifecycle.py +0 -0
  44. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/paper_futures_business_date.py +0 -0
  45. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/plugin/__init__.py +0 -0
  46. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/plugin/sandbox.py +0 -0
  47. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/providers/__init__.py +0 -0
  48. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/providers/llm_errors.py +0 -0
  49. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/providers/llm_provider.py +0 -0
  50. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/ranking_nodes.py +0 -0
  51. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/reconnect_handler.py +0 -0
  52. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/replay_contracts.py +0 -0
  53. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/replay_events.py +0 -0
  54. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/replay_order_adapter.py +0 -0
  55. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/replay_orders.py +0 -0
  56. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/replay_scenarios.py +0 -0
  57. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/replay_semantics.py +0 -0
  58. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/replay_sources.py +0 -0
  59. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/replay_sqlite.py +0 -0
  60. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/replay_triggers.py +0 -0
  61. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/resource/__init__.py +0 -0
  62. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/resource/context.py +0 -0
  63. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/resource/limiter.py +0 -0
  64. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/resource/monitor.py +0 -0
  65. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/resource/throttle.py +0 -0
  66. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/semantic_rules.py +0 -0
  67. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/tools/__init__.py +0 -0
  68. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/tools/credential_tools.py +0 -0
  69. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/tools/definition_tools.py +0 -0
  70. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/tools/event_tools.py +0 -0
  71. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/tools/job_tools.py +0 -0
  72. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/tools/registry_tools.py +0 -0
  73. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/tools/sqlite_tools.py +0 -0
  74. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/validation_recommender.py +0 -0
  75. {programgarden-2.6.7 → programgarden-2.7.0}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.6.7
3
+ Version: 2.7.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -15,9 +15,9 @@ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
15
15
  Requires-Dist: croniter (>=6.0.0,<7.0.0)
16
16
  Requires-Dist: litellm (>=1.40.0)
17
17
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
18
- Requires-Dist: programgarden-community (>=2.3.1,<3.0.0)
19
- Requires-Dist: programgarden-core (>=2.6.3,<3.0.0)
20
- Requires-Dist: programgarden-finance (>=2.0.5,<3.0.0)
18
+ Requires-Dist: programgarden-community (>=2.3.2,<3.0.0)
19
+ Requires-Dist: programgarden-core (>=2.7.0,<3.0.0)
20
+ Requires-Dist: programgarden-finance (>=2.0.6,<3.0.0)
21
21
  Requires-Dist: psutil (>=6.0.0,<7.0.0)
22
22
  Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
23
23
  Requires-Dist: pydantic (>=2.0.1,<3.0.0)
@@ -2725,7 +2725,7 @@ class WorkflowPositionTracker:
2725
2725
  with sqlite3.connect(self.db_path) as conn:
2726
2726
  cursor = conn.cursor()
2727
2727
  cursor.execute("""
2728
- SELECT order_no, order_date, symbol, exchange, side, quantity
2728
+ SELECT order_no, order_date, symbol, exchange, side, quantity, product
2729
2729
  FROM workflow_orders
2730
2730
  WHERE trading_mode = ? AND (price = 0 OR price IS NULL)
2731
2731
  ORDER BY created_at DESC
@@ -2739,6 +2739,7 @@ class WorkflowPositionTracker:
2739
2739
  "exchange": row[3],
2740
2740
  "side": row[4],
2741
2741
  "quantity": row[5],
2742
+ "product": row[6],
2742
2743
  }
2743
2744
  for row in cursor.fetchall()
2744
2745
  ]
@@ -79,7 +79,8 @@ def _config_symbols(config: Dict[str, Any]) -> Any:
79
79
  _FIXTURE_BARS = 64
80
80
 
81
81
 
82
- def _ohlcv_series(symbol: str, *, n: int = _FIXTURE_BARS, base: float = 100.0) -> List[Dict[str, Any]]:
82
+ def _ohlcv_series(symbol: str, *, n: int = _FIXTURE_BARS, base: float = 100.0,
83
+ minute_step: Optional[int] = None) -> List[Dict[str, Any]]:
83
84
  """Build a deterministic OHLCV series for one symbol (oldest bar first).
84
85
 
85
86
  The close path rises for the first ~⅔ of the window then declines into the
@@ -93,6 +94,9 @@ def _ohlcv_series(symbol: str, *, n: int = _FIXTURE_BARS, base: float = 100.0) -
93
94
  price = base
94
95
  for i in range(n):
95
96
  day = _FIXTURE_ANCHOR + timedelta(days=i)
97
+ if minute_step:
98
+ # Minute bars carry YYYYMMDDHHMMSS stamps, like the live g3203/o3103 rows.
99
+ day = _FIXTURE_ANCHOR.replace(hour=9, minute=30) + timedelta(minutes=i * minute_step)
96
100
  # Rise toward the peak, then ease back down — a gentle mean-reverting arc.
97
101
  if i <= peak:
98
102
  price = base + i * 0.8
@@ -101,7 +105,7 @@ def _ohlcv_series(symbol: str, *, n: int = _FIXTURE_BARS, base: float = 100.0) -
101
105
  price = max(price, base * 0.5)
102
106
  bars.append(
103
107
  {
104
- "date": day.strftime("%Y%m%d"),
108
+ "date": day.strftime("%Y%m%d%H%M%S" if minute_step else "%Y%m%d"),
105
109
  "open": round(price - 0.5, 2),
106
110
  "high": round(price + 1.0, 2),
107
111
  "low": round(price - 1.0, 2),
@@ -112,7 +116,8 @@ def _ohlcv_series(symbol: str, *, n: int = _FIXTURE_BARS, base: float = 100.0) -
112
116
  return bars
113
117
 
114
118
 
115
- def _time_series_entries(config: Dict[str, Any], symbols_raw: Any = None) -> List[Dict[str, Any]]:
119
+ def _time_series_entries(config: Dict[str, Any], symbols_raw: Any = None,
120
+ minute_step: Optional[int] = None) -> List[Dict[str, Any]]:
116
121
  """Build the per-symbol ``{symbol, exchange, time_series:[bars]}`` list that
117
122
  HistoricalDataNode emits at runtime. Downstream ConditionNode auto-iterates
118
123
  this list, so each entry must carry the keys its ``items.extract`` reads
@@ -125,7 +130,7 @@ def _time_series_entries(config: Dict[str, Any], symbols_raw: Any = None) -> Lis
125
130
  {
126
131
  "symbol": entry["symbol"],
127
132
  "exchange": entry["exchange"],
128
- "time_series": _ohlcv_series(entry["symbol"], base=100.0 + idx * 10),
133
+ "time_series": _ohlcv_series(entry["symbol"], base=100.0 + idx * 10, minute_step=minute_step),
129
134
  }
130
135
  )
131
136
  return entries
@@ -179,7 +184,8 @@ def market_data_fixture(config: Dict[str, Any], symbols_raw: Any = None, *, prod
179
184
  return {"values": values}
180
185
 
181
186
 
182
- def historical_data_fixture(config: Dict[str, Any], symbols_raw: Any = None) -> Dict[str, Any]:
187
+ def historical_data_fixture(config: Dict[str, Any], symbols_raw: Any = None, *,
188
+ minute_step: Optional[int] = None) -> Dict[str, Any]:
183
189
  """HistoricalDataNode deep fixture.
184
190
 
185
191
  Real shape (executor.py ``HistoricalDataNodeExecutor.execute`` return):
@@ -195,7 +201,7 @@ def historical_data_fixture(config: Dict[str, Any], symbols_raw: Any = None) ->
195
201
  """
196
202
  if symbols_raw is None:
197
203
  symbols_raw = config.get("symbol") or _config_symbols(config)
198
- entries = _time_series_entries(config, symbols_raw)
204
+ entries = _time_series_entries(config, symbols_raw, minute_step=minute_step)
199
205
  single = entries[0] if len(entries) == 1 else None
200
206
  return {
201
207
  "value": single,
@@ -1012,6 +1012,12 @@ class LSClientManager:
1012
1012
  (ls_instance, success: bool, error_message: str | None)
1013
1013
  """
1014
1014
  from programgarden_finance import LS
1015
+ from programgarden_finance.ls.replay_transport import active_transport, replay_client
1016
+
1017
+ # Offline replay (ls_tape) only: a client whose REST TRs are answered from
1018
+ # the recording. No login, no token provider, never cached or reused.
1019
+ if active_transport() is not None:
1020
+ return replay_client(paper_trading), True, None
1015
1021
 
1016
1022
  # 이미 해당 product의 인스턴스가 있고 로그인된 경우
1017
1023
  if product in cls._instances:
@@ -1102,6 +1108,54 @@ def _resolve_broker_credentials(
1102
1108
  return appkey, appsecret, paper_trading
1103
1109
 
1104
1110
 
1111
+ FUTURES_FILL_PRICE_LOOKBACK_DAYS = 1 # today and the previous local day
1112
+
1113
+
1114
+ def _futures_fill_price_memo(context: Any) -> Dict[str, Any]:
1115
+ """Per-run memory of the futures fill-price recovery (kept on the context)."""
1116
+ memo = getattr(context, "_futures_fill_price_memo", None)
1117
+ if not isinstance(memo, dict):
1118
+ memo = {"attempted": set(), "failures": 0}
1119
+ try:
1120
+ setattr(context, "_futures_fill_price_memo", memo)
1121
+ except Exception:
1122
+ pass
1123
+ return memo
1124
+
1125
+
1126
+ def _futures_fill_price_candidates(orders: Any, context: Any) -> Dict[str, Dict[str, Any]]:
1127
+ """Unpriced futures orders worth one CIDBQ01800 read, by order date.
1128
+
1129
+ Keeps orders of a futures product (rows without a product are kept: the
1130
+ caller already chose the product), dated today or yesterday, with a broker
1131
+ order number, that this run has not looked up yet.
1132
+ """
1133
+ from datetime import date, timedelta
1134
+ from programgarden_finance.ls.overseas_futureoption.extension.execution_history import (
1135
+ normalize_broker_identity,
1136
+ )
1137
+
1138
+ today = date.today()
1139
+ window = {
1140
+ (today - timedelta(days=days)).strftime("%Y%m%d")
1141
+ for days in range(FUTURES_FILL_PRICE_LOOKBACK_DAYS + 1)
1142
+ }
1143
+ attempted = _futures_fill_price_memo(context)["attempted"]
1144
+ wanted: Dict[str, Dict[str, Any]] = {}
1145
+ for order in orders or []:
1146
+ if not isinstance(order, Mapping):
1147
+ continue
1148
+ product = order.get("product")
1149
+ if product is not None and product not in FUTURES_PRODUCTS:
1150
+ continue
1151
+ identity = normalize_broker_identity(order.get("order_no"))
1152
+ order_date = str(order.get("order_date") or "")
1153
+ if not identity or order_date not in window or (order_date, identity) in attempted:
1154
+ continue
1155
+ wanted.setdefault(order_date, {})[identity] = order
1156
+ return wanted
1157
+
1158
+
1105
1159
  def ensure_ls_login(
1106
1160
  appkey: str,
1107
1161
  appsecret: str,
@@ -5582,7 +5636,20 @@ class BrokerNodeExecutor(NodeExecutorBase):
5582
5636
  orders_without_price = context.get_workflow_orders_without_fill_price()
5583
5637
  if not orders_without_price:
5584
5638
  return
5585
-
5639
+ if product == "overseas_futures":
5640
+ # Bounded (2.7.0): only today's/yesterday's futures orders that
5641
+ # this run has not read yet. The broker node re-runs every
5642
+ # scheduled cycle, and unpriced rows stay in the ledger forever
5643
+ # (an order cancelled before any fill keeps price 0), so without
5644
+ # this every cycle would log in and read again.
5645
+ orders_without_price = [
5646
+ order
5647
+ for by_identity in _futures_fill_price_candidates(orders_without_price, context).values()
5648
+ for order in by_identity.values()
5649
+ ]
5650
+ if not orders_without_price:
5651
+ return
5652
+
5586
5653
  context.log("info", f"Found {len(orders_without_price)} orders without fill price - syncing from history", node_id)
5587
5654
 
5588
5655
  from programgarden_finance import LS
@@ -5717,37 +5784,82 @@ class BrokerNodeExecutor(NodeExecutorBase):
5717
5784
  context: ExecutionContext,
5718
5785
  node_id: str,
5719
5786
  ) -> None:
5720
- """해외선물 체결내역에서 가격 동기화"""
5787
+ """Recover missing futures fill prices from CIDBQ01800 order rows.
5788
+
5789
+ Bounded, because the broker node re-runs every scheduled cycle and
5790
+ starts this read again while unpriced rows stay in the ledger forever
5791
+ (an order cancelled before any fill keeps price 0):
5792
+
5793
+ - only futures orders dated today or yesterday (local dates, the clock
5794
+ the futures order path stamps the ledger with);
5795
+ - each order is looked up at most once per run: one CIDBQ01800 read per
5796
+ order date that has an unpriced order this run has not read yet, so
5797
+ a new unpriced order on a date already read causes one more read;
5798
+ - the first failed read of a run is a warning, later ones are debug.
5799
+
5800
+ A row supplies a price only for the same broker order — OvrsFutsOrdNo
5801
+ compared as the integer identity, because CIDBQ01800 zero-pads it
5802
+ ("0000000614") while the order TRs answer "614" — on the same symbol
5803
+ when the ledger row has one, with an executed quantity and a positive
5804
+ AbrdFutsExecPrc (LS paper 2026-10-15: ``ExecQty=1``,
5805
+ ``AbrdFutsExecPrc="24114.00000000000"``). The stored order number and
5806
+ date are written back unchanged.
5807
+
5808
+ Until 2.7.0 this called a method that does not exist, so every attempt
5809
+ ended in a logged warning before any request and no price was ever
5810
+ recovered.
5811
+ """
5721
5812
  try:
5722
- from programgarden_finance import CIDBQ01800
5723
-
5724
- # 오늘 날짜 기준 체결내역 조회
5725
- today = datetime.now().strftime("%Y%m%d")
5726
-
5727
- response = ls.overseas_futureoption().accno().해외선물체결내역조회(
5728
- body=CIDBQ01800.CIDBQ01800InBlock(
5729
- QryDt=today,
5730
- )
5813
+ from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ01800.blocks import (
5814
+ CIDBQ01800InBlock1,
5731
5815
  )
5732
- result = await response.req_async()
5733
-
5734
- if not result or not hasattr(result, 'block1') or not result.block1:
5816
+ from programgarden_finance.ls.overseas_futureoption.extension.execution_history import (
5817
+ normalize_broker_identity,
5818
+ )
5819
+
5820
+ wanted = _futures_fill_price_candidates(orders, context)
5821
+ if not wanted:
5735
5822
  return
5736
-
5737
- # 체결내역에서 주문번호별 가격 추출
5823
+ memo = _futures_fill_price_memo(context)
5824
+
5825
+ accno = ls.overseas_futureoption().accno()
5738
5826
  fill_history = []
5739
- for item in result.block1:
5740
- fill_history.append({
5741
- "order_no": str(getattr(item, 'OrdNo', '')),
5742
- "order_date": today,
5743
- "fill_price": float(getattr(item, 'ExecPrc', 0)),
5744
- })
5745
-
5827
+ for order_date, by_identity in wanted.items():
5828
+ # Mark before the read: a failed or empty answer is not retried
5829
+ # by the next cycles of this run.
5830
+ memo["attempted"].update((order_date, identity) for identity in by_identity)
5831
+ result = await accno.CIDBQ01800(body=CIDBQ01800InBlock1(
5832
+ RecCnt=1, IsuCodeVal="", OrdDt=order_date, ThdayTpCode="",
5833
+ OrdStatCode="0", BnsTpCode="0", QryTpCode="1", OrdPtnCode="00",
5834
+ OvrsDrvtFnoTpCode="A",
5835
+ )).req_async()
5836
+ if getattr(result, "error_msg", None):
5837
+ level = "warning" if memo["failures"] == 0 else "debug"
5838
+ memo["failures"] += 1
5839
+ context.log(level, f"CIDBQ01800 fill price read failed ({order_date}): {result.error_msg}", node_id)
5840
+ continue
5841
+ for row in getattr(result, "block2", None) or []:
5842
+ order = by_identity.get(normalize_broker_identity(getattr(row, "OvrsFutsOrdNo", None)))
5843
+ price = getattr(row, "AbrdFutsExecPrc", None)
5844
+ executed = getattr(row, "ExecQty", 0) or 0
5845
+ if (order is None or isinstance(price, bool) or not isinstance(price, (int, float))
5846
+ or not math.isfinite(price) or price <= 0 or executed <= 0):
5847
+ continue
5848
+ ledger_symbol = str(order.get("symbol") or "").strip().upper()
5849
+ row_symbol = str(getattr(row, "IsuCodeVal", "") or "").strip().upper()
5850
+ if ledger_symbol and row_symbol and ledger_symbol != row_symbol:
5851
+ continue
5852
+ fill_history.append({
5853
+ "order_no": order["order_no"],
5854
+ "order_date": order["order_date"],
5855
+ "fill_price": float(price),
5856
+ })
5857
+
5746
5858
  if fill_history:
5747
5859
  updated = context.sync_workflow_fill_prices_from_history(fill_history)
5748
5860
  if updated > 0:
5749
5861
  context.log("info", f"Synced {updated} order fill prices from futures history", node_id)
5750
-
5862
+
5751
5863
  except Exception as e:
5752
5864
  context.log("warning", f"Failed to sync futures fill prices: {e}", node_id)
5753
5865
 
@@ -12486,7 +12598,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12486
12598
  HistoricalDataNode executor - 과거 OHLCV 데이터 조회
12487
12599
 
12488
12600
  LS Finance Chart API를 사용하여 과거 차트 데이터를 조회합니다.
12489
- - Overseas stock: g3204 (daily/weekly/monthly/yearly; no intraday)
12601
+ - Overseas stock: g3204 (daily/weekly/monthly/yearly), g3203 (1/5/10/15/30/60-minute bars)
12490
12602
  - 해외선물: o3108 (일봉), o3103 (분봉)
12491
12603
  """
12492
12604
 
@@ -12512,10 +12624,12 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12512
12624
  # The broker-specific fetch repeats this guard for direct/legacy callers.
12513
12625
  connection = config.get("connection")
12514
12626
  product = connection.get("product") if isinstance(connection, dict) else None
12627
+ stock_minute_step = None
12515
12628
  if node_type == "OverseasStockHistoricalDataNode" or (
12516
12629
  node_type == "HistoricalDataNode" and product in (None, "overseas_stock")
12517
12630
  ):
12518
- self._stock_history_gubun(config.get("interval", "1d"))
12631
+ tr_code, selector = self._stock_history_request(config.get("interval", "1d"))
12632
+ stock_minute_step = selector if tr_code == "g3203" else None
12519
12633
  if node_type == "OverseasFuturesHistoricalDataNode" or (
12520
12634
  node_type == "HistoricalDataNode" and product in FUTURES_PRODUCTS
12521
12635
  ):
@@ -12536,7 +12650,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12536
12650
  _seed = _cfg_sym or _in_sym
12537
12651
  except Exception:
12538
12652
  _seed = None
12539
- fixture = _df.historical_data_fixture(config, _seed)
12653
+ fixture = _df.historical_data_fixture(config, _seed, minute_step=stock_minute_step)
12540
12654
  override = context.get_deep_fixture(node_id, node_type)
12541
12655
  return _df.apply_override(fixture, override)
12542
12656
 
@@ -12765,17 +12879,38 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12765
12879
  # 파싱 실패 시 오늘 날짜 반환
12766
12880
  return datetime.now().strftime("%Y%m%d")
12767
12881
 
12768
- @staticmethod
12769
- def _stock_history_gubun(interval: str) -> str:
12770
- """Reject unsupported periods instead of relabeling daily bars."""
12771
- periods = {"1d": "2", "1w": "3", "1M": "4", "1Y": "5"}
12772
- if not isinstance(interval, str) or interval not in periods:
12882
+ # LS g3204 period selectors (gubun) and g3203 bar sizes (ncnt, minutes).
12883
+ # Every ncnt below answered rsp_cd 00000 with bars of exactly that step on a
12884
+ # live platform call (2026-10-18, NVDA): 1, 5, 10, 15, 30 and 60.
12885
+ STOCK_PERIOD_GUBUN = {"1d": "2", "1w": "3", "1M": "4", "1Y": "5"}
12886
+ STOCK_MINUTE_NCNT = {"1m": 1, "5m": 5, "10m": 10, "15m": 15, "30m": 30, "60m": 60}
12887
+
12888
+ @classmethod
12889
+ def _stock_history_request(cls, interval: str) -> tuple:
12890
+ """``("g3204", gubun)`` for a period, ``("g3203", ncnt)`` for minute bars.
12891
+
12892
+ Unknown values are refused instead of relabeling daily bars.
12893
+ """
12894
+ if isinstance(interval, str) and interval in cls.STOCK_PERIOD_GUBUN:
12895
+ return "g3204", cls.STOCK_PERIOD_GUBUN[interval]
12896
+ if isinstance(interval, str) and interval in cls.STOCK_MINUTE_NCNT:
12897
+ return "g3203", cls.STOCK_MINUTE_NCNT[interval]
12898
+ raise ValueError(
12899
+ "UNSUPPORTED_STOCK_HISTORY_INTERVAL: OverseasStockHistoricalDataNode "
12900
+ "supports 1d, 1w, 1M and 1Y through LS g3204 and 1m, 5m, 10m, 15m, 30m "
12901
+ "and 60m minute bars through LS g3203; select a supported interval explicitly."
12902
+ )
12903
+
12904
+ @classmethod
12905
+ def _stock_history_gubun(cls, interval: str) -> str:
12906
+ """The g3204 period selector; minute intervals and unknown values are refused."""
12907
+ tr_code, selector = cls._stock_history_request(interval)
12908
+ if tr_code != "g3204":
12773
12909
  raise ValueError(
12774
- "UNSUPPORTED_STOCK_HISTORY_INTERVAL: OverseasStockHistoricalDataNode "
12775
- "supports 1d, 1w, 1M and 1Y through LS g3204. "
12776
- "Intraday g3203 is not supported; select a supported period explicitly."
12910
+ "UNSUPPORTED_STOCK_HISTORY_INTERVAL: g3204 reads 1d, 1w, 1M and 1Y; "
12911
+ f"{interval} is a g3203 minute interval."
12777
12912
  )
12778
- return periods[interval]
12913
+ return selector
12779
12914
 
12780
12915
  async def _fetch_overseas_stock(
12781
12916
  self,
@@ -12789,7 +12924,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12789
12924
  symbol_exchange_map: Dict[str, str] = None,
12790
12925
  symbols_raw: List[Dict[str, Any]] = None,
12791
12926
  ) -> List[Dict[str, Any]]:
12792
- """해외주식 차트 데이터 조회 (g3204)
12927
+ """해외주식 차트 데이터 조회 (g3204 기간봉 / g3203 분봉)
12793
12928
 
12794
12929
  g3204 API 사용 (sdate, edate, qrycnt 지원):
12795
12930
  - g3103: date 기준 최근 30개만 반환 (제한적)
@@ -12799,7 +12934,9 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12799
12934
  """
12800
12935
 
12801
12936
  # Validate before credential access or login, outside the soft-failure handler.
12802
- gubun = self._stock_history_gubun(interval)
12937
+ tr_code, selector = self._stock_history_request(interval)
12938
+ gubun = selector if tr_code == "g3204" else None
12939
+ ncnt = selector if tr_code == "g3203" else None
12803
12940
 
12804
12941
  credential = context.get_credential()
12805
12942
  if not credential:
@@ -12808,6 +12945,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12808
12945
 
12809
12946
  try:
12810
12947
  from programgarden_finance.ls.overseas_stock.chart.g3204.blocks import G3204InBlock
12948
+ from programgarden_finance.ls.overseas_stock.chart.g3203.blocks import G3203InBlock
12811
12949
 
12812
12950
  ls, success, error = ensure_ls_login(
12813
12951
  credential.get("appkey"),
@@ -12861,6 +12999,25 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12861
12999
  exchange = symbol_to_exchange.get(symbol) or exchcd_to_exchange.get(exchcd, "NASDAQ")
12862
13000
 
12863
13001
  keysymbol = f"{exchcd}{symbol}"
13002
+
13003
+ if ncnt is not None:
13004
+ bars = await self._fetch_overseas_stock_minutes(
13005
+ api, G3203InBlock(
13006
+ delaygb="R",
13007
+ comp_yn="N",
13008
+ keysymbol=keysymbol,
13009
+ exchcd=exchcd,
13010
+ symbol=symbol,
13011
+ ncnt=ncnt,
13012
+ qrycnt=500, # LS cap for an uncompressed page
13013
+ sdate=start_date,
13014
+ edate=end_date,
13015
+ cts_date="",
13016
+ cts_time="",
13017
+ ), symbol, context, node_id,
13018
+ )
13019
+ result_list.append({"symbol": symbol, "exchange": exchange, "time_series": bars})
13020
+ continue
12864
13021
 
12865
13022
  # g3204 API 사용 (sdate, edate, qrycnt 지원)
12866
13023
  body = G3204InBlock(
@@ -12925,6 +13082,41 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12925
13082
  context.log("error", f"Historical data fetch error: {e}", node_id)
12926
13083
  return self._empty_historical_result(symbols, f"i18n:errors.HISTORICAL_DATA_FETCH_ERROR|error={e}")
12927
13084
 
13085
+ async def _fetch_overseas_stock_minutes(self, api, body, symbol: str, context, node_id: str) -> List[Dict[str, Any]]:
13086
+ """Read one g3203 page of overseas-stock N-minute bars, oldest first.
13087
+
13088
+ LS answers the newest ``qrycnt`` bars at or before ``edate`` within
13089
+ ``sdate..edate`` (observed 2026-10-18: ascending rows, ``tr_cont=Y`` when
13090
+ older bars exist). This reads that first page only, like the futures
13091
+ o3103 path: the requested range is not guaranteed to be complete.
13092
+ ``date`` is ``YYYYMMDD`` + ``loctime`` (``YYYYMMDDHHMMSS``, the exchange
13093
+ local time label exactly as LS returns it); ``volume`` is ``exevol``.
13094
+ Bars outside the regular session are kept as LS returns them; no
13095
+ completion or finality flag is added.
13096
+ """
13097
+ result = await api.chart().g3203(body=body).req_async()
13098
+ if getattr(result, "error_msg", None):
13099
+ context.log("warning", f"g3203 {symbol}: {result.error_msg}", node_id)
13100
+ return []
13101
+ bars = []
13102
+ for item in result.block1 or []:
13103
+ day = getattr(item, "date", "") or ""
13104
+ clock = getattr(item, "loctime", "") or ""
13105
+ bars.append({
13106
+ "date": f"{day}{clock}" if day and clock else day,
13107
+ "open": float(item.open) if item.open else 0,
13108
+ "high": float(item.high) if item.high else 0,
13109
+ "low": float(item.low) if item.low else 0,
13110
+ "close": float(item.close) if item.close else 0,
13111
+ "volume": int(item.exevol) if item.exevol else 0,
13112
+ })
13113
+ bars.sort(key=lambda x: x["date"])
13114
+ if not bars:
13115
+ context.log("warning", f"No minute data for {symbol}", node_id)
13116
+ else:
13117
+ context.log("debug", f"Fetched {len(bars)} g3203 bars for {symbol} (ncnt={body.ncnt})", node_id)
13118
+ return bars
13119
+
12928
13120
  async def _fetch_overseas_futures(
12929
13121
  self,
12930
13122
  symbols: List[str],
@@ -176,10 +176,54 @@ def source_recording(node_type, config, source, contract, *, as_of, item=None):
176
176
  return result
177
177
 
178
178
 
179
+ def per_item_key(node_type, item):
180
+ """The key of one per-item recording in ``record["items"]``.
181
+
182
+ A symbol item is keyed ``EXCHANGE:SYMBOL`` (a domestic node's item without
183
+ an exchange is ``KRX:SYMBOL``, as the domestic schemas accept ``{symbol}``).
184
+ Any other item (a position row without an exchange, a number, a text, a
185
+ nested object) is keyed by its ``content_hash``. Recording authors use this
186
+ function so the worker and the author compute the same key.
187
+ """
188
+ from programgarden.validation_replay import content_hash
189
+ if isinstance(item, dict) and isinstance(item.get("symbol"), str) and item["symbol"]:
190
+ exchange = item.get("exchange")
191
+ if not exchange and node_type.startswith("KoreaStock"):
192
+ exchange = "KRX"
193
+ if isinstance(exchange, str) and exchange:
194
+ return exchange + ":" + item["symbol"]
195
+ return content_hash(item)
196
+
197
+
198
+ def branch_item(context, config):
199
+ """``(split_id, index, item)`` for a node running inside a SplitNode branch, else None.
200
+
201
+ The branch runner passes ``_branch_scope = "<split_id>#<index>"`` in the
202
+ node config and publishes the whole array once on the split's ``items``
203
+ port before the first item runs. The item is read from that array by index,
204
+ never from the split's ``item`` port or ``_split_context`` state: parallel
205
+ branches overwrite those single slots while another item is still running.
206
+ """
207
+ scope = config.get("_branch_scope") if isinstance(config, dict) else None
208
+ if not isinstance(scope, str) or "#" not in scope:
209
+ return None
210
+ split_id, _, raw_index = scope.rpartition("#")
211
+ try:
212
+ index = int(raw_index)
213
+ except ValueError:
214
+ return None
215
+ items = context.get_output(split_id, "items")
216
+ if not isinstance(items, list) or not 0 <= index < len(items):
217
+ raise ContractViolation(split_id, "The split item of this branch is not available",
218
+ "REPLAY_FIXTURE_REQUIRED")
219
+ return split_id, index, items[index]
220
+
221
+
179
222
  def external_record(fixture, node_id, node_type, config, context):
180
223
  record = fixture.get("nodes", {}).get(node_id)
181
224
  if not isinstance(record, dict):
182
225
  raise ContractViolation(node_id, "An explicit external recording is required", "REPLAY_FIXTURE_REQUIRED")
226
+ expected_item = None
183
227
  if context._iteration_total:
184
228
  item = context._iteration_item
185
229
  if not isinstance(item, dict) or not isinstance(item.get("symbol"), str) or not item["symbol"]:
@@ -191,6 +235,21 @@ def external_record(fixture, node_id, node_type, config, context):
191
235
  if not isinstance(exchange, str) or not exchange:
192
236
  raise ContractViolation(node_id, "An overseas recording requires exchange identity", "REPLAY_FIXTURE_REQUIRED")
193
237
  record = record.get("items", {}).get(exchange + ":" + item["symbol"])
238
+ expected_item = item
239
+ elif isinstance(record.get("items"), dict):
240
+ # A per-item group inside a SplitNode branch (a basket): select this
241
+ # item's recording. A flat recording (no "items") keeps its meaning: one
242
+ # request identity for every item, with no item bound (item null).
243
+ branch = branch_item(context, config)
244
+ if branch is None:
245
+ raise ContractViolation(node_id, "A per-item recording runs only for an iterated or split item",
246
+ "REPLAY_FIXTURE_REQUIRED")
247
+ split_id, index, expected_item = branch
248
+ key = per_item_key(node_type, expected_item)
249
+ record = record["items"].get(key)
250
+ if not isinstance(record, dict):
251
+ raise ContractViolation(node_id, f"No recording for split item {key} ({split_id}#{index})",
252
+ "REPLAY_FIXTURE_REQUIRED", {"split": split_id, "index": index, "key": key})
194
253
  if not isinstance(record, dict):
195
254
  raise ContractViolation(node_id, "No matching per-item recording", "REPLAY_FIXTURE_REQUIRED")
196
255
  if "request" not in record or "as_of" not in record or "item" not in record:
@@ -211,7 +270,7 @@ def external_record(fixture, node_id, node_type, config, context):
211
270
  raise ContractViolation(node_id, _mismatch_message(
212
271
  "Recording does not match the shared replay clock", ["as_of"]),
213
272
  "REPLAY_FIXTURE_MISMATCH", detail)
214
- item = context._iteration_item if context._iteration_total else None
273
+ item = expected_item
215
274
  if content_hash(record["item"]) != content_hash(item):
216
275
  detail = mismatch_detail(record["item"], item)
217
276
  raise ContractViolation(node_id, _mismatch_message(
@@ -0,0 +1,148 @@
1
+ """Request-independent responders: answers built for the request the node actually makes.
2
+
3
+ Some external nodes bind values that exist only at run time (a price a CodeNode
4
+ computes, an order result, an AI prompt built from indicators), so no recording
5
+ made before execution can match their request. A responder recording answers
6
+ whatever request the node resolves, through the node's real parser, and is
7
+ labelled a scenario assumption (``ReplayResult.assumptions``), never broker or
8
+ provider evidence::
9
+
10
+ {"kind": "responder", "as_of": "<fixture clock>", ...}
11
+
12
+ - ``TelegramNode``: renders the message exactly as the live node would, never
13
+ sends it, returns ``{"sent": True, "message_id": "replay-N"}`` and appends
14
+ ``{node_id, text, parse_mode}`` to ``ReplayResult.outbox``.
15
+ - ``LLMModelNode``: the live executor's non-secret ``connection`` output
16
+ (no credential is read; ``api_key`` stays ``None``).
17
+ - ``AIAgentNode``: ``answer`` (or ``answers`` keyed by
18
+ ``replay_external.per_item_key`` of the iterated/split item) is the raw text a
19
+ model would write; the live parser (``output_format``/``output_schema``,
20
+ presets applied) produces ``response``. No model is called.
21
+ - ``OverseasFuturesOrderableQuantityNode``: ``orderable_quantity`` (an integer
22
+ >= 0) is the scenario's stated capacity. A CIDBQ01400 response echoing the
23
+ ACTUAL resolved request is built and read by the live parser.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from decimal import Decimal
28
+ import json
29
+ from typing import Any, Dict
30
+
31
+ from programgarden.replay_contracts import ContractViolation
32
+
33
+ RESPONDER_NODES = frozenset({"TelegramNode", "LLMModelNode", "AIAgentNode",
34
+ "OverseasFuturesOrderableQuantityNode"})
35
+ _PROMPT_PREVIEW = 2000
36
+
37
+
38
+ def _check_clock(node_id: str, record: Dict[str, Any], context) -> None:
39
+ if not context.validation_as_of or record.get("as_of") != context.validation_as_of:
40
+ raise ContractViolation(node_id, "Recording does not match the shared replay clock; differs at as_of",
41
+ "REPLAY_FIXTURE_MISMATCH", {"recorded": {"as_of": record.get("as_of")},
42
+ "resolved": {"as_of": context.validation_as_of}, "differing": ["as_of"]})
43
+
44
+
45
+ def _current_item(node_type: str, config: Dict[str, Any], context):
46
+ """(found, item) of the iteration or split branch this execution runs for."""
47
+ from programgarden.replay_external import branch_item
48
+ if context._iteration_total:
49
+ return True, context._iteration_item
50
+ branch = branch_item(context, config)
51
+ return (True, branch[2]) if branch is not None else (False, None)
52
+
53
+
54
+ def _telegram(runner, node_id, config, context, record):
55
+ template = config.get("template") or ""
56
+ # Same text rule as TelegramNode.execute (no render_template on the engine context).
57
+ text = template if isinstance(template, str) and template.strip() else "ProgramGarden notification"
58
+ outbox = runner.outcome.outbox
59
+ outbox.append({"node_id": node_id, "text": text, "parse_mode": "HTML",
60
+ "scope": config.get("_branch_scope")})
61
+ return {"sent": True, "message_id": f"replay-{len(outbox)}"}
62
+
63
+
64
+ def _llm_model(runner, node_id, config, context, record):
65
+ return {"connection": {
66
+ "provider": config.get("provider", "openai"), "model": config.get("model", "gpt-4o"),
67
+ "api_key": None, "_llm_node_id": node_id, "base_url": config.get("base_url"),
68
+ "organization": config.get("organization"), "api_version": config.get("api_version"),
69
+ "temperature": config.get("temperature", 0.7), "max_tokens": config.get("max_tokens", 1000),
70
+ "seed": config.get("seed"), "streaming": config.get("streaming", False)}}
71
+
72
+
73
+ def _ai_agent(runner, node_id, config, context, record):
74
+ from programgarden.executor import AIAgentNodeExecutor
75
+ from programgarden.replay_external import per_item_key
76
+
77
+ if "answers" in record:
78
+ found, item = _current_item("AIAgentNode", config, context)
79
+ answers = record["answers"]
80
+ key = per_item_key("AIAgentNode", item) if found else None
81
+ if not isinstance(answers, dict) or key not in answers:
82
+ raise ContractViolation(node_id, f"No responder answer for item {key}", "REPLAY_FIXTURE_REQUIRED")
83
+ raw = answers[key]
84
+ elif "answer" in record:
85
+ raw = record["answer"]
86
+ else:
87
+ raise ContractViolation(node_id, "An AI responder needs answer or answers", "REPLAY_FIXTURE_INVALID")
88
+ if not isinstance(raw, str):
89
+ raise ContractViolation(node_id, "A responder answer is the raw text a model writes",
90
+ "REPLAY_FIXTURE_INVALID")
91
+ preset = config.get("preset")
92
+ if preset and preset != "custom":
93
+ from programgarden_core.presets import PresetLoader
94
+ config = PresetLoader.apply_preset(preset, config)
95
+ parsed = AIAgentNodeExecutor()._parse_output(
96
+ raw, config.get("output_format", "text"), config.get("output_schema"), context, node_id)
97
+ prompt = config.get("user_prompt", "")
98
+ if not isinstance(prompt, str):
99
+ prompt = json.dumps(prompt, ensure_ascii=False, default=str)
100
+ runner.outcome.assumptions.append({"node_id": node_id, "kind": "responder", "node_type": "AIAgentNode",
101
+ "prompt_preview": prompt[:_PROMPT_PREVIEW],
102
+ "label": record.get("label")})
103
+ return {"response": parsed}
104
+
105
+
106
+ def _orderable_quantity(runner, node_id, config, context, record):
107
+ from programgarden.futures_orderable import (
108
+ FuturesCapacityEvidenceError, build_orderable_request, read_orderable_quantity)
109
+ from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ01400.blocks import CIDBQ01400Response
110
+
111
+ quantity = record.get("orderable_quantity")
112
+ if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity < 0:
113
+ raise ContractViolation(node_id, "orderable_quantity must be an integer >= 0", "REPLAY_FIXTURE_INVALID")
114
+ try:
115
+ request = build_orderable_request(config)
116
+ except FuturesCapacityEvidenceError as exc:
117
+ raise ContractViolation(node_id, str(exc), "REPLAY_CONTRACT_FAILED") from exc
118
+ echo = request.model_dump()
119
+ echo["OvrsDrvtOrdPrc"] = str(Decimal(str(echo["OvrsDrvtOrdPrc"]))) # LS echoes price as text
120
+ response = CIDBQ01400Response.model_validate({
121
+ "header": None, "block1": {**echo, "AcntNo": ""}, "block2": {"RecCnt": 1, "OrdAbleQty": quantity},
122
+ "status_code": 200, "rsp_cd": "00000", "rsp_msg": "scenario answer", "error_msg": None})
123
+ try:
124
+ value = read_orderable_quantity(response, request)
125
+ except FuturesCapacityEvidenceError as exc: # pragma: no cover - built to match
126
+ raise ContractViolation(node_id, str(exc), "REPLAY_CONTRACT_FAILED") from exc
127
+ runner.outcome.assumptions.append({"node_id": node_id, "kind": "responder",
128
+ "node_type": "OverseasFuturesOrderableQuantityNode",
129
+ "request": {"symbol": echo["IsuCodeVal"], "side": echo["BnsTpCode"],
130
+ "price": echo["OvrsDrvtOrdPrc"]},
131
+ "orderable_quantity": quantity, "label": record.get("label")})
132
+ return {"quantity": value, "verified": True, "error": None}
133
+
134
+
135
+ _RESPONDERS = {"TelegramNode": _telegram, "LLMModelNode": _llm_model, "AIAgentNode": _ai_agent,
136
+ "OverseasFuturesOrderableQuantityNode": _orderable_quantity}
137
+
138
+
139
+ def respond(runner, node_id: str, node_type: str, config: Dict[str, Any], context,
140
+ record: Dict[str, Any]) -> Dict[str, Any]:
141
+ _check_clock(node_id, record, context)
142
+ handler = _RESPONDERS.get(node_type)
143
+ if handler is None:
144
+ raise ContractViolation(node_id, "No responder for this node type", "REPLAY_CAPABILITY_BLOCKED")
145
+ if node_type == "TelegramNode":
146
+ runner.outcome.assumptions.append({"node_id": node_id, "kind": "responder", "node_type": node_type,
147
+ "note": "message collected in outbox, not sent"})
148
+ return handler(runner, node_id, config, context, record)
@@ -0,0 +1,182 @@
1
+ """Replay LS REST read nodes through their real executors over recorded HTTP answers.
2
+
3
+ A recording ``{"kind": "ls_tape", "as_of": ..., "tape": [entry, ...]}`` lets the
4
+ validation worker run the node's own executor and the finance TR parser over
5
+ scenario answers. Each entry is one recorded HTTP answer::
6
+
7
+ {"tr": "COSOQ00201", # LS tr_cd
8
+ "request": {"COSOQ00201InBlock1": {...}}, # the JSON body the node sends
9
+ "ignore": ["COSOQ00201InBlock1.BaseDt"], # optional: paths not compared
10
+ "tr_cont": "N", "tr_cont_key": "", # optional continuation header
11
+ "status": 200, # optional, default 200
12
+ "headers": {"tr_cont": "N"}, # optional response headers
13
+ "json": {"rsp_cd": "00000", ...}} # the response body
14
+
15
+ A request is answered by the first entry with the same ``tr``, continuation
16
+ header and body (exactly, apart from the listed ``ignore`` paths, which exist
17
+ for request fields the executor fills from the wall clock, such as a query
18
+ date). A request no entry answers fails the node with
19
+ ``REPLAY_FIXTURE_REQUIRED`` naming the TR. Order TRs, token issuance and token
20
+ reissue are refused (``REPLAY_CAPABILITY_BLOCKED``). The node never sees a
21
+ credential: it reads a non-secret placeholder, and its LS client is a fresh
22
+ replay client with no login (``programgarden_finance.ls.replay_transport``).
23
+
24
+ Tape answers are scenario assumptions written by the suite author, never broker
25
+ evidence. Realtime (websocket) nodes and order nodes are not tape nodes.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from copy import deepcopy
30
+ import json
31
+ from typing import Any, Dict, List, Optional
32
+
33
+ from programgarden.replay_contracts import ContractViolation
34
+
35
+ TAPE_NODES = frozenset({
36
+ f"{product}{kind}Node"
37
+ for product in ("OverseasStock", "OverseasFutures", "KoreaStock")
38
+ for kind in ("Account", "MarketData", "HistoricalData", "Fundamental", "SymbolQuery", "OpenOrders")
39
+ if not (product == "OverseasFutures" and kind == "Fundamental")
40
+ }) | {"KoreaStockRankingNode", "OverseasStockRankingNode", "FuturesContractNode",
41
+ "OverseasFuturesContractInfoNode", "OverseasFuturesOrderableQuantityNode"}
42
+
43
+ _DEFAULT_HEADERS = {"Content-Type": "application/json; charset=utf-8", "tr_cont": "N", "tr_cont_key": ""}
44
+ _MAX_REPORTED = 8
45
+
46
+
47
+ def _canon(value: Any) -> str:
48
+ return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
49
+
50
+
51
+ def _without(body: Any, paths: List[str]) -> Any:
52
+ """``body`` with the dotted ``paths`` removed (a copy)."""
53
+ result = deepcopy(body)
54
+ for path in paths:
55
+ parts = path.split(".")
56
+ node = result
57
+ for part in parts[:-1]:
58
+ node = node.get(part) if isinstance(node, dict) else None
59
+ if isinstance(node, dict):
60
+ node.pop(parts[-1], None)
61
+ return result
62
+
63
+
64
+ class LsTape:
65
+ """The installed replay transport of one tape node execution."""
66
+
67
+ def __init__(self, node_id: str, record: Dict[str, Any], paper_trading: bool):
68
+ entries = record.get("tape")
69
+ if not isinstance(entries, list) or not entries:
70
+ raise ContractViolation(node_id, "An ls_tape recording needs a non-empty tape list",
71
+ "REPLAY_FIXTURE_INVALID")
72
+ self.node_id = node_id
73
+ self.paper_trading = bool(paper_trading)
74
+ self.entries: List[Dict[str, Any]] = []
75
+ for index, entry in enumerate(entries):
76
+ where = f"{node_id}.tape[{index}]"
77
+ if not isinstance(entry, dict) or not isinstance(entry.get("tr"), str) or not entry["tr"]:
78
+ raise ContractViolation(where, "A tape entry needs its tr code", "REPLAY_FIXTURE_INVALID")
79
+ if not isinstance(entry.get("request"), dict) or not isinstance(entry.get("json"), dict):
80
+ raise ContractViolation(where, "A tape entry needs a request body and a json answer",
81
+ "REPLAY_FIXTURE_INVALID")
82
+ ignore = entry.get("ignore", [])
83
+ if not isinstance(ignore, list) or not all(isinstance(p, str) and p for p in ignore):
84
+ raise ContractViolation(where, "ignore must list dotted request paths", "REPLAY_FIXTURE_INVALID")
85
+ status = entry.get("status", 200)
86
+ if isinstance(status, bool) or not isinstance(status, int):
87
+ raise ContractViolation(where, "status must be an integer", "REPLAY_FIXTURE_INVALID")
88
+ _canon(entry["request"]), _canon(entry["json"]) # finite JSON only
89
+ self.entries.append(entry)
90
+ self.calls: List[Dict[str, Any]] = []
91
+ self.misses: List[Dict[str, Any]] = []
92
+ self.refusals: List[Dict[str, Any]] = []
93
+
94
+ # -- transport protocol (programgarden_finance.ls.replay_transport) ---------
95
+ def answer(self, tr_cd: str, url: str, header: Dict[str, Any], body: Dict[str, Any]):
96
+ from programgarden_finance.ls.replay_transport import ReplayTransportRefused
97
+
98
+ if url.rstrip("/").endswith("/order"):
99
+ self.refusals.append({"kind": "order", "tr": tr_cd})
100
+ raise ReplayTransportRefused(f"order TR {tr_cd} is not answered by a tape")
101
+ cont = (str(header.get("tr_cont") or "N"), str(header.get("tr_cont_key") or ""))
102
+ nearest: Optional[Dict[str, Any]] = None
103
+ for index, entry in enumerate(self.entries):
104
+ if entry["tr"] != tr_cd:
105
+ continue
106
+ if (str(entry.get("tr_cont", "N")), str(entry.get("tr_cont_key", ""))) != cont:
107
+ continue
108
+ ignore = entry.get("ignore", [])
109
+ if _canon(_without(body, ignore)) == _canon(_without(entry["request"], ignore)):
110
+ self.calls.append({"tr": tr_cd, "entry": index})
111
+ headers = {**_DEFAULT_HEADERS, "tr_cd": tr_cd, **(entry.get("headers") or {})}
112
+ return entry.get("status", 200), deepcopy(entry["json"]), headers
113
+ if nearest is None:
114
+ nearest = entry
115
+ from programgarden.replay_external import mismatch_detail
116
+ miss: Dict[str, Any] = {"tr": tr_cd, "tr_cont": cont[0]}
117
+ if nearest is not None:
118
+ miss.update(mismatch_detail(_without(nearest["request"], nearest.get("ignore", [])),
119
+ _without(body, nearest.get("ignore", []))))
120
+ self.misses.append(miss)
121
+ raise ReplayTransportRefused(f"no tape answer for TR {tr_cd}")
122
+
123
+ def refused(self, kind: str, detail: str) -> None:
124
+ self.refusals.append({"kind": kind, "detail": str(detail)[:200]})
125
+
126
+
127
+ class _TapeContext:
128
+ """The replay context as a live read node sees it under a tape.
129
+
130
+ Live read paths run (no dry-run or deep-validation shortcut) and the node
131
+ reads a non-secret placeholder credential instead of the context secrets.
132
+ """
133
+
134
+ is_dry_run = False
135
+ is_deep_validate = False
136
+
137
+ def __init__(self, context, tape: LsTape):
138
+ object.__setattr__(self, "_context", context)
139
+ object.__setattr__(self, "_tape", tape)
140
+
141
+ def __getattr__(self, name):
142
+ return getattr(self._context, name)
143
+
144
+ def __setattr__(self, name, value):
145
+ setattr(self._context, name, value)
146
+
147
+ def get_credential(self, credential_id: str = "credential_id"):
148
+ return {"appkey": "REPLAY", "appsecret": "REPLAY", "paper_trading": self._tape.paper_trading}
149
+
150
+
151
+ def _paper_trading(config: Dict[str, Any]) -> bool:
152
+ connection = config.get("connection")
153
+ return bool(connection.get("paper_trading")) if isinstance(connection, dict) else False
154
+
155
+
156
+ async def execute_tape(runner, node_id: str, node_type: str, config: Dict[str, Any], context,
157
+ record: Dict[str, Any], kwargs: Dict[str, Any]) -> Dict[str, Any]:
158
+ """Run the node's live executor with its LS REST TRs answered from ``record``."""
159
+ from programgarden.executor import WorkflowExecutor
160
+ from programgarden_finance.ls.replay_transport import use_replay_transport
161
+
162
+ if not context.validation_as_of or record.get("as_of") != context.validation_as_of:
163
+ raise ContractViolation(node_id, "Recording does not match the shared replay clock; differs at as_of",
164
+ "REPLAY_FIXTURE_MISMATCH", {"recorded": {"as_of": record.get("as_of")},
165
+ "resolved": {"as_of": context.validation_as_of}, "differing": ["as_of"]})
166
+ tape = LsTape(node_id, record, _paper_trading(config))
167
+ with use_replay_transport(tape):
168
+ output = await WorkflowExecutor.execute_node(runner, node_id, node_type, config,
169
+ _TapeContext(context, tape), **kwargs)
170
+ if tape.refusals:
171
+ raise ContractViolation(node_id, "The node asked for a call a tape never makes: "
172
+ + ", ".join(sorted({r["kind"] for r in tape.refusals})),
173
+ "REPLAY_CAPABILITY_BLOCKED", {"refused": tape.refusals[:_MAX_REPORTED]})
174
+ if tape.misses:
175
+ trs = ", ".join(sorted({m["tr"] for m in tape.misses}))
176
+ first = tape.misses[0]
177
+ suffix = f"; differs at {', '.join(first['differing'][:6])}" if first.get("differing") else ""
178
+ raise ContractViolation(node_id, f"No ls_tape answer for TR {trs}{suffix}", "REPLAY_FIXTURE_REQUIRED",
179
+ {"misses": tape.misses[:_MAX_REPORTED]})
180
+ runner.outcome.assumptions.append({"node_id": node_id, "kind": "ls_tape",
181
+ "trs": [call["tr"] for call in tape.calls][:32]})
182
+ return output
@@ -379,13 +379,13 @@ class WorkflowResolver:
379
379
  continue
380
380
  result.add(build_error(
381
381
  ErrorCode.INVALID_FIELD_ENUM,
382
- "Stock historical intervals require a supported LS g3204 period; "
383
- "intraday g3203 is not supported.",
382
+ "Stock historical intervals are 1d, 1w, 1M, 1Y (LS g3204) or the "
383
+ "1m, 5m, 10m, 15m, 30m, 60m minute bars (LS g3203).",
384
384
  location=ErrorLocation(
385
385
  node_id=node.get("id"), node_type=node_type, field_path="interval",
386
386
  ),
387
387
  available_values=field.enum_values,
388
- suggestion="Choose 1d, 1w, 1M or 1Y explicitly; do not substitute daily bars for an intraday strategy.",
388
+ suggestion="Choose one of the listed intervals explicitly (60m, not 1h); do not substitute daily bars for an intraday strategy.",
389
389
  ))
390
390
 
391
391
  def _validate_futures_history_contract(self, workflow, registry, result) -> None:
@@ -115,6 +115,12 @@ class ReplayResult:
115
115
  # no events (or the main flow raised) — consumers then fall back to the final state.
116
116
  main_outputs: dict[str, Any] | None = None
117
117
  main_executed: list[str] | None = None
118
+ # Messages notification responders would have sent (never sent), in order:
119
+ # {node_id, text, parse_mode, scope}. Scenario output, not delivery evidence.
120
+ outbox: list[dict[str, Any]] = field(default_factory=list)
121
+ # Which nodes were answered by scenario assumptions (ls_tape, responder),
122
+ # so a reader never mistakes them for broker or provider evidence.
123
+ assumptions: list[dict[str, Any]] = field(default_factory=list)
118
124
 
119
125
  @property
120
126
  def initial_outputs(self) -> dict[str, Any]:
@@ -242,7 +248,28 @@ class ReplayExecutor(WorkflowExecutor):
242
248
  rules = self.fixture.get("contracts", {}).get(node_id, {})
243
249
  if "input" in rules:
244
250
  check_contract(config, rules["input"], f"{node_id}.input")
245
- if node_type in FIXTURE_NODES:
251
+ own_record = self.fixture.get("nodes", {}).get(node_id)
252
+ record_kind = own_record.get("kind") if isinstance(own_record, dict) else None
253
+ if record_kind == "ls_tape":
254
+ from programgarden.replay_tape import TAPE_NODES, execute_tape
255
+ if node_type not in TAPE_NODES:
256
+ raise ContractViolation(node_id, "An ls_tape recording serves LS REST read nodes only",
257
+ "REPLAY_CAPABILITY_BLOCKED")
258
+ output = await execute_tape(self, node_id, node_type, config, context, own_record, kwargs)
259
+ if node_type.endswith("OpenOrdersNode") and isinstance(output, dict):
260
+ # Same snapshot/book rules as a node-output recording.
261
+ from programgarden.replay_order_adapter import check_open_orders_snapshot
262
+ if self.orders is not None:
263
+ self.orders.check_open_orders(output, node_type)
264
+ else:
265
+ check_open_orders_snapshot(output)
266
+ elif record_kind == "responder":
267
+ from programgarden.replay_responders import RESPONDER_NODES, respond
268
+ if node_type not in RESPONDER_NODES:
269
+ raise ContractViolation(node_id, "No responder for this node type",
270
+ "REPLAY_CAPABILITY_BLOCKED")
271
+ output = respond(self, node_id, node_type, config, context, own_record)
272
+ elif node_type in FIXTURE_NODES:
246
273
  from programgarden.replay_external import external_record
247
274
  from programgarden.replay_order_adapter import check_open_orders_snapshot, check_order_events_shape
248
275
  record = external_record(self.fixture, node_id, node_type, config, context)
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "2.6.7"
8
+ version = "2.7.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -36,9 +36,9 @@ litellm = ">=1.40.0"
36
36
  # 로 통째 실패한다(1.35.1 의 조용한 TypeError 보다 강한 모듈 로드 실패).
37
37
  # (1.26.0 이후로도 context.py 는 WorkflowPnLEvent(**event_data) 에 personal_metrics
38
38
  # 를 무조건 실으므로 그 하한 근거도 이 상한에 포함된다.)
39
- programgarden-core = "^2.6.3"
40
- programgarden-finance = "^2.0.5"
41
- programgarden-community = "^2.3.1"
39
+ programgarden-core = "^2.7.0"
40
+ programgarden-finance = "^2.0.6"
41
+ programgarden-community = "^2.3.2"
42
42
 
43
43
  [tool.poetry.group.dev.dependencies]
44
44
  pytest = "^8.0.0"
File without changes