fixtureqa 0.4.18__tar.gz → 0.4.19__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 (100) hide show
  1. {fixtureqa-0.4.18/fixtureqa.egg-info → fixtureqa-0.4.19}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/perf.py +8 -1
  3. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/exec_csv_writer.py +5 -5
  4. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/perf_charts.py +2 -2
  5. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/perf_engine.py +76 -26
  6. {fixtureqa-0.4.18 → fixtureqa-0.4.19/fixtureqa.egg-info}/PKG-INFO +1 -1
  7. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/pyproject.toml +1 -1
  8. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_perf_engine.py +47 -9
  9. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/LICENSE +0 -0
  10. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/README.md +0 -0
  11. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/__init__.py +0 -0
  12. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/__main__.py +0 -0
  13. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/__init__.py +0 -0
  14. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/app.py +0 -0
  15. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/connection_manager.py +0 -0
  16. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/deps.py +0 -0
  17. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/__init__.py +0 -0
  18. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/admin.py +0 -0
  19. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/auth.py +0 -0
  20. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/branding.py +0 -0
  21. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/custom_tags.py +0 -0
  22. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/fix_spec.py +0 -0
  23. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/messages.py +0 -0
  24. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/scenarios.py +0 -0
  25. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/sessions.py +0 -0
  26. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/setup.py +0 -0
  27. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/spec_overlay.py +0 -0
  28. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/templates.py +0 -0
  29. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/routers/ws.py +0 -0
  30. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/api/schemas.py +0 -0
  31. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/config/__init__.py +0 -0
  32. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/__init__.py +0 -0
  33. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/atomic_io.py +0 -0
  34. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/auth.py +0 -0
  35. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/config_store.py +0 -0
  36. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/custom_tag_store.py +0 -0
  37. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/db_migrations.py +0 -0
  38. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/events.py +0 -0
  39. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/fix_application.py +0 -0
  40. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/fix_builder.py +0 -0
  41. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/fix_parser.py +0 -0
  42. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/fix_spec_parser.py +0 -0
  43. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/fix_tags.py +0 -0
  44. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/fix_time.py +0 -0
  45. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/housekeeping.py +0 -0
  46. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/inbound.py +0 -0
  47. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/json_store.py +0 -0
  48. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/message_log.py +0 -0
  49. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/message_store.py +0 -0
  50. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/models.py +0 -0
  51. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/perf_models.py +0 -0
  52. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/perf_payload.py +0 -0
  53. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/perf_stats.py +0 -0
  54. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/perf_store.py +0 -0
  55. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/perf_writer.py +0 -0
  56. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/scenario_runner.py +0 -0
  57. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/scenario_store.py +0 -0
  58. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/session.py +0 -0
  59. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/session_manager.py +0 -0
  60. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/spec_overlay_store.py +0 -0
  61. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/template_store.py +0 -0
  62. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/user_store.py +0 -0
  63. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/core/venue_responses.py +0 -0
  64. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/fix_specs/FIX42.xml +0 -0
  65. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/fix_specs/FIX44.xml +0 -0
  66. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/server.py +0 -0
  67. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  68. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/static/assets/index-BSuHxk6b.js +0 -0
  69. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  70. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/static/assets/index-o_l1KBYd.css +0 -0
  71. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  72. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/static/favicon.svg +0 -0
  73. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/static/index.html +0 -0
  74. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixture/ui/__init__.py +0 -0
  75. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixtureqa.egg-info/SOURCES.txt +0 -0
  76. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixtureqa.egg-info/dependency_links.txt +0 -0
  77. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixtureqa.egg-info/entry_points.txt +0 -0
  78. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixtureqa.egg-info/requires.txt +0 -0
  79. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/fixtureqa.egg-info/top_level.txt +0 -0
  80. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/setup.cfg +0 -0
  81. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_atomic_io.py +0 -0
  82. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_auth.py +0 -0
  83. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_config_store.py +0 -0
  84. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_connection_manager.py +0 -0
  85. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_db_migrations.py +0 -0
  86. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_fix_builder.py +0 -0
  87. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_health.py +0 -0
  88. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_inbound.py +0 -0
  89. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_inbound_validation.py +0 -0
  90. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_message_store.py +0 -0
  91. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_perf_api.py +0 -0
  92. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_perf_models.py +0 -0
  93. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_perf_payload.py +0 -0
  94. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_perf_rehydrate.py +0 -0
  95. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_scenarios.py +0 -0
  96. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_session_lifecycle.py +0 -0
  97. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_session_manager_concurrency.py +0 -0
  98. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_sessions.py +0 -0
  99. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_templates.py +0 -0
  100. {fixtureqa-0.4.18 → fixtureqa-0.4.19}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.18
3
+ Version: 0.4.19
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -228,12 +228,19 @@ async def run_chart(run_id: str, reg: REG, _admin: Admin,
228
228
  if csv_path is None or not os.path.exists(csv_path):
229
229
  raise HTTPException(status_code=404,
230
230
  detail="No per-message recording for this run (enable record_execs before starting)")
231
+ # Single-leg runs (client/venue) derive one-way from the wire send time;
232
+ # label it so the chart can't be misread as the single-clock measurement.
233
+ st = reg.get_status(run_id)
234
+ one_way_label = None
235
+ if st is not None and st.mode not in ("loopback", "manager"):
236
+ one_way_label = "one-way (tag-60 wire send → recv; needs synced clocks)"
231
237
  out_path = f"{csv_path[:-len('.csv.gz')]}_{metric}_{agg}.png"
232
238
  if not os.path.exists(out_path):
233
239
  try:
234
240
  await asyncio.to_thread(
235
241
  render_latency_chart, csv_path, out_path,
236
- metric=metric, agg=agg, title=f"Per-message latency — {run_id}")
242
+ metric=metric, agg=agg, title=f"Per-message latency — {run_id}",
243
+ one_way_label=one_way_label)
237
244
  except ValueError as e:
238
245
  # No chartable points (e.g. recording produced only un-latencied rows).
239
246
  raise HTTPException(status_code=404, detail=str(e))
@@ -32,15 +32,15 @@ EXEC_COLS = (
32
32
  # client→venue: order / amend / cancel
33
33
  # venue→client: ack / fill / pending-replace / replace /
34
34
  # pending-cancel / canceled / cancel-reject / amend-reject
35
- "direction", # c2v (client→hub→venue) | v2c (venue→hub→client) | rt
36
- # (rt = round-trip-only fallback, client/venue-only modes)
35
+ "direction", # c2v (client→hub→venue) | v2c (venue→hub→client)
37
36
  "send_at", # wall clock of THIS message's send on the near leg (one-way x-axis)
38
37
  "recv_at", # wall clock of THIS message's receive on the far leg
39
38
  "req_send_at", # wall clock of the originating request's send (order/amend/cancel
40
39
  # send; == send_at for c2v rows) — the round-trip x-axis
41
- "one_way_us", # recv_ns − send_ns: this message's transit through the hub, one
42
- # monotonic clock (empty in client/venue-only modes no far leg)
43
- "round_trip_us", # v2c only: recv_ns the originating request's send_ns; empty for c2v
40
+ "one_way_us", # this message's one-way transit. manager/loopback: recv send on
41
+ # one monotonic clock. client/venue-only: recv the wire send time
42
+ # (TransactTime 60 / SendingTime 52), needs a synced clock.
43
+ "round_trip_us", # v2c only: recv − the originating request's send (our clock); empty for c2v
44
44
  "exec_type", "ord_status", "cum_qty", "leaves_qty", # tags 150 / 39 / 14 / 151 (forensics)
45
45
  )
46
46
 
@@ -115,7 +115,7 @@ def _draw(ax, by_kind, agg: str, bucket_s: float, logy: bool, ylabel: str, title
115
115
  def render_latency_chart(csv_gz_path: str, out_path: str, *, metric: str = "one_way",
116
116
  agg: str = "both", bucket_s: float = 2.0,
117
117
  logy: bool = True, kinds: Optional[list[str]] = None,
118
- title: Optional[str] = None) -> str:
118
+ title: Optional[str] = None, one_way_label: Optional[str] = None) -> str:
119
119
  """Render a per-kind latency chart from a recorded run CSV to out_path (PNG).
120
120
 
121
121
  metric: 'one_way' (recv − send, this message's hub transit; x = its own send
@@ -146,7 +146,7 @@ def render_latency_chart(csv_gz_path: str, out_path: str, *, metric: str = "one_
146
146
  axes = fig.subplots(len(panels), 1, squeeze=False)[:, 0]
147
147
  base = title or "Per-message latency"
148
148
  for ax, (m, by_kind) in zip(axes, panels):
149
- label = _METRIC[m][2]
149
+ label = one_way_label if (m == "one_way" and one_way_label) else _METRIC[m][2]
150
150
  _draw(ax, by_kind, agg, bucket_s, logy, f"latency (ms) — {label}",
151
151
  base if len(panels) == 1 else f"{base} — {label}")
152
152
  fig.tight_layout()
@@ -26,6 +26,7 @@ import random
26
26
  import time
27
27
  import uuid
28
28
  from collections import deque
29
+ from datetime import datetime, timezone
29
30
  from typing import Optional
30
31
 
31
32
  from fixcore.message.message import Message
@@ -96,6 +97,29 @@ def _to_float(v) -> Optional[float]:
96
97
  return None
97
98
 
98
99
 
100
+ # Wire send-time tags for the tag-60 latency path (client/venue-only modes):
101
+ # TransactTime(60), falling back to the session SendingTime(52) which every
102
+ # message carries (e.g. 35=9 has no TransactTime).
103
+ _LAT_TS_TAGS = (60, 52)
104
+
105
+
106
+ def _parse_fix_ts(fields: dict) -> Optional[float]:
107
+ """The counterparty's wall-clock send time (epoch seconds, UTC) from
108
+ TransactTime(60) → SendingTime(52). Returns None if absent/unparseable.
109
+ Used only when FIXture sees one leg, so the send timestamp must come off the
110
+ wire — accuracy then depends on the two hosts' clocks being NTP/PTP-synced."""
111
+ for tag in _LAT_TS_TAGS:
112
+ val = fields.get(tag)
113
+ if not val:
114
+ continue
115
+ for fmt in ("%Y%m%d-%H:%M:%S.%f", "%Y%m%d-%H:%M:%S"):
116
+ try:
117
+ return datetime.strptime(val, fmt).replace(tzinfo=timezone.utc).timestamp()
118
+ except ValueError:
119
+ continue
120
+ return None
121
+
122
+
99
123
  class PerfRun:
100
124
  def __init__(self, run_id: str, config: RunConfig, sm: SessionManager, writer: PerfWriter,
101
125
  template_store=None, owner_uid: str = "",
@@ -181,16 +205,23 @@ class PerfRun:
181
205
  self.status = "running"
182
206
  self.started_at = time.time()
183
207
  self._start_ns = time.perf_counter_ns()
208
+ # Wall-clock anchored to the same instant as _start_ns, so an inbound
209
+ # message's monotonic recv_perf_ns can be expressed as a UTC wall time
210
+ # (for the tag-60 latency path) without a second clock read per message.
211
+ self._start_wall = time.time()
184
212
  if self.config.test.record_execs:
185
- # Per-exec capture happens in the client-leg correlator; venue-only
186
- # runs have nothing to record.
187
- if not self._has_client:
188
- self.warnings.append("record_execs has no effect in venue mode (no client leg)")
213
+ if not (self._has_client or self._has_venue):
214
+ self.warnings.append("record_execs has no effect with no client or venue leg")
189
215
  elif not self._execs_dir:
190
216
  self.warnings.append("record_execs unavailable (no exec CSV directory configured)")
191
217
  else:
192
218
  self._exec_csv = ExecCsvWriter(
193
219
  os.path.join(self._execs_dir, f"{self.run_id}.csv.gz"))
220
+ if not self._one_way:
221
+ self.warnings.append(
222
+ "single-leg run: per-message one-way latency uses the wire "
223
+ "send time (TransactTime 60 / SendingTime 52) — accurate only "
224
+ "if this host and the counterparty share a synced clock")
194
225
  if self.config.amend.enabled and not self._has_client:
195
226
  self.warnings.append(
196
227
  "amend injection has no effect in venue mode (no client leg); "
@@ -817,38 +848,58 @@ class PerfRun:
817
848
  sends[eid] = {"kind": kind, "send_ns": send_ns, "send_wall": time.time(),
818
849
  "corr": corr, "clordid": clordid}
819
850
 
851
+ def _recv_wall(self, recv_ns: int) -> float:
852
+ """Wall-clock (epoch secs) of an inbound message, anchored to the same
853
+ instant as its monotonic recv_perf_ns — for the tag-60 latency path."""
854
+ return self._start_wall + (recv_ns - self._start_ns) / 1e9
855
+
820
856
  def _record_c2v(self, of: dict, corr: str, recv_ns: int, kind: str) -> None:
821
857
  """Record a client→venue crossing (order/amend/cancel) at the venue
822
- inbound side: one-way latency = venue recv − the client's send (looked up
823
- in _pending). Only when we drive both legs on one clock (manager/loopback)."""
824
- if self._exec_csv is None or not self._one_way:
825
- return
826
- entry = self._pending.get(corr)
827
- if entry is None:
828
- return
829
- ns_key = {"order": "sent_ns", "amend": "amend_sent_ns",
830
- "cancel": "cancel_sent_ns"}[kind]
831
- send_ns = entry.get(ns_key)
832
- if not send_ns:
858
+ inbound side. Manager/loopback: one-way = venue recv − the client's send
859
+ (from _pending, one clock). Venue-only: one-way = our recv the
860
+ counterparty's wire send time (tag-60), needing a synced clock."""
861
+ if self._exec_csv is None:
833
862
  return
834
- send_at = entry.get(ns_key[:-2] + "at") # *_sent_ns → *_sent_at
863
+ if self._one_way:
864
+ entry = self._pending.get(corr)
865
+ if entry is None:
866
+ return
867
+ ns_key = {"order": "sent_ns", "amend": "amend_sent_ns",
868
+ "cancel": "cancel_sent_ns"}[kind]
869
+ send_ns = entry.get(ns_key)
870
+ if not send_ns:
871
+ return
872
+ send_at = entry.get(ns_key[:-2] + "at") # *_sent_ns → *_sent_at
873
+ one_way_us = (recv_ns - send_ns) / 1000.0
874
+ else:
875
+ ts = _parse_fix_ts(of)
876
+ if ts is None:
877
+ return
878
+ send_at = ts
879
+ one_way_us = (self._recv_wall(recv_ns) - ts) * 1e6
835
880
  self._emit_crossing(
836
881
  corr=corr, clordid=of.get(T_CLORDID), exec_id=None, kind=kind,
837
882
  direction="c2v", send_at=send_at, recv_at=time.time(),
838
- req_send_at=send_at, one_way_us=(recv_ns - send_ns) / 1000.0,
839
- round_trip_us=None)
883
+ req_send_at=send_at, one_way_us=one_way_us, round_trip_us=None)
840
884
 
841
885
  def _record_v2c(self, f: dict, corr, exec_id, now_ns: int, kind: str,
842
886
  entry: Optional[dict]) -> None:
843
- """Record a venue→client crossing at the client inbound side: one-way
844
- latency from the venue send registry (manager/loopback), and round-trip
845
- from the originating request's send time in _pending. Client-only mode
846
- has no registry hit round-trip only, direction 'rt'."""
887
+ """Record a venue→client crossing at the client inbound side. one-way:
888
+ from the venue send registry (manager/loopback) or the counterparty's
889
+ wire send time (tag-60, client-only synced clock needed). round-trip:
890
+ from the originating request's send in _pending (always our own clock)."""
847
891
  if self._exec_csv is None:
848
892
  return
849
893
  vs = self._venue_sends.pop(exec_id, None) if exec_id else None
850
- one_way_us = ((now_ns - vs["send_ns"]) / 1000.0) if vs else None
851
- send_at = vs["send_wall"] if vs else None
894
+ if vs:
895
+ one_way_us = (now_ns - vs["send_ns"]) / 1000.0
896
+ send_at = vs["send_wall"]
897
+ elif not self._one_way:
898
+ ts = _parse_fix_ts(f)
899
+ one_way_us = ((self._recv_wall(now_ns) - ts) * 1e6) if ts is not None else None
900
+ send_at = ts
901
+ else:
902
+ one_way_us = send_at = None
852
903
  rt_us = req_send_at = None
853
904
  ns_key = _KIND_REQ_NS.get(kind)
854
905
  if entry is not None and ns_key:
@@ -858,8 +909,7 @@ class PerfRun:
858
909
  req_send_at = entry.get(ns_key[:-2] + "at") # *_sent_ns → *_sent_at
859
910
  clordid = (entry.get("clordid") if entry else None) or (vs["clordid"] if vs else None)
860
911
  self._emit_crossing(
861
- corr=corr, clordid=clordid, exec_id=exec_id, kind=kind,
862
- direction="v2c" if self._one_way else "rt",
912
+ corr=corr, clordid=clordid, exec_id=exec_id, kind=kind, direction="v2c",
863
913
  send_at=send_at, recv_at=time.time(), req_send_at=req_send_at,
864
914
  one_way_us=one_way_us, round_trip_us=rt_us,
865
915
  exec_type=f.get(T_EXECTYPE, ""), ord_status=f.get(T_ORDSTATUS, ""),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.18
3
+ Version: 0.4.19
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fixtureqa"
7
- version = "0.4.18"
7
+ version = "0.4.19"
8
8
  description = "FIXture — FIX Protocol Testing Tool"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -497,9 +497,9 @@ def test_record_c2v_one_way(tmp_path):
497
497
  assert r["round_trip_us"] == "" # c2v has no round-trip
498
498
 
499
499
 
500
- def test_record_v2c_round_trip_only_in_client_mode(tmp_path):
501
- # client-only mode: no venue send registry → one-way empty, round-trip recorded,
502
- # direction 'rt'.
500
+ def test_record_v2c_round_trip_without_wire_ts_in_client_mode(tmp_path):
501
+ # client-only mode, reply has no TransactTime/SendingTime → one-way empty,
502
+ # round-trip (our own clock) still recorded.
503
503
  from fixture.core.exec_csv_writer import ExecCsvWriter
504
504
  run = _run(_cfg(mode="client"))
505
505
  assert run._one_way is False
@@ -509,12 +509,50 @@ def test_record_v2c_round_trip_only_in_client_mode(tmp_path):
509
509
  run._record_v2c({150: "2", 39: "2", 14: "100", 151: "0"}, "X", "E1",
510
510
  base + 5_000_000, "fill", entry)
511
511
  run._exec_csv.close()
512
- rows = _read_gz(tmp_path / "cm.csv.gz")
513
- assert len(rows) == 1
514
- r = rows[0]
515
- assert r["direction"] == "rt"
516
- assert r["one_way_us"] == "" # no far-leg send time
517
- assert 4900 <= float(r["round_trip_us"]) <= 5100 # ~5 ms
512
+ r = _read_gz(tmp_path / "cm.csv.gz")[0]
513
+ assert r["direction"] == "v2c"
514
+ assert r["one_way_us"] == "" # no wire send time to compare
515
+ assert 4900 <= float(r["round_trip_us"]) <= 5100 # ~5 ms, our clock
516
+
517
+
518
+ def _fix_ts(epoch: float) -> str:
519
+ from datetime import datetime, timezone
520
+ return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
521
+
522
+
523
+ def test_record_v2c_tag60_one_way_in_client_mode(tmp_path):
524
+ # client-only mode: one-way derived from the reply's TransactTime(60) vs our recv.
525
+ from fixture.core.exec_csv_writer import ExecCsvWriter
526
+ run = _run(_cfg(mode="client"))
527
+ run._start_ns = time.perf_counter_ns()
528
+ run._start_wall = time.time()
529
+ run._exec_csv = ExecCsvWriter(str(tmp_path / "t60.csv.gz"))
530
+ # venue stamped tag 60 eight ms before our (anchored) receive
531
+ recv_ns = run._start_ns + 1_000_000
532
+ tag60 = _fix_ts(run._recv_wall(recv_ns) - 0.008)
533
+ run._record_v2c({60: tag60, 150: "2", 39: "2"}, "X", "E1", recv_ns, "fill", None)
534
+ run._exec_csv.close()
535
+ r = _read_gz(tmp_path / "t60.csv.gz")[0]
536
+ assert r["direction"] == "v2c"
537
+ assert 7000 <= float(r["one_way_us"]) <= 9000 # ~8 ms from tag 60
538
+
539
+
540
+ def test_record_c2v_tag60_one_way_in_venue_mode(tmp_path):
541
+ # venue-only mode: inbound order one-way from its TransactTime(60) vs our recv.
542
+ from fixture.core.exec_csv_writer import ExecCsvWriter
543
+ run = _run(_cfg(mode="venue"))
544
+ assert run._one_way is False
545
+ run._start_ns = time.perf_counter_ns()
546
+ run._start_wall = time.time()
547
+ run._exec_csv = ExecCsvWriter(str(tmp_path / "v60.csv.gz"))
548
+ recv_ns = run._start_ns + 500_000
549
+ tag60 = _fix_ts(run._recv_wall(recv_ns) - 0.012)
550
+ run._record_c2v({11: "O1", 60: tag60}, "X", recv_ns, "order")
551
+ run._exec_csv.close()
552
+ r = _read_gz(tmp_path / "v60.csv.gz")[0]
553
+ assert r["kind"] == "order" and r["direction"] == "c2v"
554
+ assert 11000 <= float(r["one_way_us"]) <= 13000 # ~12 ms from tag 60
555
+ assert r["round_trip_us"] == ""
518
556
 
519
557
 
520
558
  def test_render_latency_chart_metrics(tmp_path):
File without changes
File without changes
File without changes
File without changes
File without changes