programgarden 2.6.7__tar.gz → 2.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {programgarden-2.6.7 → programgarden-2.7.1}/PKG-INFO +4 -4
  2. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/broker_snapshot.py +6 -3
  3. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/workflow_position_tracker.py +2 -1
  4. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/deep_fixtures.py +12 -6
  5. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/executor.py +230 -38
  6. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_external.py +60 -1
  7. programgarden-2.7.1/programgarden/replay_responders.py +148 -0
  8. programgarden-2.7.1/programgarden/replay_tape.py +182 -0
  9. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resolver.py +3 -3
  10. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/validation_replay.py +28 -1
  11. {programgarden-2.6.7 → programgarden-2.7.1}/pyproject.toml +4 -4
  12. {programgarden-2.6.7 → programgarden-2.7.1}/README.md +0 -0
  13. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/__init__.py +0 -0
  14. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/binding_provenance.py +0 -0
  15. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/binding_validator.py +0 -0
  16. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/client.py +0 -0
  17. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/code_worker.py +0 -0
  18. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/context.py +0 -0
  19. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/__init__.py +0 -0
  20. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/adjustment_delivery.py +0 -0
  21. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/broker_evidence.py +0 -0
  22. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/broker_order_totals.py +0 -0
  23. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/checkpoint_manager.py +0 -0
  24. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/db_naming.py +0 -0
  25. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/execution_storage.py +0 -0
  26. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/fill_reconciler.py +0 -0
  27. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/order_cancellation.py +0 -0
  28. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/order_recovery.py +0 -0
  29. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/owned_order_cancellation.py +0 -0
  30. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/position_reconciliation.py +0 -0
  31. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/query_builder.py +0 -0
  32. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/sqlite_scope.py +0 -0
  33. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/database/workflow_risk_tracker.py +0 -0
  34. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_contract_info.py +0 -0
  35. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_exchange.py +0 -0
  36. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_history.py +0 -0
  37. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_orderable.py +0 -0
  38. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_pnl.py +0 -0
  39. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/futures_read_evidence.py +0 -0
  40. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/incremental_build.py +0 -0
  41. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/managed_order_control.py +0 -0
  42. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/node_runner.py +0 -0
  43. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/order_lifecycle.py +0 -0
  44. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/paper_futures_business_date.py +0 -0
  45. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/plugin/__init__.py +0 -0
  46. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/plugin/sandbox.py +0 -0
  47. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/providers/__init__.py +0 -0
  48. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/providers/llm_errors.py +0 -0
  49. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/providers/llm_provider.py +0 -0
  50. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/ranking_nodes.py +0 -0
  51. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/reconnect_handler.py +0 -0
  52. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_contracts.py +0 -0
  53. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_events.py +0 -0
  54. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_order_adapter.py +0 -0
  55. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_orders.py +0 -0
  56. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_scenarios.py +0 -0
  57. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_semantics.py +0 -0
  58. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_sources.py +0 -0
  59. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_sqlite.py +0 -0
  60. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/replay_triggers.py +0 -0
  61. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resource/__init__.py +0 -0
  62. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resource/context.py +0 -0
  63. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resource/limiter.py +0 -0
  64. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resource/monitor.py +0 -0
  65. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/resource/throttle.py +0 -0
  66. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/semantic_rules.py +0 -0
  67. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/__init__.py +0 -0
  68. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/credential_tools.py +0 -0
  69. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/definition_tools.py +0 -0
  70. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/event_tools.py +0 -0
  71. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/job_tools.py +0 -0
  72. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/registry_tools.py +0 -0
  73. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/tools/sqlite_tools.py +0 -0
  74. {programgarden-2.6.7 → programgarden-2.7.1}/programgarden/validation_recommender.py +0 -0
  75. {programgarden-2.6.7 → programgarden-2.7.1}/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.1
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -15,9 +15,9 @@ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
15
15
  Requires-Dist: croniter (>=6.0.0,<7.0.0)
16
16
  Requires-Dist: litellm (>=1.40.0)
17
17
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
18
- Requires-Dist: programgarden-community (>=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)
@@ -61,19 +61,22 @@ async def read_broker_snapshot(ls, *, execution_key, product, provider, trading_
61
61
  from programgarden_finance.ls.korea_stock.extension.valuation import collect_domestic_trading_positions
62
62
  from programgarden_finance.ls.korea_stock.extension.account_queries import collect_domestic_open_orders
63
63
 
64
+ # The collector returns t0424 rows (trade-basis balance, chegb=2), not
65
+ # CSPAQ12300 rows: the symbol is expcode and the held quantity janqty,
66
+ # the same fields the server startup gate reads.
64
67
  _, rows = await collect_domestic_trading_positions(ls.korea_stock().accno())
65
68
  for row in rows:
66
- symbol = normalized_symbol(str(required(row, "IsuNo")), product)
69
+ symbol = normalized_symbol(str(required(row, "expcode")), product)
67
70
  if symbol in positions:
68
71
  raise ReconciliationUnavailable("ambiguous_position_rows")
69
- positions[symbol] = quantity(required(row, "BnsBaseBalQty"))
72
+ positions[symbol] = quantity(required(row, "janqty"))
70
73
  await asyncio.sleep(2)
71
74
  pending = await collect_domestic_open_orders(ls.korea_stock().accno())
72
75
  # t0425 is the documented current-day inquiry. Retain that observation
73
76
  # date for exact ledger matching; never widen it to yesterday's order.
74
77
  for row in pending:
75
78
  row["order_date"] = day
76
- source = "CSPAQ12300+t0425"
79
+ source = "t0424+t0425"
77
80
  elif product == "overseas_stock":
78
81
  from programgarden_finance import COSOQ00201, COSAQ00102
79
82
 
@@ -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(