programgarden 2.5.5__tar.gz → 2.6.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 (68) hide show
  1. {programgarden-2.5.5 → programgarden-2.6.0}/PKG-INFO +11 -2
  2. {programgarden-2.5.5 → programgarden-2.6.0}/README.md +9 -0
  3. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/order_recovery.py +29 -8
  4. programgarden-2.6.0/programgarden/database/sqlite_scope.py +19 -0
  5. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/workflow_position_tracker.py +11 -1
  6. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/executor.py +62 -3
  7. programgarden-2.6.0/programgarden/futures_contract_info.py +91 -0
  8. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_events.py +15 -4
  9. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_scenarios.py +2 -2
  10. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_sources.py +12 -1
  11. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_sqlite.py +2 -1
  12. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/validation_replay.py +9 -2
  13. {programgarden-2.5.5 → programgarden-2.6.0}/pyproject.toml +2 -2
  14. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/__init__.py +0 -0
  15. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/binding_validator.py +0 -0
  16. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/client.py +0 -0
  17. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/code_worker.py +0 -0
  18. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/context.py +0 -0
  19. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/__init__.py +0 -0
  20. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/adjustment_delivery.py +0 -0
  21. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/broker_evidence.py +0 -0
  22. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/broker_order_totals.py +0 -0
  23. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/broker_snapshot.py +0 -0
  24. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/checkpoint_manager.py +0 -0
  25. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/db_naming.py +0 -0
  26. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/execution_storage.py +0 -0
  27. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/fill_reconciler.py +0 -0
  28. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/order_cancellation.py +0 -0
  29. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/owned_order_cancellation.py +0 -0
  30. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/position_reconciliation.py +0 -0
  31. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/query_builder.py +0 -0
  32. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  33. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/deep_fixtures.py +0 -0
  34. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/futures_orderable.py +0 -0
  35. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/futures_pnl.py +0 -0
  36. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/futures_read_evidence.py +0 -0
  37. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/incremental_build.py +0 -0
  38. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/managed_order_control.py +0 -0
  39. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/node_runner.py +0 -0
  40. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/order_lifecycle.py +0 -0
  41. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/plugin/__init__.py +0 -0
  42. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/plugin/sandbox.py +0 -0
  43. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/providers/__init__.py +0 -0
  44. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/providers/llm_errors.py +0 -0
  45. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/providers/llm_provider.py +0 -0
  46. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/reconnect_handler.py +0 -0
  47. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_contracts.py +0 -0
  48. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_external.py +0 -0
  49. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_order_adapter.py +0 -0
  50. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_orders.py +0 -0
  51. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_semantics.py +0 -0
  52. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_triggers.py +0 -0
  53. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resolver.py +0 -0
  54. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resource/__init__.py +0 -0
  55. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resource/context.py +0 -0
  56. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resource/limiter.py +0 -0
  57. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resource/monitor.py +0 -0
  58. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resource/throttle.py +0 -0
  59. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/semantic_rules.py +0 -0
  60. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/__init__.py +0 -0
  61. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/credential_tools.py +0 -0
  62. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/definition_tools.py +0 -0
  63. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/event_tools.py +0 -0
  64. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/job_tools.py +0 -0
  65. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/registry_tools.py +0 -0
  66. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/sqlite_tools.py +0 -0
  67. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/validation_recommender.py +0 -0
  68. {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.5.5
3
+ Version: 2.6.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -16,7 +16,7 @@ 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
18
  Requires-Dist: programgarden-community (>=2.3.1,<3.0.0)
19
- Requires-Dist: programgarden-core (>=2.5.3,<3.0.0)
19
+ Requires-Dist: programgarden-core (>=2.6.0,<3.0.0)
20
20
  Requires-Dist: programgarden-finance (>=2.0.1,<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)
@@ -29,6 +29,15 @@ Description-Content-Type: text/markdown
29
29
 
30
30
  # ProgramGarden
31
31
 
32
+ An unreleased [futures detail and durable state candidate](../../docs/futures-contract-details-and-rollover-state.md)
33
+ adds exact o3105 metadata, opt-in execution-scoped SQLite and XHKG calendar
34
+ intersection. Missing evidence remains explicit; this does not authorize orders.
35
+
36
+ An unreleased [offline temporal replay extension](../../docs/offline-temporal-replay.md)
37
+ supports explicit bounded multi-day acceptance without changing the default
38
+ 32-event worker/save path. Public package release remains pending; the server
39
+ team has validated a private DEV candidate with the same runtime changes.
40
+
32
41
  The REST overseas-stock account output preserves broker-reported `sellable_qty`
33
42
  separately from holdings; absent capacity remains null. See
34
43
  [the sell-capacity contract](../../docs/overseas-stock-sellable-quantity.md).
@@ -1,5 +1,14 @@
1
1
  # ProgramGarden
2
2
 
3
+ An unreleased [futures detail and durable state candidate](../../docs/futures-contract-details-and-rollover-state.md)
4
+ adds exact o3105 metadata, opt-in execution-scoped SQLite and XHKG calendar
5
+ intersection. Missing evidence remains explicit; this does not authorize orders.
6
+
7
+ An unreleased [offline temporal replay extension](../../docs/offline-temporal-replay.md)
8
+ supports explicit bounded multi-day acceptance without changing the default
9
+ 32-event worker/save path. Public package release remains pending; the server
10
+ team has validated a private DEV candidate with the same runtime changes.
11
+
3
12
  The REST overseas-stock account output preserves broker-reported `sellable_qty`
4
13
  separately from holdings; absent capacity remains null. See
5
14
  [the sell-capacity contract](../../docs/overseas-stock-sellable-quantity.md).
@@ -97,6 +97,9 @@ def initialize_recovery(conn):
97
97
  columns = {row[1] for row in conn.execute("PRAGMA table_info(workflow_position_lots)")}
98
98
  if "recovery_id" not in columns:
99
99
  conn.execute("ALTER TABLE workflow_position_lots ADD COLUMN recovery_id INTEGER")
100
+ recovery_columns = {row[1] for row in conn.execute("PRAGMA table_info(workflow_order_recoveries)")}
101
+ if "unpriced_quantity" not in recovery_columns:
102
+ conn.execute("ALTER TABLE workflow_order_recoveries ADD COLUMN unpriced_quantity TEXT NOT NULL DEFAULT '0'")
100
103
 
101
104
 
102
105
  def recovery_for_order(conn, tracker, day, number):
@@ -159,7 +162,8 @@ def recover_totals(tracker, totals):
159
162
  with sqlite3.connect(tracker.db_path) as conn:
160
163
  conn.execute("BEGIN IMMEDIATE")
161
164
  # Aggregate execution times are only a recovery ordering, never invented
162
- # timestamps for individual executions. Missing basis fails closed.
165
+ # timestamps for individual executions. Missing basis invalidates PnL,
166
+ # not a broker-verified execution or the later fresh holdings check.
163
167
  for total, (number, symbol, total_qty, price) in sorted(
164
168
  validated, key=lambda item: (item[0].order_date, item[0].fill_time, int(item[1][0]))):
165
169
  scope = tracker.product, tracker.provider, tracker.trading_mode
@@ -232,13 +236,18 @@ def recover_totals(tracker, totals):
232
236
  SELECT 1 FROM trade_history WHERE product=? AND provider=? AND trading_mode=?
233
237
  AND symbol=? AND classification='workflow' AND side='sell' AND fill_datetime>=? LIMIT 1
234
238
  """, (*scope, symbol, fill_datetime)).fetchone()
239
+ later_recovery = conn.execute("""
240
+ SELECT 1 FROM workflow_order_recoveries WHERE product=? AND provider=? AND trading_mode=?
241
+ AND symbol=? AND side='sell' AND fill_datetime>=? LIMIT 1
242
+ """, (*scope, symbol, fill_datetime)).fetchone()
235
243
  adjusted = conn.execute("SELECT 1 FROM sqlite_master WHERE name='position_adjustments'").fetchone()
236
244
  if adjusted:
237
245
  adjusted = conn.execute("""SELECT 1 FROM position_adjustments WHERE product=?
238
246
  AND provider=? AND trading_mode=? AND symbol=? LIMIT 1""", (*scope, symbol)).fetchone()
239
- if later_sell or adjusted:
247
+ if later_sell or later_recovery or adjusted:
240
248
  raise ReconciliationUnavailable("historical_buy_requires_full_replay")
241
249
  pnl = Decimal(0)
250
+ unpriced = Decimal(0)
242
251
  lots = []
243
252
  if total.side == "sell" and missing:
244
253
  lots = conn.execute("""
@@ -246,9 +255,12 @@ def recover_totals(tracker, totals):
246
255
  AND provider=? AND trading_mode=? AND symbol=? AND classification='workflow'
247
256
  AND remaining_qty>0 AND fill_datetime<=? ORDER BY fill_datetime,id
248
257
  """, (*scope, symbol, fill_datetime)).fetchall()
249
- if (any(quantity(row[1]) <= 0 for row in lots)
250
- or sum((quantity(row[2]) for row in lots), Decimal(0)) < missing):
251
- raise ReconciliationUnavailable("recovery_cost_basis_unavailable")
258
+ # Account-wide strategies can sell holdings acquired before this
259
+ # workflow. Never invent their purchase lots or a zero-cost basis.
260
+ # Validate stored numbers even when there are too few owned lots.
261
+ for _, basis, available in lots:
262
+ quantity(basis)
263
+ quantity(available)
252
264
  cursor = conn.execute("""
253
265
  INSERT INTO workflow_order_recoveries
254
266
  (product,provider,trading_mode,order_date,order_no,symbol,exchange,side,
@@ -273,16 +285,25 @@ def recover_totals(tracker, totals):
273
285
  remainder = missing
274
286
  for lot_id, basis, available in lots:
275
287
  used = min(remainder, quantity(available))
276
- pnl += (recovered_price - quantity(basis)) * used
288
+ if quantity(basis) > 0:
289
+ pnl += (recovered_price - quantity(basis)) * used
290
+ else:
291
+ unpriced += used
277
292
  conn.execute("UPDATE workflow_position_lots SET remaining_qty=? WHERE id=?",
278
293
  (float(quantity(available) - used), lot_id))
279
294
  remainder -= used
280
295
  if not remainder:
281
296
  break
282
- conn.execute("UPDATE workflow_order_recoveries SET estimated_pnl=? WHERE id=?", (str(pnl), recovery_id))
297
+ unpriced += remainder
298
+ # estimated_pnl stores only the matched portion for audit. Public
299
+ # readers must return null whenever any quantity lacks a basis.
300
+ conn.execute("UPDATE workflow_order_recoveries SET estimated_pnl=?, unpriced_quantity=? WHERE id=?",
301
+ (str(pnl), str(unpriced), recovery_id))
283
302
  results.append({"id": recovery_id, "order_date": total.order_date, "order_no": number,
284
303
  "symbol": symbol, "side": total.side, "quantity": str(missing),
285
- "estimated_pnl": str(pnl), "currency": total.currency,
304
+ "estimated_pnl": None if unpriced else str(pnl), "currency": total.currency,
305
+ "unpriced_quantity": str(unpriced),
306
+ "cost_basis_status": "unavailable" if unpriced else "estimated",
286
307
  "basis": "broker_order_total", "is_estimated": True, "is_trade": False})
287
308
  return results
288
309
 
@@ -0,0 +1,19 @@
1
+ """Opt-in strategy SQLite namespace below the host-provided durable directory."""
2
+ import hashlib
3
+ import re
4
+
5
+
6
+ def scoped_sqlite_filename(config, context):
7
+ name = config.get('db_name', 'default.db')
8
+ scope = config.get('storage_scope', 'shared')
9
+ if scope == 'shared':
10
+ return name
11
+ if scope != 'execution':
12
+ raise ValueError('Unknown SQLite storage scope')
13
+ if not isinstance(name, str) or not re.fullmatch(r'[A-Za-z0-9_-][A-Za-z0-9_.-]{0,119}\.db', name):
14
+ raise ValueError('Execution-scoped SQLite requires a plain .db filename')
15
+ key = context.execution_key or context.workflow_id
16
+ if not isinstance(key, str) or not key.strip():
17
+ raise ValueError('Execution-scoped SQLite requires a stable execution or workflow identity')
18
+ basis = ('execution:' if context.execution_key else 'workflow:') + key
19
+ return 'strategy_' + hashlib.sha256(basis.encode()).hexdigest() + '_' + name
@@ -1818,10 +1818,16 @@ class WorkflowPositionTracker:
1818
1818
  """Read the immutable recovery audit; this is not individual fill history."""
1819
1819
  with sqlite3.connect(self.db_path) as conn:
1820
1820
  conn.row_factory = sqlite3.Row
1821
- return [dict(row) for row in conn.execute(
1821
+ rows = [dict(row) for row in conn.execute(
1822
1822
  "SELECT * FROM workflow_order_recoveries WHERE product=? AND provider=? "
1823
1823
  "AND trading_mode=? ORDER BY id",
1824
1824
  (self.product, self.provider, self.trading_mode))]
1825
+ for row in rows:
1826
+ unknown = Decimal(row["unpriced_quantity"]) > 0
1827
+ row["cost_basis_status"] = "unavailable" if unknown else "estimated"
1828
+ if unknown:
1829
+ row["estimated_pnl"] = None
1830
+ return rows
1825
1831
 
1826
1832
  def get_position_adjustments(self, limit: int = 50) -> List[Dict[str, Any]]:
1827
1833
  """Return the current product/mode audit without creating synthetic trades."""
@@ -2249,6 +2255,10 @@ class WorkflowPositionTracker:
2249
2255
  reason = "futures_fifo_not_monetary"
2250
2256
  elif not symbol:
2251
2257
  reason = "missing_symbol"
2258
+ elif any(fill.get("cost_basis_status") == "unavailable" for fill in fills):
2259
+ # Verified order quantity can permit startup without establishing
2260
+ # historical profit. Preserve the existing unavailable contract.
2261
+ reason = "incomplete_fifo_basis"
2252
2262
  elif len(units - {None}) > 1 or unit_conflict:
2253
2263
  # Contradictory units cannot establish a comparable FIFO basis.
2254
2264
  reason = "incomplete_fifo_basis"
@@ -6312,6 +6312,49 @@ class AccountNodeExecutor(NodeExecutorBase):
6312
6312
  return result
6313
6313
 
6314
6314
 
6315
+ class FuturesContractInfoNodeExecutor(NodeExecutorBase):
6316
+ """Read exact contract details independently of quote availability."""
6317
+
6318
+ async def execute(self, node_id, node_type, config, context, **kwargs):
6319
+ from programgarden.futures_contract_info import (
6320
+ FuturesContractEvidenceError, build_contract_info_request, read_contract_info,
6321
+ synthetic_contract_info,
6322
+ )
6323
+ config = evaluate_all_bindings(config, context, node_id)
6324
+ try:
6325
+ request = build_contract_info_request(config)
6326
+ if context.is_deep_validate:
6327
+ from programgarden import deep_fixtures
6328
+ return deep_fixtures.apply_override(
6329
+ synthetic_contract_info(config), context.get_deep_fixture(node_id, node_type),
6330
+ )
6331
+ connection = config.get("connection")
6332
+ if not isinstance(connection, dict) or connection.get("product") != "overseas_futures" or not connection.get("credential_id"):
6333
+ raise FuturesContractEvidenceError("A matching futures broker connection is required")
6334
+ try:
6335
+ credential = exact_futures_credential(connection, context)
6336
+ except ValueError as exc:
6337
+ raise FuturesContractEvidenceError(str(exc)) from exc
6338
+ if not credential or not credential.get("appkey") or not credential.get("appsecret"):
6339
+ raise FuturesContractEvidenceError("The selected futures credential is unavailable")
6340
+ ls, success, _ = ensure_ls_login(
6341
+ credential["appkey"], credential["appsecret"], connection.get("paper_trading", False),
6342
+ context, node_id, product="overseas_futures", caller_name="FuturesContractInfoNode",
6343
+ )
6344
+ if not success:
6345
+ raise FuturesContractEvidenceError("The selected futures broker login failed")
6346
+ response = await asyncio.wait_for(
6347
+ ls.overseas_futureoption().market().o3105(body=request).req_async(), timeout=20,
6348
+ )
6349
+ return read_contract_info(response, config)
6350
+ except (FuturesContractEvidenceError, asyncio.TimeoutError) as exc:
6351
+ reason = str(exc) or "Futures contract detail query timed out"
6352
+ except Exception as exc:
6353
+ reason = f"Futures contract detail query failed ({type(exc).__name__})"
6354
+ context.log("warning", reason, node_id)
6355
+ return {"value": None, "verified": False, "missing_fields": [], "error": reason}
6356
+
6357
+
6315
6358
  class FuturesOrderableQuantityNodeExecutor(NodeExecutorBase):
6316
6359
  """Read per-contract capacity; never place an order or replace failed evidence."""
6317
6360
 
@@ -8479,7 +8522,19 @@ class RealOrderEventNodeExecutor(NodeExecutorBase):
8479
8522
  """LS증권 실시간 주문 이벤트 구독"""
8480
8523
 
8481
8524
  # secrets에서 인증 정보 가져오기
8482
- credential = context.get_credential()
8525
+ if product == "overseas_futures":
8526
+ try:
8527
+ credential = exact_futures_credential(config.get("connection") or {}, context)
8528
+ except ValueError:
8529
+ return {"error": "The selected futures credential is unavailable"}
8530
+ connection = config.get("connection") or {}
8531
+ owner = tuple(connection.get(key) for key in ("broker_node_id", "credential_id", "paper_trading"))
8532
+ prior_owner = getattr(context, "_futures_order_event_owner", None)
8533
+ if prior_owner is not None and prior_owner != owner:
8534
+ return {"error": "A futures event stream cannot be shared across broker connections"}
8535
+ context._futures_order_event_owner = owner
8536
+ else:
8537
+ credential = context.get_credential()
8483
8538
 
8484
8539
  if not credential:
8485
8540
  context.log("error", "Credential not found in secrets", node_id)
@@ -8487,7 +8542,8 @@ class RealOrderEventNodeExecutor(NodeExecutorBase):
8487
8542
 
8488
8543
  appkey = credential.get("appkey")
8489
8544
  appsecret = credential.get("appsecret")
8490
- paper_trading = credential.get("paper_trading", False)
8545
+ paper_trading = (config.get("connection", {}).get("paper_trading", False)
8546
+ if product == "overseas_futures" else credential.get("paper_trading", False))
8491
8547
 
8492
8548
  if not appkey or not appsecret:
8493
8549
  context.log("error", "appkey/appsecret not found in credential", node_id)
@@ -19886,7 +19942,8 @@ class SQLiteNodeExecutor(NodeExecutorBase):
19886
19942
  import os
19887
19943
  from programgarden.database.query_builder import SQLQueryBuilder
19888
19944
 
19889
- db_name = config.get("db_name", "default.db")
19945
+ from programgarden.database.sqlite_scope import scoped_sqlite_filename
19946
+ db_name = scoped_sqlite_filename(config, context)
19890
19947
  operation = config.get("operation", "simple")
19891
19948
 
19892
19949
  # dry_run 모드에서는 SELECT만 허용. INSERT/UPDATE/DELETE/UPSERT는
@@ -20334,6 +20391,7 @@ class WorkflowExecutor:
20334
20391
  "OverseasStockSymbolQueryNode": SymbolQueryNodeExecutor(),
20335
20392
  "OverseasFuturesSymbolQueryNode": SymbolQueryNodeExecutor(),
20336
20393
  "FuturesContractNode": FuturesContractNodeExecutor(),
20394
+ "OverseasFuturesContractInfoNode": FuturesContractInfoNodeExecutor(),
20337
20395
  "SymbolFilterNode": SymbolFilterNodeExecutor(),
20338
20396
  "ExclusionListNode": ExclusionListNodeExecutor(),
20339
20397
  "MarketUniverseNode": MarketUniverseNodeExecutor(),
@@ -22055,6 +22113,7 @@ class WorkflowJob:
22055
22113
  # 종목 마스터 조회 노드 — 배열을 **생성**하는 쪽이다. 상류에 배열이 붙었다고
22056
22114
  # 아이템 수만큼 마스터 조회를 반복하면 같은 전체 목록을 N 번 받아온다.
22057
22115
  "FuturesContractNode",
22116
+ "OverseasFuturesContractInfoNode", # One exact identity; explicit SplitNode for a batch.
22058
22117
  "OverseasFuturesSymbolQueryNode", "OverseasStockSymbolQueryNode", "KoreaStockSymbolQueryNode",
22059
22118
  "SymbolFilterNode", # 집합 연산 노드 (배열 입력/출력)
22060
22119
  # LogicNode 도 집합 연산 노드다 — 상류 조건 결과 **배열 전체**를 AND/OR 해야 한다.
@@ -0,0 +1,91 @@
1
+ """Strict o3105 detail projection shared by live nodes and offline replay.
2
+
3
+ Request fields follow finance/example/overseas_futureoption/run_o3105.py.
4
+ No current quote is needed to read valid static contract metadata.
5
+ """
6
+ from datetime import UTC, datetime
7
+ import math
8
+
9
+ from programgarden_finance.ls.overseas_futureoption.market.o3105.blocks import O3105InBlock
10
+
11
+
12
+ TEXT_FIELDS = {
13
+ "symbol_name": "SymbolNm", "underlying_code": "BscGdsCd", "currency": "CrncyCd",
14
+ "trade_status_code": "DlPsblCd",
15
+ }
16
+ DATE_FIELDS = {
17
+ "maturity_date": "MtrtDt", "last_trading_date": "FnlDlDt", "first_notice_date": "FstTrsfrDt",
18
+ "master_received_date": "ApplDate", "korea_start_date": "DlDt",
19
+ "local_start_date": "OvsStrDay", "local_end_date": "OvsEndDay",
20
+ }
21
+ TIME_FIELDS = {"korea_start_time": "DlStrtTm", "korea_end_time": "DlEndTm",
22
+ "local_start_time": "OvsStrTm", "local_end_time": "OvsEndTm"}
23
+ NUMBER_FIELDS = {"initial_margin": "OpngMgn", "maintenance_margin": "MntncMgn",
24
+ "tick_size": "UntPrc", "tick_value": "MnChgAmt"}
25
+
26
+
27
+ class FuturesContractEvidenceError(ValueError):
28
+ """Safe-to-display contract evidence error without broker payloads or secrets."""
29
+
30
+
31
+ def build_contract_info_request(config):
32
+ identity = config.get("symbol")
33
+ if not isinstance(identity, dict) or not all(
34
+ isinstance(identity.get(key), str) and identity[key].strip() for key in ("symbol", "exchange")
35
+ ):
36
+ raise FuturesContractEvidenceError("An exact futures contract symbol and exchange are required")
37
+ return O3105InBlock(symbol=identity["symbol"].strip())
38
+
39
+
40
+ def _text(block, field):
41
+ value = getattr(block, field) if field in block.model_fields_set else None
42
+ return value.strip() or None if isinstance(value, str) else None
43
+
44
+
45
+ def _date_or_time(block, field, *, date):
46
+ value = _text(block, field)
47
+ if not value or len(value) != (8 if date else 6) or not value.isascii() or not value.isdigit():
48
+ return None
49
+ try:
50
+ parsed = datetime.strptime(value, "%Y%m%d" if date else "%H%M%S")
51
+ except ValueError:
52
+ return None
53
+ return parsed.date().isoformat() if date else parsed.time().isoformat()
54
+
55
+
56
+ def read_contract_info(response, config, *, observed_at=None):
57
+ request = build_contract_info_request(config)
58
+ if response.status_code != 200 or response.error_msg or response.rsp_cd != "00000" or response.block is None:
59
+ raise FuturesContractEvidenceError("o3105 did not return a successful contract detail response")
60
+ block = response.block
61
+ if not {"Symbol", "ExchCd"} <= block.model_fields_set or block.Symbol.strip() != request.symbol:
62
+ raise FuturesContractEvidenceError("o3105 contract identity is absent or mismatched")
63
+ exchange = block.ExchCd.strip()
64
+ if exchange != config["symbol"]["exchange"].strip():
65
+ raise FuturesContractEvidenceError("o3105 exchange identity is absent or mismatched")
66
+ now = observed_at or datetime.now(UTC)
67
+ if now.tzinfo is None:
68
+ raise FuturesContractEvidenceError("Contract observation requires a timezone-aware timestamp")
69
+ value = {"symbol": block.Symbol.strip(), "exchange": exchange,
70
+ "source_tr": "o3105", "observed_at": now.astimezone(UTC).isoformat()}
71
+ value.update({name: _text(block, field) for name, field in TEXT_FIELDS.items()})
72
+ for fields, is_date in ((DATE_FIELDS, True), (TIME_FIELDS, False)):
73
+ value.update({name: _date_or_time(block, field, date=is_date) for name, field in fields.items()})
74
+ for name, field in NUMBER_FIELDS.items():
75
+ observed = getattr(block, field) if field in block.model_fields_set else None
76
+ valid = type(observed) in (int, float) and math.isfinite(observed) and observed >= 0
77
+ if name in ("tick_size", "tick_value"):
78
+ valid = valid and observed > 0
79
+ value[name] = float(observed) if valid else None
80
+ return {"value": value, "verified": True, "missing_fields": sorted(k for k, v in value.items() if v is None), "error": None}
81
+
82
+
83
+ def synthetic_contract_info(config, *, observed_at=None):
84
+ """Legacy deep-check shape only; replay instead requires raw recorded evidence."""
85
+ from programgarden_finance.ls.overseas_futureoption.market.o3105.blocks import O3105OutBlock, O3105Response
86
+ request = build_contract_info_request(config)
87
+ response = O3105Response(status_code=200, rsp_cd="00000", rsp_msg="Synthetic deep-check identity", block=O3105OutBlock(
88
+ Symbol=request.symbol, ExchCd=config["symbol"]["exchange"].strip(),
89
+ ))
90
+ # Unknown dates/margins intentionally stay unknown; do not manufacture rollover permission.
91
+ return read_contract_info(response, config, observed_at=observed_at)
@@ -12,9 +12,20 @@ from programgarden.context import WorkflowEvent
12
12
  from programgarden.replay_contracts import ContractViolation, check_contract
13
13
 
14
14
 
15
- def checked_events(fixture):
15
+ DEFAULT_EVENT_LIMIT = 32
16
+ MAX_OFFLINE_EVENT_LIMIT = 4096
17
+
18
+
19
+ def checked_events(fixture, *, event_limit=DEFAULT_EVENT_LIMIT):
20
+ """Allow a trusted offline caller to raise the bound, never a fixture field.
21
+
22
+ Interactive worker callers retain 32 events. Longer acceptance runs keep all
23
+ cron instants and one disposable state store, bounded by replay's timeout.
24
+ """
25
+ if type(event_limit) is not int or not 0 <= event_limit <= MAX_OFFLINE_EVENT_LIMIT:
26
+ raise ContractViolation("events", "Event limit must be an integer from 0 to 4096")
16
27
  events = fixture.get("events", [])
17
- check_contract(events, {"type": "array", "maxItems": 32, "items": {
28
+ check_contract(events, {"type": "array", "maxItems": event_limit, "items": {
18
29
  "type": "object", "required": ["as_of", "type", "source_node_id"],
19
30
  "additionalProperties": False, "properties": {
20
31
  "as_of": {"type": "string", "format": "date-time"},
@@ -79,8 +90,8 @@ def event_fixture(fixture, event):
79
90
  return frame
80
91
 
81
92
 
82
- async def replay_events(job, runner, fixture, outcome):
83
- events = checked_events(fixture)
93
+ async def replay_events(job, runner, fixture, outcome, *, event_limit=DEFAULT_EVENT_LIMIT):
94
+ events = checked_events(fixture, event_limit=event_limit)
84
95
  # Ancestor-only node checks may not yet contain a later event source. Final
85
96
  # verification checks all declared events against the complete graph.
86
97
  pending = iter((index, event) for index, event in enumerate(events)
@@ -23,7 +23,7 @@ def check_financial_expectations(simulation, schema):
23
23
  raise ContractViolation("live_order_count", "Replay must never submit live orders")
24
24
 
25
25
 
26
- def check_final_expectations(result, fixture, graph):
26
+ def check_final_expectations(result, fixture, graph, *, event_limit=32):
27
27
  """Final acceptance asserts outputs and financial state, not just no error."""
28
28
  expected = fixture.get("expected")
29
29
  if not isinstance(expected,dict) or not expected:
@@ -39,7 +39,7 @@ def check_final_expectations(result, fixture, graph):
39
39
  if node_id not in result.initial_executed:
40
40
  raise ContractViolation(node_id, "Required path was not reached")
41
41
  from programgarden.replay_events import checked_events
42
- events = checked_events(fixture)
42
+ events = checked_events(fixture, event_limit=event_limit)
43
43
  if len(events) != len(result.events):
44
44
  raise ContractViolation("events", "Every declared final replay event must be observed")
45
45
  has_orders = any(node["type"] in ORDER_NODES for node in graph["nodes"])
@@ -10,7 +10,7 @@ from programgarden.replay_external import external_record
10
10
  from programgarden.replay_triggers import fixture_instant
11
11
 
12
12
  SOURCE_NODES = frozenset({"MarketUniverseNode",
13
- "FuturesContractNode", "ScreenerNode", "OverseasFuturesOrderableQuantityNode"})
13
+ "FuturesContractNode", "ScreenerNode", "OverseasFuturesOrderableQuantityNode", "OverseasFuturesContractInfoNode"})
14
14
 
15
15
 
16
16
  def source_contract_catalog():
@@ -21,6 +21,7 @@ def source_contract_catalog():
21
21
  The parser and independently written assertions remain authoritative.
22
22
  """
23
23
  from programgarden_finance.ls.overseas_futureoption.market.o3101.blocks import O3101OutBlock
24
+ from programgarden_finance.ls.overseas_futureoption.market.o3105.blocks import O3105Response
24
25
  from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ01400.blocks import CIDBQ01400Response
25
26
  from programgarden_finance.ls.overseas_stock.market.g3101.blocks import G3101OutBlock
26
27
  from programgarden.executor import MarketUniverseNodeExecutor
@@ -39,6 +40,11 @@ def source_contract_catalog():
39
40
  "required_row_fields": ["Symbol", "BscGdsCd", "ExchCd", "LstngYr", "LstngM"],
40
41
  "notes": "Supply raw o3101 model fields, including explicit contract identity/month. "
41
42
  "Native expiry/exchange/front-next selection runs at as_of; do not preselect results."},
43
+ "OverseasFuturesContractInfoNode": {"source_schema": O3105Response.model_json_schema(),
44
+ "notes": "Use the raw SDK o3105 response envelope, with status_code=200, rsp_cd=00000, "
45
+ "no error_msg, and matching block.Symbol and block.ExchCd. Optional dates use "
46
+ "YYYYMMDD, times HHMMSS. Omitted fields stay null; no quote is required. "
47
+ "The recorded response is parsed by the native live detail parser."},
42
48
  "OverseasFuturesOrderableQuantityNode": {"source_schema": CIDBQ01400Response.model_json_schema(),
43
49
  "notes": "Use the SDK response envelope (block1, block2, status_code, rsp_cd, error_msg), "
44
50
  "not LS wire block names. status_code=200, error_msg absent and rsp_cd=00000 or00136 "
@@ -104,6 +110,11 @@ async def execute_source(node, config, fixture, context):
104
110
  return FuturesContractNodeExecutor().select_recorded_master(rows,
105
111
  [p.strip().upper() for p in node.base_products], node.contract_selection, exchange,
106
112
  context, node.id, as_of=fixture_instant(context, node.id))
113
+ if node.type == "OverseasFuturesContractInfoNode":
114
+ from programgarden.futures_contract_info import read_contract_info
115
+ from programgarden_finance.ls.overseas_futureoption.market.o3105.blocks import O3105Response
116
+ response = O3105Response.model_validate(source, strict=True)
117
+ return read_contract_info(response, config, observed_at=fixture_instant(context, node.id))
107
118
  if node.type == "OverseasFuturesOrderableQuantityNode":
108
119
  from programgarden.futures_orderable import build_orderable_request, read_orderable_quantity
109
120
  from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ01400.blocks import CIDBQ01400Response
@@ -20,7 +20,8 @@ async def execute_sqlite(node_id, config, context):
20
20
  if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]{0,119}\.db", name):
21
21
  raise ContractViolation(node_id, "Replay SQLite requires a plain .db filename")
22
22
  root = Path(context._storage_dir).resolve()
23
- path = root / name
23
+ from programgarden.database.sqlite_scope import scoped_sqlite_filename
24
+ path = root / scoped_sqlite_filename(config, context)
24
25
  if path.is_symlink() or path.resolve().parent != root:
25
26
  raise ContractViolation(node_id, "SQLite path leaves the replay workspace")
26
27
  operation = config.get("operation", "simple")
@@ -349,7 +349,8 @@ class ReplayExecutor(WorkflowExecutor):
349
349
 
350
350
 
351
351
  async def replay(definition: dict[str, Any], fixture: dict[str, Any], *,
352
- timeout: float = 30, node_under_test: str | None = None) -> ReplayResult:
352
+ timeout: float = 30, node_under_test: str | None = None,
353
+ event_limit: int = 32) -> ReplayResult:
353
354
  """Run actual scheduler/mappings in disposable state; no silent soft PASS.
354
355
 
355
356
  A node check executes its actual upstream setup in the same disposable
@@ -358,6 +359,9 @@ async def replay(definition: dict[str, Any], fixture: dict[str, Any], *,
358
359
  node_under_test and always start with another fresh workspace.
359
360
  An empty branch is not an exception; required activation/output expectations
360
361
  belong to the suite and are checked explicitly by the coordinator.
362
+ Offline acceptance may explicitly raise event_limit (at most 4096). The
363
+ default worker/save path stays at 32. Every scheduled instant is still
364
+ required; state and the timeout span the whole timeline without resets.
361
365
  """
362
366
  outcome = ReplayResult(False, content_hash(definition), content_hash(fixture))
363
367
  outcome.node_under_test = node_under_test
@@ -391,6 +395,9 @@ async def replay(definition: dict[str, Any], fixture: dict[str, Any], *,
391
395
  context.set_workflow_job(job)
392
396
  context.start()
393
397
  async def run_all():
398
+ # Reject an oversized/malformed timeline before any initial action.
399
+ from programgarden.replay_events import checked_events
400
+ checked_events(fixture, event_limit=event_limit)
394
401
  if any(node.node_type in ORDER_NODES for node in resolved.nodes.values()):
395
402
  # A no-signal branch still has account state to verify. Starting
396
403
  # the book only on the first order loses unchanged cash/holdings
@@ -407,7 +414,7 @@ async def replay(definition: dict[str, Any], fixture: dict[str, Any], *,
407
414
  for node in resolved.nodes}
408
415
  outcome.main_executed = list(outcome.executed)
409
416
  from programgarden.replay_events import replay_events
410
- await replay_events(job, runner, fixture, outcome)
417
+ await replay_events(job, runner, fixture, outcome, event_limit=event_limit)
411
418
 
412
419
  try:
413
420
  await asyncio.wait_for(run_all(), timeout=timeout)
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "2.5.5"
8
+ version = "2.6.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -36,7 +36,7 @@ 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.5.3"
39
+ programgarden-core = "^2.6.0"
40
40
  programgarden-finance = "^2.0.1"
41
41
  programgarden-community = "^2.3.1"
42
42