programgarden 2.5.4__tar.gz → 2.5.6__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 (66) hide show
  1. {programgarden-2.5.4 → programgarden-2.5.6}/PKG-INFO +1 -1
  2. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/context.py +1 -0
  3. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/order_recovery.py +29 -8
  4. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/workflow_position_tracker.py +11 -1
  5. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/executor.py +23 -6
  6. {programgarden-2.5.4 → programgarden-2.5.6}/pyproject.toml +1 -1
  7. {programgarden-2.5.4 → programgarden-2.5.6}/README.md +0 -0
  8. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/__init__.py +0 -0
  9. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/binding_validator.py +0 -0
  10. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/client.py +0 -0
  11. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/code_worker.py +0 -0
  12. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/__init__.py +0 -0
  13. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/adjustment_delivery.py +0 -0
  14. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/broker_evidence.py +0 -0
  15. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/broker_order_totals.py +0 -0
  16. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/broker_snapshot.py +0 -0
  17. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/checkpoint_manager.py +0 -0
  18. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/db_naming.py +0 -0
  19. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/execution_storage.py +0 -0
  20. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/fill_reconciler.py +0 -0
  21. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/order_cancellation.py +0 -0
  22. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/owned_order_cancellation.py +0 -0
  23. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/position_reconciliation.py +0 -0
  24. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/query_builder.py +0 -0
  25. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/database/workflow_risk_tracker.py +0 -0
  26. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/deep_fixtures.py +0 -0
  27. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/futures_orderable.py +0 -0
  28. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/futures_pnl.py +0 -0
  29. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/futures_read_evidence.py +0 -0
  30. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/incremental_build.py +0 -0
  31. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/managed_order_control.py +0 -0
  32. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/node_runner.py +0 -0
  33. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/order_lifecycle.py +0 -0
  34. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/plugin/__init__.py +0 -0
  35. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/plugin/sandbox.py +0 -0
  36. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/providers/__init__.py +0 -0
  37. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/providers/llm_errors.py +0 -0
  38. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/providers/llm_provider.py +0 -0
  39. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/reconnect_handler.py +0 -0
  40. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/replay_contracts.py +0 -0
  41. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/replay_events.py +0 -0
  42. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/replay_external.py +0 -0
  43. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/replay_order_adapter.py +0 -0
  44. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/replay_orders.py +0 -0
  45. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/replay_scenarios.py +0 -0
  46. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/replay_semantics.py +0 -0
  47. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/replay_sources.py +0 -0
  48. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/replay_sqlite.py +0 -0
  49. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/replay_triggers.py +0 -0
  50. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/resolver.py +0 -0
  51. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/resource/__init__.py +0 -0
  52. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/resource/context.py +0 -0
  53. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/resource/limiter.py +0 -0
  54. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/resource/monitor.py +0 -0
  55. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/resource/throttle.py +0 -0
  56. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/semantic_rules.py +0 -0
  57. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/tools/__init__.py +0 -0
  58. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/tools/credential_tools.py +0 -0
  59. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/tools/definition_tools.py +0 -0
  60. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/tools/event_tools.py +0 -0
  61. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/tools/job_tools.py +0 -0
  62. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/tools/registry_tools.py +0 -0
  63. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/tools/sqlite_tools.py +0 -0
  64. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/validation_recommender.py +0 -0
  65. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/validation_replay.py +0 -0
  66. {programgarden-2.5.4 → programgarden-2.5.6}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.5.4
3
+ Version: 2.5.6
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -260,6 +260,7 @@ class ExecutionContext:
260
260
  # Stores trackers/connections that should stay alive between flow executions
261
261
  self._persistent_nodes: Dict[str, Any] = {} # node_id -> tracker/connection
262
262
  self._persistent_tasks: Dict[str, asyncio.Task] = {} # node_id -> background task
263
+ self._schedule_tick_source: Optional[str] = None # Only this timer may trigger the current cycle.
263
264
  self._persistent_metadata: Dict[str, Dict[str, Any]] = {} # node_id -> {key: value}
264
265
 
265
266
  # === New: Order Event Dispatcher ===
@@ -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
 
@@ -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"
@@ -2227,12 +2227,12 @@ class ScheduleNodeExecutor(NodeExecutorBase):
2227
2227
  ) -> Dict[str, Any]:
2228
2228
  from datetime import datetime
2229
2229
  from zoneinfo import ZoneInfo
2230
- from croniter import croniter
2230
+ from croniter import croniter, CroniterBadDateError
2231
2231
 
2232
2232
  # 이미 스케줄러가 실행 중이면 재등록하지 않음 (schedule_tick 이벤트로 인한 재실행 시)
2233
2233
  if node_id in context._persistent_tasks:
2234
2234
  context.log("debug", f"Scheduler already running for {node_id}, skip", node_id)
2235
- return {"trigger": True}
2235
+ return {"trigger": bool(context.is_dry_run or context._schedule_tick_source == node_id)}
2236
2236
 
2237
2237
  # ⑭ cron is required — NO silent "*/5 * * * *" fallback. A missing cron
2238
2238
  # used to make the schedule quietly run every 5 minutes ("looks fine,
@@ -2305,7 +2305,7 @@ class ScheduleNodeExecutor(NodeExecutorBase):
2305
2305
  try:
2306
2306
  # second_at_beginning=True로 초 단위 cron도 지원
2307
2307
  try:
2308
- itr = croniter(cron_expr, datetime.now(tz), second_at_beginning=True)
2308
+ itr = croniter(cron_expr, datetime.now(tz), second_at_beginning=True, max_years_between_matches=130)
2309
2309
  except TypeError:
2310
2310
  itr = croniter(cron_expr, datetime.now(tz))
2311
2311
 
@@ -2340,7 +2340,12 @@ class ScheduleNodeExecutor(NodeExecutorBase):
2340
2340
  )
2341
2341
  break
2342
2342
  # 다음 실행 시간 계산
2343
- next_dt = itr.get_next(datetime)
2343
+ try:
2344
+ next_dt = itr.get_next(datetime)
2345
+ except CroniterBadDateError:
2346
+ # A calendar date/year can be exhausted. It is not an
2347
+ # execution failure and must never roll into another year.
2348
+ break
2344
2349
  now = datetime.now(tz)
2345
2350
  delay = (next_dt - now).total_seconds()
2346
2351
 
@@ -2421,8 +2426,9 @@ class ScheduleNodeExecutor(NodeExecutorBase):
2421
2426
  task = asyncio.create_task(scheduler_task())
2422
2427
  context.register_persistent_task(node_id, task)
2423
2428
 
2424
- # 초기 트리거 반환 (첫 플로우 실행용)
2425
- return {"trigger": True}
2429
+ # Register the timer without executing downstream trading work early.
2430
+ # Explicit dry-run simulation still exercises one synthetic cycle.
2431
+ return {"trigger": bool(context.is_dry_run)}
2426
2432
 
2427
2433
 
2428
2434
  class WatchlistNodeExecutor(NodeExecutorBase):
@@ -23557,6 +23563,14 @@ class WorkflowJob:
23557
23563
  event = await self.context.wait_for_event(timeout=1.0)
23558
23564
 
23559
23565
  if event is None:
23566
+ has_realtime_source = any(
23567
+ self.workflow.nodes[n].node_type in REALTIME_NODE_TYPES
23568
+ for n in self._stay_connected_nodes
23569
+ )
23570
+ if (self._has_schedule_node and not has_realtime_source
23571
+ and all(task.done() for task in self.context._persistent_tasks.values())):
23572
+ # Finite calendar/count schedules finish after queued ticks drain.
23573
+ break
23560
23574
  continue
23561
23575
 
23562
23576
  # Wait if paused
@@ -23595,6 +23609,7 @@ class WorkflowJob:
23595
23609
  # RuntimeError 등)가 스케줄 잡 전체를 종료시키지 않도록 한다.
23596
23610
  # 실패 사이클은 silent 하지 않게 'cycle_failed' 로 통지하고
23597
23611
  # 다음 tick 에서 재시도한다 (24시간 무인 운영 보호).
23612
+ self.context._schedule_tick_source = event.source_node_id
23598
23613
  try:
23599
23614
  await self._execute_main_flow()
23600
23615
  except Exception as cycle_err:
@@ -23606,6 +23621,8 @@ class WorkflowJob:
23606
23621
  await self.context.notify_job_state("cycle_failed", self.stats)
23607
23622
  else:
23608
23623
  await self.context.notify_job_state("cycle_completed", self.stats)
23624
+ finally:
23625
+ self.context._schedule_tick_source = None
23609
23626
 
23610
23627
  logger.info("Event loop ended")
23611
23628
 
@@ -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.4"
8
+ version = "2.5.6"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
File without changes