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.
- {programgarden-2.5.5 → programgarden-2.6.0}/PKG-INFO +11 -2
- {programgarden-2.5.5 → programgarden-2.6.0}/README.md +9 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/order_recovery.py +29 -8
- programgarden-2.6.0/programgarden/database/sqlite_scope.py +19 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/workflow_position_tracker.py +11 -1
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/executor.py +62 -3
- programgarden-2.6.0/programgarden/futures_contract_info.py +91 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_events.py +15 -4
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_scenarios.py +2 -2
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_sources.py +12 -1
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_sqlite.py +2 -1
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/validation_replay.py +9 -2
- {programgarden-2.5.5 → programgarden-2.6.0}/pyproject.toml +2 -2
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/__init__.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/binding_validator.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/client.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/code_worker.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/context.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/__init__.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/adjustment_delivery.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/broker_evidence.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/broker_order_totals.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/broker_snapshot.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/db_naming.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/execution_storage.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/fill_reconciler.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/order_cancellation.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/owned_order_cancellation.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/position_reconciliation.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/deep_fixtures.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/futures_orderable.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/futures_pnl.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/futures_read_evidence.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/incremental_build.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/managed_order_control.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/node_runner.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/order_lifecycle.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_contracts.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_external.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_order_adapter.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_orders.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_semantics.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/replay_triggers.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resolver.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resource/context.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/semantic_rules.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-2.5.5 → programgarden-2.6.0}/programgarden/validation_recommender.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
{programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/workflow_position_tracker.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
39
|
+
programgarden-core = "^2.6.0"
|
|
40
40
|
programgarden-finance = "^2.0.1"
|
|
41
41
|
programgarden-community = "^2.3.1"
|
|
42
42
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/owned_order_cancellation.py
RENAMED
|
File without changes
|
{programgarden-2.5.5 → programgarden-2.6.0}/programgarden/database/position_reconciliation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|