93 const std::vector<std::string>& log_files,
96 auto license = commercial::require_feature(
"MiFID2Reporter");
97 if (!license)
return license.error();
99 if (log_files.empty())
101 "MiFID2Reporter: no log files supplied"};
103 std::vector<DecisionRecord> records;
104 bool chain_ok =
true;
105 std::string chain_id;
106 bool incomplete_data =
false;
107 std::vector<std::string> read_errors;
109 for (
const auto& path : log_files) {
114 "MiFID2Reporter: cannot open log file '" + path +
115 "': " + rdr_result.error().message};
117 auto& rdr = *rdr_result;
120 if (opts.verify_chain) {
121 auto vr = rdr.verify_chain();
122 if (!vr.valid) chain_ok =
false;
126 if (chain_id.empty()) {
127 auto meta_result = rdr.audit_metadata();
128 if (meta_result) chain_id = meta_result->chain_id;
132 auto all_result = rdr.read_all();
134 if (opts.strict_source_reads) {
136 "MiFID2Reporter: failed to read records from '" + path +
137 "': " + all_result.error().message};
139 incomplete_data =
true;
140 read_errors.push_back(
"Failed to read records from '" + path +
141 "': " + all_result.error().message);
145 for (
auto& rec : *all_result) {
146 if (rec.timestamp_ns >= opts.start_ns &&
147 rec.timestamp_ns <= opts.end_ns)
148 records.push_back(std::move(rec));
153 std::sort(records.begin(), records.end(),
154 [](
const DecisionRecord& a,
const DecisionRecord& b) {
155 return a.timestamp_ns < b.timestamp_ns;
158 auto usage = commercial::record_usage_rows(
159 "MiFID2Reporter::generate",
static_cast<uint64_t
>(records.size()));
160 if (!usage)
return usage.error();
163 ComplianceReport report;
165 report.format = opts.format;
166 report.chain_verified = chain_ok;
167 report.chain_id = chain_id;
168 report.total_records =
static_cast<int64_t
>(records.size());
169 report.incomplete_data = incomplete_data;
170 report.read_errors = std::move(read_errors);
171 report.generated_at_ns = now_ns_();
172 report.generated_at_iso = ns_to_iso8601(report.generated_at_ns);
173 report.period_start_iso = ns_to_iso8601(opts.start_ns);
174 report.period_end_iso = (opts.end_ns == (std::numeric_limits<int64_t>::max)())
176 : ns_to_iso8601(opts.end_ns);
180 report.report_id = opts.report_id.empty()
181 ? (
"MIFID2-" + std::to_string(report.generated_at_ns)
182 +
"-" + random_hex_suffix_())
185 switch (opts.format) {
188 report.content = format_json(records, opts, report, chain_ok);
191 report.content = format_csv(records, opts);
202 return "timestamp_utc,order_id,firm_id,algo_identifier,"
203 "model_version,instrument,venue,decision_type,"
204 "price,quantity,risk_gate,model_output,confidence,"
205 "buy_sell,order_type,time_in_force,isin,currency,"
206 "short_selling,aggregated_order,parent_order_id,"
207 "chain_seq,chain_hash\n";
220 const std::string& firm_id =
"") {
223 row += csv_escape(ns_to_iso8601(rec.
timestamp_ns)) +
",";
224 row += csv_escape(rec.
order_id) +
",";
225 row += csv_escape(firm_id.empty()
226 ? std::to_string(rec.
strategy_id) : firm_id) +
",";
227 row += csv_escape(std::to_string(rec.
strategy_id)) +
",";
229 row += csv_escape(rec.
symbol) +
",";
230 row += csv_escape(rec.
venue) +
",";
231 row += csv_escape(decision_type_str(rec.
decision_type)) +
",";
232 row += double_str(rec.
price) +
",";
233 row += double_str(rec.
quantity) +
",";
234 row += csv_escape(risk_result_str(rec.
risk_result)) +
",";
238 row += csv_escape(buy_sell_str(rec.
buy_sell)) +
",";
239 row += csv_escape(order_type_str(rec.
order_type)) +
",";
240 row += csv_escape(time_in_force_str(rec.
time_in_force)) +
",";
241 row += csv_escape(rec.
isin) +
",";
242 row += csv_escape(rec.
currency) +
",";
256 static std::string format_json(
257 const std::vector<DecisionRecord>& records,
263 const std::string ind = opts.
pretty_print && !ndjson ?
" " :
"";
264 const std::string ind2 = opts.
pretty_print && !ndjson ?
" " :
"";
265 const std::string nl = opts.
pretty_print && !ndjson ?
"\n" :
"";
266 const std::string sp = opts.
pretty_print && !ndjson ?
" " :
"";
271 for (
const auto& rec : records)
272 out += record_to_json_line(rec, opts) +
"\n";
278 o += ind +
"\"report_type\":" + sp +
"\"MiFID_II_RTS_24\"," + nl;
279 o += ind +
"\"regulatory_reference\":" + sp
280 +
"\"Commission Delegated Regulation (EU) 2017/589 — RTS 24\"," + nl;
281 o += ind +
"\"report_id\":" + sp +
"\"" + j(meta.
report_id) +
"\"," + nl;
282 o += ind +
"\"generated_at\":" + sp +
"\"" + meta.
generated_at_iso +
"\"," + nl;
283 o += ind +
"\"period_start\":" + sp +
"\"" + meta.
period_start_iso +
"\"," + nl;
284 o += ind +
"\"period_end\":" + sp +
"\"" + meta.
period_end_iso +
"\"," + nl;
285 o += ind +
"\"firm_id\":" + sp +
"\""
286 + j(opts.
firm_id.empty() ?
"UNSPECIFIED" : opts.firm_id)
288 o += ind +
"\"chain_id\":" + sp +
"\"" + j(meta.
chain_id) +
"\"," + nl;
289 o += ind +
"\"chain_verified\":" + sp + (chain_ok ?
"true" :
"false") +
"," + nl;
290 o += ind +
"\"total_records\":" + sp + std::to_string(records.size()) +
"," + nl;
291 o += ind +
"\"records\":" + sp +
"[" + nl;
293 for (
size_t i = 0; i < records.size(); ++i) {
294 const auto& rec = records[i];
295 o += ind2 +
"{" + nl;
297 o += ind2 + ind +
"\"field_01_firm_id\":" + sp
298 +
"\"" + j(opts.
firm_id.empty() ? std::to_string(rec.strategy_id) : opts.firm_id)
300 o += ind2 + ind +
"\"field_02_order_id\":" + sp
301 +
"\"" + j(rec.order_id) +
"\"," + nl;
302 o += ind2 + ind +
"\"field_03_client_id\":" + sp
303 +
"\"[REDACTED-GDPR]\"," + nl;
304 o += ind2 + ind +
"\"field_04_algo_identifier\":" + sp
305 +
"\"" + j(std::to_string(rec.strategy_id)) +
"\"," + nl;
306 o += ind2 + ind +
"\"field_05_instrument\":" + sp
307 +
"\"" + j(rec.symbol) +
"\"," + nl;
308 o += ind2 + ind +
"\"field_06_price\":" + sp
310 o += ind2 + ind +
"\"field_07_quantity\":" + sp
311 + double_str(rec.quantity) +
"," + nl;
312 o += ind2 + ind +
"\"field_08_timestamp_utc\":" + sp
314 o += ind2 + ind +
"\"field_09_decision_type\":" + sp
315 +
"\"" + decision_type_str(rec.decision_type) +
"\"," + nl;
316 o += ind2 + ind +
"\"field_10_venue\":" + sp
317 +
"\"" + j(rec.venue) +
"\"," + nl;
318 o += ind2 + ind +
"\"field_11_model_version\":" + sp
319 +
"\"" + j(rec.model_version) +
"\"," + nl;
320 o += ind2 + ind +
"\"field_12_risk_gate\":" + sp
321 +
"\"" + risk_result_str(rec.risk_result) +
"\"," + nl;
322 o += ind2 + ind +
"\"field_13_model_output\":" + sp
323 + double_str(rec.model_output) +
"," + nl;
324 o += ind2 + ind +
"\"field_14_confidence\":" + sp
325 + double_str(rec.confidence) +
"," + nl;
327 o += ind2 + ind +
"\"field_15_buy_sell\":" + sp
328 +
"\"" + buy_sell_str(rec.buy_sell) +
"\"," + nl;
329 o += ind2 + ind +
"\"field_16_order_type\":" + sp
330 +
"\"" + order_type_str(rec.order_type) +
"\"," + nl;
331 o += ind2 + ind +
"\"field_17_time_in_force\":" + sp
332 +
"\"" + time_in_force_str(rec.time_in_force) +
"\"," + nl;
333 o += ind2 + ind +
"\"field_18_isin\":" + sp
334 +
"\"" + j(rec.isin) +
"\"," + nl;
335 o += ind2 + ind +
"\"field_19_currency\":" + sp
336 +
"\"" + j(rec.currency) +
"\"," + nl;
337 o += ind2 + ind +
"\"field_20_short_selling\":" + sp
338 + (rec.short_selling_flag ?
"true" :
"false") +
"," + nl;
339 o += ind2 + ind +
"\"field_21_aggregated_order\":" + sp
340 + (rec.aggregated_order ?
"true" :
"false") +
"," + nl;
341 if (rec.validity_period_ns > 0) {
342 o += ind2 + ind +
"\"field_22_validity_period\":" + sp
343 +
"\"" + ns_to_iso8601(rec.validity_period_ns) +
"\"," + nl;
345 if (!rec.parent_order_id.empty()) {
346 o += ind2 + ind +
"\"field_23_parent_order_id\":" + sp
347 +
"\"" + j(rec.parent_order_id) +
"\"," + nl;
349 o += ind2 + ind +
"\"field_24_notes\":" + sp
350 +
"\"" + j(rec.notes) +
"\"";
353 o += ind2 + ind +
"\"input_features\":" + sp
354 + features_array(rec.input_features);
356 o += nl + ind2 +
"}";
357 if (i + 1 < records.size()) o +=
",";
365 static std::string record_to_json_line(
const DecisionRecord& rec,
366 const ReportOptions& opts) {
368 o +=
"\"ts\":\"" + ns_to_iso8601(rec.timestamp_ns, opts.timestamp_granularity) +
"\",";
369 o +=
"\"order_id\":\"" + j(rec.order_id) +
"\",";
370 o +=
"\"algo\":\"" + j(std::to_string(rec.strategy_id)) +
"\",";
371 o +=
"\"model\":\"" + j(rec.model_version) +
"\",";
372 o +=
"\"instrument\":\"" + j(rec.symbol) +
"\",";
373 o +=
"\"venue\":\"" + j(rec.venue) +
"\",";
374 o +=
"\"type\":\"" + decision_type_str(rec.decision_type) +
"\",";
375 o +=
"\"price\":" + double_str(rec.price, opts.price_significant_digits) +
",";
376 o +=
"\"qty\":" + double_str(rec.quantity) +
",";
377 o +=
"\"risk_gate\":\"" + risk_result_str(rec.risk_result) +
"\",";
378 o +=
"\"output\":" + double_str(rec.model_output) +
",";
379 o +=
"\"conf\":" + double_str(rec.confidence) +
",";
381 o +=
"\"buy_sell\":\"" + buy_sell_str(rec.buy_sell) +
"\",";
382 o +=
"\"order_type\":\"" + order_type_str(rec.order_type) +
"\",";
383 o +=
"\"tif\":\"" + time_in_force_str(rec.time_in_force) +
"\",";
384 o +=
"\"isin\":\"" + j(rec.isin) +
"\",";
385 o +=
"\"ccy\":\"" + j(rec.currency) +
"\",";
386 o +=
"\"short_sell\":" + std::string(rec.short_selling_flag ?
"true" :
"false") +
",";
387 o +=
"\"aggregated\":" + std::string(rec.aggregated_order ?
"true" :
"false");
396 static std::string format_csv(
const std::vector<DecisionRecord>& records,
397 const ReportOptions& opts) {
399 for (
const auto& rec : records) {
400 out += csv_escape(ns_to_iso8601(rec.timestamp_ns, opts.timestamp_granularity)) +
",";
401 out += csv_escape(rec.order_id) +
",";
402 out += csv_escape(opts.firm_id.empty()
403 ? std::to_string(rec.strategy_id) : opts.firm_id) +
",";
404 out += csv_escape(std::to_string(rec.strategy_id)) +
",";
405 out += csv_escape(rec.model_version) +
",";
406 out += csv_escape(rec.symbol) +
",";
407 out += csv_escape(rec.venue) +
",";
408 out += csv_escape(decision_type_str(rec.decision_type)) +
",";
409 out += double_str(rec.price, opts.price_significant_digits) +
",";
410 out += double_str(rec.quantity) +
",";
411 out += csv_escape(risk_result_str(rec.risk_result)) +
",";
412 out += double_str(rec.model_output) +
",";
413 out += double_str(rec.confidence) +
",";
415 out += csv_escape(buy_sell_str(rec.buy_sell)) +
",";
416 out += csv_escape(order_type_str(rec.order_type)) +
",";
417 out += csv_escape(time_in_force_str(rec.time_in_force)) +
",";
418 out += csv_escape(rec.isin) +
",";
419 out += csv_escape(rec.currency) +
",";
420 out += std::string(rec.short_selling_flag ?
"true":
"false") +
",";
421 out += std::string(rec.aggregated_order ?
"true":
"false") +
",";
422 out += csv_escape(rec.parent_order_id) +
",";
432 static int64_t now_ns_() {
433 using namespace std::chrono;
434 return static_cast<int64_t
>(
435 duration_cast<nanoseconds>(
436 system_clock::now().time_since_epoch()).count());
439 static std::string ns_to_iso8601(int64_t ns,
441 static_assert(
sizeof(std::time_t) >= 8,
442 "Signet compliance reporters require 64-bit time_t for timestamps beyond 2038");
443 if (ns <= 0)
return "1970-01-01T00:00:00.000000000Z";
444 const int64_t secs = ns / 1'000'000'000LL;
445 const int64_t ns_part = ns % 1'000'000'000LL;
446 std::time_t t =
static_cast<std::time_t
>(secs);
449 gmtime_s(&tm_buf, &t);
450 std::tm* utc = &tm_buf;
452 std::tm* utc = gmtime_r(&t, &tm_buf);
455 std::strftime(date_buf,
sizeof(date_buf),
"%Y-%m-%dT%H:%M:%S", utc);
460 std::snprintf(full_buf,
sizeof(full_buf),
"%s.%03lldZ",
461 date_buf,
static_cast<long long>(ns_part / 1'000'000LL));
464 std::snprintf(full_buf,
sizeof(full_buf),
"%s.%06lldZ",
465 date_buf,
static_cast<long long>(ns_part / 1'000LL));
469 std::snprintf(full_buf,
sizeof(full_buf),
"%s.%09lldZ",
470 date_buf,
static_cast<long long>(ns_part));
510 static std::string order_type_str(
OrderType ot) {
522 static std::string time_in_force_str(
TimeInForce tif) {
534 static constexpr size_t MAX_FIELD_LENGTH = 4096;
536 static std::string truncate_field(
const std::string& s) {
537 if (s.size() <= MAX_FIELD_LENGTH)
return s;
538 return s.substr(0, MAX_FIELD_LENGTH) +
"...[TRUNCATED]";
541 static std::string j(
const std::string& s) {
542 const std::string safe = truncate_field(s);
544 out.reserve(safe.size());
545 for (
unsigned char c : safe) {
546 if (c ==
'"') out +=
"\\\"";
547 else if (c ==
'\\') out +=
"\\\\";
548 else if (c ==
'/') out +=
"\\/";
549 else if (c ==
'\n') out +=
"\\n";
550 else if (c ==
'\r') out +=
"\\r";
551 else if (c ==
'\t') out +=
"\\t";
552 else if (c < 0x20) {
char buf[8];
553 std::snprintf(buf,
sizeof(buf),
"\\u%04x",c);
555 else out +=
static_cast<char>(c);
560 static std::string csv_escape(
const std::string& s) {
561 const std::string safe = truncate_field(s);
563 if (safe.find_first_of(
",\"\n\r") == std::string::npos)
return safe;
564 std::string out =
"\"";
565 for (
char c : safe) {
566 if (c ==
'"') out +=
"\"\"";
573 if (out.size() > 1) {
575 if (first ==
'=' || first ==
'+' || first ==
'-' || first ==
'@' ||
576 first ==
'\t' || first ==
'\r') {
577 out.insert(1, 1,
'\'');
583 static std::string double_str(
double v,
int significant_digits = 10) {
584 if (std::isnan(v) || std::isinf(v))
return "null";
586 std::snprintf(buf,
sizeof(buf),
"%.*g", significant_digits, v);
595 static std::string random_hex_suffix_() {
597#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__)
598 arc4random_buf(buf,
sizeof(buf));
601 for (
auto& b : buf) {
604 b =
static_cast<uint8_t
>(val);
609 while (written <
sizeof(buf)) {
610 ssize_t ret = getrandom(buf + written,
sizeof(buf) - written, 0);
611 if (ret > 0) written +=
static_cast<size_t>(ret);
615 static constexpr char hex[] =
"0123456789abcdef";
616 std::string result(8,
'\0');
617 for (
size_t i = 0; i < 4; ++i) {
618 result[2 * i] = hex[buf[i] >> 4];
619 result[2 * i + 1] = hex[buf[i] & 0x0F];
624 static std::string features_array(
const std::vector<float>& feats) {
626 for (
size_t i = 0; i < feats.size(); ++i) {
627 char buf[32]; std::snprintf(buf,
sizeof(buf),
"%.6g", feats[i]);
629 if (i + 1 < feats.size()) o +=
",";