mt5cli 1.2.4__tar.gz → 1.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {mt5cli-1.2.4 → mt5cli-1.3.1}/.github/workflows/ci.yml +1 -1
  2. {mt5cli-1.2.4 → mt5cli-1.3.1}/PKG-INFO +1 -1
  3. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/public-contract.md +4 -1
  4. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/trading.md +108 -7
  5. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/__init__.py +5 -1
  6. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/contract.py +5 -1
  7. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/history.py +34 -6
  8. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/trading.py +706 -59
  9. {mt5cli-1.2.4 → mt5cli-1.3.1}/pyproject.toml +3 -1
  10. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_cli.py +1 -3
  11. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_history.py +165 -9
  12. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_trading.py +1003 -75
  13. {mt5cli-1.2.4 → mt5cli-1.3.1}/uv.lock +9 -2
  14. {mt5cli-1.2.4 → mt5cli-1.3.1}/.agents/skills/local-qa/SKILL.md +0 -0
  15. {mt5cli-1.2.4 → mt5cli-1.3.1}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  16. {mt5cli-1.2.4 → mt5cli-1.3.1}/.agents/skills/mt5cli/SKILL.md +0 -0
  17. {mt5cli-1.2.4 → mt5cli-1.3.1}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  18. {mt5cli-1.2.4 → mt5cli-1.3.1}/.claude/agents/codex.md +0 -0
  19. {mt5cli-1.2.4 → mt5cli-1.3.1}/.claude/settings.json +0 -0
  20. {mt5cli-1.2.4 → mt5cli-1.3.1}/.github/FUNDING.yml +0 -0
  21. {mt5cli-1.2.4 → mt5cli-1.3.1}/.github/dependabot.yml +0 -0
  22. {mt5cli-1.2.4 → mt5cli-1.3.1}/.github/renovate.json +0 -0
  23. {mt5cli-1.2.4 → mt5cli-1.3.1}/.github/workflows/claude.yml +0 -0
  24. {mt5cli-1.2.4 → mt5cli-1.3.1}/.github/workflows/release.yml +0 -0
  25. {mt5cli-1.2.4 → mt5cli-1.3.1}/.gitignore +0 -0
  26. {mt5cli-1.2.4 → mt5cli-1.3.1}/AGENTS.md +0 -0
  27. {mt5cli-1.2.4 → mt5cli-1.3.1}/CLAUDE.md +0 -0
  28. {mt5cli-1.2.4 → mt5cli-1.3.1}/LICENSE +0 -0
  29. {mt5cli-1.2.4 → mt5cli-1.3.1}/README.md +0 -0
  30. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/cli.md +0 -0
  31. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/client.md +0 -0
  32. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/converters.md +0 -0
  33. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/exceptions.md +0 -0
  34. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/grafana.md +0 -0
  35. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/history.md +0 -0
  36. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/index.md +0 -0
  37. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/marketdata.md +0 -0
  38. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/observability.md +0 -0
  39. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/schemas.md +0 -0
  40. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/telemetry.md +0 -0
  41. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/api/utils.md +0 -0
  42. {mt5cli-1.2.4 → mt5cli-1.3.1}/docs/index.md +0 -0
  43. {mt5cli-1.2.4 → mt5cli-1.3.1}/examples/grafana/README.md +0 -0
  44. {mt5cli-1.2.4 → mt5cli-1.3.1}/examples/grafana/compose.yml +0 -0
  45. {mt5cli-1.2.4 → mt5cli-1.3.1}/examples/grafana/dashboards/mt5cli-market.json +0 -0
  46. {mt5cli-1.2.4 → mt5cli-1.3.1}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
  47. {mt5cli-1.2.4 → mt5cli-1.3.1}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
  48. {mt5cli-1.2.4 → mt5cli-1.3.1}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
  49. {mt5cli-1.2.4 → mt5cli-1.3.1}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
  50. {mt5cli-1.2.4 → mt5cli-1.3.1}/mkdocs.yml +0 -0
  51. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/__main__.py +0 -0
  52. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/cli.py +0 -0
  53. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/client.py +0 -0
  54. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/converters.py +0 -0
  55. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/exceptions.py +0 -0
  56. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/grafana.py +0 -0
  57. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/marketdata.py +0 -0
  58. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/observability.py +0 -0
  59. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/retry.py +0 -0
  60. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/schemas.py +0 -0
  61. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/telemetry.py +0 -0
  62. {mt5cli-1.2.4 → mt5cli-1.3.1}/mt5cli/utils.py +0 -0
  63. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/__init__.py +0 -0
  64. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/conftest.py +0 -0
  65. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_client.py +0 -0
  66. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_contracts.py +0 -0
  67. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_examples.py +0 -0
  68. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_grafana.py +0 -0
  69. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_marketdata.py +0 -0
  70. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_observability.py +0 -0
  71. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_packaging.py +0 -0
  72. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_telemetry.py +0 -0
  73. {mt5cli-1.2.4 → mt5cli-1.3.1}/tests/test_utils.py +0 -0
@@ -62,7 +62,7 @@ jobs:
62
62
  - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6.0.2
63
63
  with:
64
64
  persist-credentials: false
65
- - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1
65
+ - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
66
66
  - name: Check CLI help with minimum Click and Typer versions
67
67
  run: uv run --with click==8.4.0 --with typer==0.26.0 mt5cli --help
68
68
  python-docs-deploy:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 1.2.4
3
+ Version: 1.3.1
4
4
  Summary: Generic MT5 data and execution infrastructure for Python applications
5
5
  Project-URL: Repository, https://github.com/dceoy/mt5cli.git
6
6
  Author-email: dceoy <dceoy@users.noreply.github.com>
@@ -102,7 +102,10 @@ strategy entries, exits, Kelly sizing, or signal logic.
102
102
  | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
103
103
  | `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
104
104
  | `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
105
- | `estimate_server_clock_offset_seconds` | Broker server clock offset from true UTC, via the latest tick |
105
+ | `TickClockNormalizer` | Connection-scoped, copied-tick-validated UTC tick normalization |
106
+ | `TickClockCalibration` | Server-clock calibration diagnostics (offset, evidence, status) |
107
+ | `NormalizedTickSnapshot` | Typed latest-tick contract with raw vs validated UTC timestamps |
108
+ | `CalibrationStatus`, `ClockStatus` | Literal status types for clock calibration and normalization |
106
109
  | `detect_position_side` | Net long / short / flat from open positions |
107
110
  | `calculate_spread_ratio` | Relative bid-ask spread |
108
111
  | `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
@@ -215,14 +215,115 @@ Any broker-specific offset handling therefore requires evidence from the live
215
215
  data source in use and must be applied separately. Do not subtract a presumed
216
216
  UTC+2 or UTC+3 offset from timestamps that are already UTC.
217
217
 
218
- `estimate_server_clock_offset_seconds()` reads the latest tick for a symbol
219
- and returns the broker's clock offset from true UTC, rounded to the nearest
220
- half hour, or `None` when no valid tick time is available. Use it to validate a
221
- suspected offset for the live `symbol_info_tick()` source before applying the
222
- result in downstream time-window calculations.
223
-
224
218
  No offset is applied automatically by history retrieval helpers.
225
219
 
220
+ ## UTC normalization for live ticks
221
+
222
+ `TickClockNormalizer` provides the stable API for obtaining a latest tick with
223
+ a **validated** UTC timestamp, so strategy applications never compare a raw
224
+ server-labeled tick time against `datetime.now(UTC)` themselves.
225
+
226
+ ```python
227
+ from mt5cli import TickClockNormalizer, mt5_session
228
+
229
+ with mt5_session() as client:
230
+ normalizer = TickClockNormalizer(client, ["SING30", "EURUSD"])
231
+ snapshot = normalizer.get_normalized_tick_snapshot("SING30")
232
+ if snapshot["clock_status"] == "calibrated":
233
+ freshness = now_utc - snapshot["time_utc"] # safe wall-clock compare
234
+ ```
235
+
236
+ A normalized snapshot keeps the raw/UTC distinction explicit:
237
+
238
+ ```python
239
+ {
240
+ "symbol": "SING30",
241
+ "bid": ...,
242
+ "ask": ...,
243
+ "last": ...,
244
+ "volume": ...,
245
+ "raw_time": 1717243200, # numeric MT5 epoch, as labeled by broker
246
+ "time_utc": datetime(..., tzinfo=UTC), # None unless calibrated
247
+ "server_clock_offset_seconds": 10800.0, # None unless calibrated
248
+ "clock_status": "calibrated", # or "uncalibrated"
249
+ }
250
+ ```
251
+
252
+ ### Calibration evidence
253
+
254
+ The offset is never inferred from `latest_tick_time - now` alone. Each
255
+ calibration sample fetches the live `symbol_info_tick()` value **and** recent
256
+ UTC-labeled `copy_ticks_range()` data, then searches every recent copied row
257
+ (not just the newest) for one that matches the live tick on its shared
258
+ `bid`/`ask`/`last`/`volume` fields (with at least one agreeing positive price
259
+ field); the newest matching row sets the resulting offset, which must align
260
+ to a 30-minute increment within the realistic UTC-14..UTC+14 range.
261
+ `time_msc` is preferred over second-resolution `time` on both sides when
262
+ available.
263
+
264
+ An offset is accepted only after `min_agreeing_samples` (default 2)
265
+ **distinct** matched tick events agree on the same rounded offset — repeated
266
+ observations of one unchanged tick never count twice, so an illiquid symbol
267
+ cannot self-confirm. Evidence can come from multiple samples of one actively
268
+ updating symbol (paced by `sample_interval_seconds`) or across several
269
+ symbols passed to the constructor. Any two matched samples that disagree
270
+ abort the calibration (`offset_disagreement`).
271
+
272
+ The `copy_ticks_range()` query backing each sample extends its upper bound
273
+ `_MAX_FUTURE_SKEW_SECONDS` (2 minutes) past the host clock's current time, in
274
+ addition to covering the full trailing `copied_window_seconds`. A live tick's
275
+ true UTC event is not always at or before the host clock — feed/broker clock
276
+ skew can put it a few seconds ahead — and without that tolerance the matching
277
+ copied row would never be queried, so calibration would always report
278
+ `no_matching_event`. The tolerance is bounded, not open-ended: an event
279
+ further ahead than `_MAX_FUTURE_SKEW_SECONDS` is still excluded from the
280
+ query and cannot be matched.
281
+
282
+ ### Failure modes and fail-closed behavior
283
+
284
+ When calibration cannot be established safely — closed market or weekend
285
+ (`no_copied_ticks`), price disagreement or a non-half-hour delta
286
+ (`no_matching_event`), a single matched event only (`insufficient_agreement`),
287
+ missing live data (`no_live_tick`), or an unrealistic delta
288
+ (`implausible_offset`) — snapshots fail closed: `clock_status` is
289
+ `"uncalibrated"`, `time_utc` and `server_clock_offset_seconds` are `None`,
290
+ and the raw fields remain available. A raw timestamp is never silently
291
+ treated as UTC. `normalizer.calibration` exposes the full
292
+ `TickClockCalibration` diagnostics: status, offset, distinct sample count,
293
+ evidence symbols, and last calibration time (`to_dict()` for serialization).
294
+
295
+ ### Caching and revalidation
296
+
297
+ Calibration is a property of the broker server clock, so it is cached on the
298
+ normalizer (keep one instance per MT5 connection/account) and reused across
299
+ symbols and calls. A cached offset is recomputed in three cases:
300
+
301
+ - After `max_calibration_age_seconds` (default 6 hours, bounding
302
+ DST-transition staleness), unconditionally.
303
+ - Immediately when a normalized timestamp lands more than two minutes in the
304
+ future — evidence that the broker offset grew, e.g. a UTC+2 to UTC+3
305
+ transition.
306
+ - Periodically, at most once per `revalidation_interval_seconds` (default 5
307
+ minutes), a single confirming sample is taken against the still-cached
308
+ offset; a disagreement forces full recalibration. This is what catches a
309
+ broker offset _decrease_ (e.g. UTC+3 to UTC+2): the resulting normalized
310
+ time looks stale rather than future, which a future-skew check alone
311
+ cannot distinguish from ordinary quiet-market staleness.
312
+
313
+ If recalibration still cannot validate the timestamp, the snapshot fails
314
+ closed. Failed calibrations are recorded for diagnostics but never reused;
315
+ retries are bounded to at most once per `failed_calibration_retry_seconds`
316
+ (default 30 seconds) rather than on every call, so a closed or illiquid
317
+ market does not trigger a full resample on every snapshot request.
318
+
319
+ A genuinely stale tick (weekend, illiquid symbol) under a valid calibration
320
+ normalizes to its true **past** UTC instant — staleness is preserved, never
321
+ corrected to the present.
322
+
323
+ mt5cli owns MT5 timestamp normalization only. Freshness thresholds,
324
+ entry-blocking rules, and other strategy policy belong in downstream
325
+ applications.
326
+
226
327
  ## Migration from application-local helpers
227
328
 
228
329
  | Application-local concern | mt5cli replacement |
@@ -234,7 +335,7 @@ No offset is applied automatically by history retrieval helpers.
234
335
  | Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
235
336
  | Local closed-bar fetch from a session | `fetch_latest_closed_rates()` or `fetch_latest_closed_rates_indexed()` |
236
337
  | Local recent deal history fetch from a session | `client.recent_history_deals()` |
237
- | Local broker server clock offset measurement | `estimate_server_clock_offset_seconds()` |
338
+ | Local broker server clock offset measurement | `TickClockNormalizer` |
238
339
  | Local SL/TP price derivation | `determine_order_limits()` |
239
340
  | Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
240
341
 
@@ -40,8 +40,11 @@ from .marketdata import (
40
40
  )
41
41
  from .observability import update_observability, update_observability_with_config
42
42
  from .trading import (
43
+ CalibrationStatus,
44
+ ClockStatus,
43
45
  ExecutionStatus,
44
46
  MarginVolume,
47
+ NormalizedTickSnapshot,
45
48
  OrderExecutionResult,
46
49
  OrderFillingMode,
47
50
  OrderLimits,
@@ -49,6 +52,8 @@ from .trading import (
49
52
  OrderTimeMode,
50
53
  PositionSide,
51
54
  ProjectionMode,
55
+ TickClockCalibration,
56
+ TickClockNormalizer,
52
57
  calculate_account_projected_margin_ratio,
53
58
  calculate_margin_and_volume,
54
59
  calculate_new_position_margin_ratio,
@@ -65,7 +70,6 @@ from .trading import (
65
70
  determine_order_limits,
66
71
  ensure_symbol_selected,
67
72
  estimate_order_margin,
68
- estimate_server_clock_offset_seconds,
69
73
  extract_tick_price,
70
74
  fetch_latest_closed_rates_indexed,
71
75
  get_account_snapshot,
@@ -112,10 +112,15 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
112
112
  "OrderTimeMode",
113
113
  "PositionSide",
114
114
  "ProjectionMode",
115
+ "CalibrationStatus",
116
+ "ClockStatus",
115
117
  "ExecutionStatus",
116
118
  "MarginVolume",
119
+ "NormalizedTickSnapshot",
117
120
  "OrderExecutionResult",
118
121
  "OrderLimits",
122
+ "TickClockCalibration",
123
+ "TickClockNormalizer",
119
124
  "RateTarget",
120
125
  "ThrottledHistoryUpdater",
121
126
  "build_config",
@@ -141,7 +146,6 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
141
146
  "drop_forming_rate_bar",
142
147
  "ensure_symbol_selected",
143
148
  "estimate_order_margin",
144
- "estimate_server_clock_offset_seconds",
145
149
  "extract_tick_price",
146
150
  "fetch_latest_closed_rates",
147
151
  "fetch_latest_closed_rates_indexed",
@@ -51,6 +51,10 @@ DEFAULT_HISTORY_DATASETS: frozenset[Dataset] = frozenset({
51
51
  Dataset.history_orders,
52
52
  Dataset.history_deals,
53
53
  })
54
+ _EXPECTED_EMPTY_HISTORY_DATASETS: frozenset[Dataset] = frozenset({
55
+ Dataset.history_orders,
56
+ Dataset.history_deals,
57
+ })
54
58
 
55
59
  _HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
56
60
  Dataset.rates: DEDUP_KEYS[DataKind.rates],
@@ -1341,11 +1345,20 @@ def get_incremental_start_datetime(
1341
1345
  return starts[symbol, timeframe]
1342
1346
 
1343
1347
 
1348
+ def _empty_dataset_log_level(dataset: Dataset) -> int:
1349
+ """Return the log level for a schema-less dataset result."""
1350
+ if dataset in _EXPECTED_EMPTY_HISTORY_DATASETS:
1351
+ return logging.INFO
1352
+ return logging.WARNING
1353
+
1354
+
1344
1355
  def append_dataframe(
1345
1356
  conn: sqlite3.Connection,
1346
1357
  frame: pd.DataFrame,
1347
1358
  table_name: str,
1348
1359
  if_exists: IfExists,
1360
+ *,
1361
+ empty_log_level: int = logging.WARNING,
1349
1362
  ) -> bool:
1350
1363
  """Append a DataFrame to SQLite when it has a schema.
1351
1364
 
@@ -1353,7 +1366,11 @@ def append_dataframe(
1353
1366
  True if a table was written, False if the frame had no columns.
1354
1367
  """
1355
1368
  if len(frame.columns) == 0:
1356
- logger.warning("Skipping %s: dataset returned no columns", table_name)
1369
+ logger.log(
1370
+ empty_log_level,
1371
+ "Skipping %s: dataset returned no columns",
1372
+ table_name,
1373
+ )
1357
1374
  return False
1358
1375
  writable = _canonicalize_sqlite_time_columns(frame)
1359
1376
  writable.to_sql( # type: ignore[reportUnknownMemberType]
@@ -1409,7 +1426,13 @@ def write_streamed_frame(
1409
1426
  True if the dataset table exists after this write attempt.
1410
1427
  """
1411
1428
  write_mode = IfExists.APPEND if table_exists else if_exists
1412
- if append_dataframe(conn, frame, dataset.table_name, write_mode):
1429
+ if append_dataframe(
1430
+ conn,
1431
+ frame,
1432
+ dataset.table_name,
1433
+ write_mode,
1434
+ empty_log_level=_empty_dataset_log_level(dataset),
1435
+ ):
1413
1436
  record_written_columns(written_columns, dataset, frame)
1414
1437
  return True
1415
1438
  return table_exists
@@ -1751,7 +1774,8 @@ def _stream_symbol_frames(
1751
1774
  for sym in symbols:
1752
1775
  frame = fetch_frame(sym)
1753
1776
  if len(frame.columns) == 0:
1754
- logger.warning(
1777
+ logger.log(
1778
+ _empty_dataset_log_level(dataset),
1755
1779
  "Skipping %s for symbol=%s: dataset returned no columns",
1756
1780
  dataset.table_name,
1757
1781
  sym,
@@ -2243,8 +2267,8 @@ def _finalize_incremental_writes(
2243
2267
  Dataset.history_deals not in written_columns
2244
2268
  and Dataset.history_deals not in written_tables
2245
2269
  ):
2246
- logger.warning(
2247
- "with_views ignored: history_deals table was not available",
2270
+ logger.info(
2271
+ "Skipping history-deal views: no history_deals data was available",
2248
2272
  )
2249
2273
 
2250
2274
 
@@ -2848,9 +2872,13 @@ def collect_history(
2848
2872
  conn,
2849
2873
  written_columns[Dataset.history_deals],
2850
2874
  )
2875
+ elif with_views and Dataset.history_deals in selected:
2876
+ logger.info(
2877
+ "Skipping history-deal views: no history_deals data was collected",
2878
+ )
2851
2879
  elif with_views:
2852
2880
  logger.warning(
2853
- "--with-views ignored: history_deals table was not written",
2881
+ "--with-views requires the history_deals dataset",
2854
2882
  )
2855
2883
  logger.info(
2856
2884
  "Collected %s for %d symbol(s) into %s",