1#include "raid/data/findata.hpp"2#include "raid/core/error.hpp"3#include "raid/core/log.hpp"4#include "raid/core/hash.hpp"5#include "raid/core/manifest.hpp"6#include "raid/data/io.hpp"7#include <curl/curl.h>8#include <fstream>9#include <iostream>10#include <sstream>11#include <thread>12#include <cstdlib>13#include <filesystem>14#include <algorithm>15#include <cmath>1617namespace raid::findata {1819namespace fs = std::filesystem;20using json = nlohmann::json;2122// ---------------------------------------------------------------- config / secrets23Config Config::from_env() {24 Config c;25 if (const char* u = std::getenv("FINDATA_URL")) { c.base_url = u; if (c.base_url.find("/api/v1") == std::string::npos) c.base_url += "/api/v1"; }26 if (const char* k = std::getenv("FINDATA_API_KEY")) c.api_key = k;27 else if (const char* f = std::getenv("FINDATA_API_KEY_FILE")) { std::ifstream in(f); std::getline(in, c.api_key); }28 if (const char* d = std::getenv("RAID_CACHE_DIR")) c.cache_dir = d;29 if (c.cache_dir.empty()) c.cache_dir = "datasets/cache";30 if (const char* o = std::getenv("RAID_OFFLINE")) c.offline = std::string(o) == "1";31 if (const char* r = std::getenv("RAID_FINDATA_RPS")) c.requests_per_second = std::atof(r);32 while (!c.api_key.empty() && (c.api_key.back() == '\n' || c.api_key.back() == '\r' || c.api_key.back() == ' ')) c.api_key.pop_back();33 return c;34}3536double TokenBucket::acquire() {37 std::lock_guard<std::mutex> lk(m_);38 const auto now = std::chrono::steady_clock::now();39 tokens_ = std::min(capacity_, tokens_ + rate_ * std::chrono::duration<double>(now - last_).count()); last_ = now;40 double waited = 0;41 if (tokens_ < 1.0) { waited = (1.0 - tokens_) / rate_; std::this_thread::sleep_for(std::chrono::duration<double>(waited)); tokens_ = 1.0; last_ = std::chrono::steady_clock::now(); }42 tokens_ -= 1.0;43 return waited;44}4546std::vector<std::pair<Date, Date>> chunk_dates(Date start, Date end, int max_days) {47 RAID_REQUIRE(max_days >= 1, invalid_argument, "max_days >= 1");48 RAID_REQUIRE(end >= start, invalid_argument, "end must be >= start");49 std::vector<std::pair<Date, Date>> out;50 for (Date a = start; a <= end;) { Date b = a + (max_days - 1); if (b > end) b = end; out.emplace_back(a, b); a = b + 1; }51 return out;52}53int max_days_per_call(Interval iv) { switch (iv) { case Interval::m1: return 31; case Interval::m5: return 186; case Interval::d1: return 3650; default: return 31; } }5455// ---------------------------------------------------------------- HTTP56namespace {57size_t write_cb(char* p, size_t s, size_t n, void* ud) { static_cast<std::string*>(ud)->append(p, s * n); return s * n; }58size_t file_cb(char* p, size_t s, size_t n, void* ud) { static_cast<std::ofstream*>(ud)->write(p, static_cast<std::streamsize>(s * n)); return s * n; }59size_t header_cb(char* p, size_t s, size_t n, void* ud) {60 std::string line(p, s * n); auto* h = static_cast<std::map<std::string, std::string>*>(ud);61 const auto c = line.find(':'); if (c != std::string::npos) { std::string k = line.substr(0, c), v = line.substr(c + 1); std::transform(k.begin(), k.end(), k.begin(), ::tolower);62 while (!v.empty() && (v.back() == '\r' || v.back() == '\n' || v.back() == ' ')) v.pop_back(); while (!v.empty() && v.front() == ' ') v.erase(v.begin()); (*h)[k] = v; }63 return s * n;64}65struct CurlGlobal { CurlGlobal() { curl_global_init(CURL_GLOBAL_DEFAULT); } ~CurlGlobal() { curl_global_cleanup(); } };66void ensure_curl() { static CurlGlobal g; }67std::string url_encode(const std::string& s) { std::string o; char buf[4]; for (unsigned char c : s) { if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~' || c == ',' ) o += static_cast<char>(c); else { std::snprintf(buf, sizeof buf, "%%%02X", c); o += buf; } } return o; }68} // namespace6970Client::Client(Config cfg) : cfg_(std::move(cfg)), bucket_(cfg_.requests_per_second, cfg_.burst) {71 ensure_curl(); key_ = cfg_.api_key; cfg_.api_key.clear(); // the key lives only in key_72 RAID_REQUIRE(cfg_.offline || !key_.empty(), api, "FinData API key missing: set FINDATA_API_KEY or FINDATA_API_KEY_FILE");73 fs::create_directories(cfg_.cache_dir);74}75Client::~Client() = default;76std::string Client::key_fingerprint() const { return key_.empty() ? "none" : Sha256::of(key_).substr(0, 12); }7778Client::Response Client::request_once(const std::string& method, const std::string& url, const std::string& body, const std::string& content_type, const std::string& download_to) {79 Response r; CURL* c = curl_easy_init(); RAID_REQUIRE(c != nullptr, api, "curl init failed");80 struct curl_slist* hdrs = nullptr; hdrs = curl_slist_append(hdrs, ("Authorization: Bearer " + key_).c_str()); hdrs = curl_slist_append(hdrs, "User-Agent: raid/" RAID_VERSION " (UQO WP40; +simon-pierre.boucher@uqo.ca)");81 if (!body.empty()) hdrs = curl_slist_append(hdrs, ("Content-Type: " + content_type).c_str());82 std::ofstream out; if (!download_to.empty()) { out.open(download_to + ".part", std::ios::binary); RAID_REQUIRE(out.good(), io, "cannot write " + download_to); }83 curl_easy_setopt(c, CURLOPT_URL, url.c_str()); curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs); curl_easy_setopt(c, CURLOPT_TIMEOUT, cfg_.timeout_seconds); curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, 20L);84 curl_easy_setopt(c, CURLOPT_SSL_VERIFYPEER, cfg_.verify_tls ? 1L : 0L); curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 0L); curl_easy_setopt(c, CURLOPT_ACCEPT_ENCODING, "");85 curl_easy_setopt(c, CURLOPT_HEADERFUNCTION, header_cb); curl_easy_setopt(c, CURLOPT_HEADERDATA, &r.headers);86 if (!download_to.empty()) { curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, file_cb); curl_easy_setopt(c, CURLOPT_WRITEDATA, &out); } else { curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, write_cb); curl_easy_setopt(c, CURLOPT_WRITEDATA, &r.body); }87 if (method == "POST") { curl_easy_setopt(c, CURLOPT_POST, 1L); curl_easy_setopt(c, CURLOPT_POSTFIELDS, body.c_str()); curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, static_cast<long>(body.size())); }88 else if (method != "GET") curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, method.c_str());89 const auto t0 = std::chrono::steady_clock::now(); const CURLcode rc = curl_easy_perform(c); r.seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();90 curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &r.status); curl_slist_free_all(hdrs); curl_easy_cleanup(c);91 if (!download_to.empty()) { out.close(); if (rc == CURLE_OK && r.status == 200) fs::rename(download_to + ".part", download_to); else fs::remove(download_to + ".part"); }92 if (rc != CURLE_OK) { r.status = -static_cast<long>(rc); r.body = curl_easy_strerror(rc); }93 return r;94}9596Client::Response Client::request(const std::string& method, const std::string& path, const std::string& body, const std::string& content_type, const std::string& download_to) {97 RAID_REQUIRE(!cfg_.offline, api, "offline mode: network call refused for " + method + " " + path);98 const std::string url = cfg_.base_url + path;99 Response r; double backoff = 1.0;100 for (int attempt = 1; attempt <= cfg_.max_retries + 1; ++attempt) {101 bucket_.acquire();102 r = request_once(method, url, body, content_type, download_to); r.attempts = attempt;103 Exchange ex; ex.method = method; ex.path = path; ex.status = r.status; ex.seconds = r.seconds; ex.bytes = r.body.size(); ex.attempts = attempt;104 if (auto it = r.headers.find("x-request-id"); it != r.headers.end()) ex.request_id = it->second;105 if (auto it = r.headers.find("x-dataset-id"); it != r.headers.end()) ex.dataset_id = it->second;106 if (auto it = r.headers.find("x-dataset-version"); it != r.headers.end()) ex.dataset_version = std::atoll(it->second.c_str());107 if (auto it = r.headers.find("x-next-cursor"); it != r.headers.end()) ex.next_cursor = it->second;108 { std::lock_guard<std::mutex> lk(log_m_); log_.push_back(ex); }109 const bool retry = r.status == 429 || r.status == 502 || r.status == 503 || r.status == 504 || r.status < 0;110 if (!retry) break;111 double wait = backoff; if (auto it = r.headers.find("retry-after"); it != r.headers.end()) wait = std::max(wait, std::atof(it->second.c_str()));112 log::warn("findata retry", {{"status", std::to_string(r.status)}, {"path", path}, {"attempt", std::to_string(attempt)}, {"wait_s", std::to_string(wait)}});113 if (attempt <= cfg_.max_retries) std::this_thread::sleep_for(std::chrono::duration<double>(wait));114 backoff = std::min(backoff * 2.0, 30.0);115 }116 if (r.status != 200 && r.status != 202) {117 std::string msg = r.body.substr(0, 400); try { auto j = json::parse(r.body); if (j.contains("error")) msg = j["error"].dump(); } catch (...) {}118 throw RaidError(ErrorCode::api, "FinData " + method + " " + path + " → HTTP " + std::to_string(r.status) + ": " + log::redact(msg));119 }120 return r;121}122123std::string Client::cache_file(const std::string& kind, const std::string& key_material, const std::string& ext) const {124 return (fs::path(cfg_.cache_dir) / (kind + "_" + Sha256::of(key_material).substr(0, 24) + ext)).string();125}126127// ---------------------------------------------------------------- simple JSON endpoints128nlohmann::json Client::health() { return json::parse(request("GET", "/health").body); }129nlohmann::json Client::openapi() { return json::parse(request("GET", "/openapi.json").body); }130nlohmann::json Client::catalog_datasets() { return json::parse(request("GET", "/catalog/datasets").body); }131nlohmann::json Client::dataset(const std::string& id) { return json::parse(request("GET", "/catalog/datasets/" + url_encode(id)).body); }132nlohmann::json Client::sessions(const std::string& symbol, Interval iv, Date start, Date end) {133 return json::parse(request("GET", "/market/sessions?symbol=" + url_encode(symbol) + "&interval=" + std::string(to_string(iv)) + "&start=" + start.iso() + "&end=" + end.iso()).body);134}135nlohmann::json Client::coverage_symbols(const std::string& dataset_id, int min_sessions, usize limit, const std::string& cursor) {136 std::string p = "/coverage/symbols?dataset_id=" + url_encode(dataset_id) + "&min_sessions=" + std::to_string(min_sessions) + "&limit=" + std::to_string(limit);137 if (!cursor.empty()) p += "&cursor=" + url_encode(cursor);138 return json::parse(request("GET", p).body);139}140nlohmann::json Client::coverage_symbols_all(const std::string& dataset_id, int min_sessions, usize max_symbols) {141 json out = json::array(); std::string cursor;142 for (;;) { auto page = coverage_symbols(dataset_id, min_sessions, 1000, cursor); for (auto& r : page["data"]) out.push_back(r); if (out.size() >= max_symbols || page["next_cursor"].is_null() || page["data"].empty()) break; cursor = page["next_cursor"].get<std::string>(); }143 return out;144}145nlohmann::json Client::fundamentals(const std::string& entity, const std::string& as_of_iso, const std::vector<std::string>& metrics, const std::string& period_type) {146 std::string p = "/fundamentals/" + url_encode(entity) + "?as_of=" + url_encode(as_of_iso);147 if (!metrics.empty()) { std::string m; for (auto& x : metrics) { if (!m.empty()) m += ","; m += x; } p += "&metrics=" + url_encode(m); }148 if (!period_type.empty()) p += "&period_type=" + period_type;149 return json::parse(request("GET", p).body);150}151nlohmann::json Client::query(const std::string& sql, usize max_rows) {152 RAID_REQUIRE(max_rows <= 100000, invalid_argument, "query: max_rows <= 100000");153 return json::parse(request("POST", "/query", json({{"sql", sql}, {"max_rows", max_rows}}).dump()).body);154}155156// ---------------------------------------------------------------- bars (arrow pages)157BarsResult Client::bars(const BarsRequest& req) {158 BarsResult res; res.bars.symbol = req.symbol; res.bars.interval = req.interval; res.bars.adjustment = req.adjustment;159 const std::string key = "bars|" + req.symbol + "|" + std::string(to_string(req.interval)) + "|" + req.start.iso() + "|" + req.end.iso() + "|" + req.adjustment + "|v" + (req.version ? std::to_string(*req.version) : "latest");160 const std::string meta_path = cache_file("bars", key, ".meta.json"), data_path = cache_file("bars", key, ".csv");161 if (fs::exists(meta_path) && fs::exists(data_path)) {162 auto meta = json::parse(std::ifstream(meta_path)); if (Sha256::of_file(data_path) == meta["sha256"].get<std::string>()) {163 auto m = data::read_bars_csv(data_path, req.interval); if (!m.empty()) res.bars = m.begin()->second; res.bars.symbol = req.symbol; res.bars.instrument_id = meta.value("instrument_id", ""); res.bars.adjustment = req.adjustment;164 res.dataset_version = meta["dataset_version"].get<i64>(); res.dataset_id = meta["dataset_id"].get<std::string>(); res.from_cache = true; res.cache_path = data_path; return res; }165 }166#ifndef RAID_WITH_ARROW167 throw RaidError(ErrorCode::unsupported, "bars(): Arrow build required to decode pages (RAID_WITH_ARROW)");168#else169 const usize log_before = log_.size();170 for (auto [a, b] : chunk_dates(req.start, req.end, max_days_per_call(req.interval))) {171 std::string cursor;172 for (;;) {173 std::string p = "/market/bars?symbol=" + url_encode(req.symbol) + "&interval=" + std::string(to_string(req.interval)) + "&start=" + a.iso() + "&end=" + b.iso() + "&adjustment=" + req.adjustment + "&format=arrow&limit=" + std::to_string(req.page_limit);174 if (req.version) p += "&version=" + std::to_string(*req.version); if (!cursor.empty()) p += "&cursor=" + url_encode(cursor);175 auto r = request("GET", p);176 const std::string tmp = cache_file("page", key + p + std::to_string(log_.size()), ".arrow"); { std::ofstream f(tmp, std::ios::binary); f.write(r.body.data(), static_cast<std::streamsize>(r.body.size())); }177 auto pb = data::read_bars_arrow_ipc(tmp, req.interval); fs::remove(tmp);178 for (auto& [sym, bs] : pb.series) { if (res.bars.instrument_id.empty()) res.bars.instrument_id = bs.instrument_id; for (usize i = 0; i < bs.size(); ++i) res.bars.push_back(bs.t_open[i], bs.open[i], bs.high[i], bs.low[i], bs.close[i], bs.volume[i], bs.flags[i]); }179 if (auto it = r.headers.find("x-dataset-version"); it != r.headers.end()) res.dataset_version = std::atoll(it->second.c_str());180 if (auto it = r.headers.find("x-dataset-id"); it != r.headers.end()) res.dataset_id = it->second;181 auto nc = r.headers.find("x-next-cursor"); if (nc == r.headers.end() || nc->second.empty() || pb.rows_read == 0) break; cursor = nc->second;182 }183 }184 res.exchanges.assign(log_.begin() + static_cast<long>(log_before), log_.end());185 auto st = res.bars.normalize(); res.duplicates_dropped = st.duplicates_removed;186 data::write_bars_csv(data_path, res.bars);187 json meta = {{"key", key}, {"sha256", Sha256::of_file(data_path)}, {"dataset_version", res.dataset_version}, {"dataset_id", res.dataset_id}, {"instrument_id", res.bars.instrument_id}, {"rows", res.bars.size()}, {"duplicates_dropped", res.duplicates_dropped}, {"fetched_at", now_utc_iso()}, {"key_fingerprint", key_fingerprint()}, {"request_ids", json::array()}};188 for (auto& e : res.exchanges) meta["request_ids"].push_back(e.request_id);189 std::ofstream(meta_path) << meta.dump(1); res.cache_path = data_path;190 return res;191#endif192}193194// ---------------------------------------------------------------- exports195ExportResult Client::export_bars(const ExportRequest& req, const std::string& out_path) {196 ExportResult res; const auto t0 = std::chrono::steady_clock::now();197 RAID_REQUIRE(!req.symbols.empty(), invalid_argument, "export: symbols required");198 RAID_REQUIRE(req.end.days - req.start.days <= 12 * 366, invalid_argument, "export: at most 12 years per request (chunk the range)");199 std::string syms; for (auto& s : req.symbols) { if (!syms.empty()) syms += ","; syms += s; }200 const std::string key = "export|" + req.dataset_id + "|" + syms + "|" + req.start.iso() + "|" + req.end.iso() + "|" + req.adjustment;201 const std::string meta_path = cache_file("export", key, ".meta.json"); std::string data_path = out_path.empty() ? cache_file("export", key, ".parquet") : out_path;202 if (fs::exists(meta_path) && fs::exists(data_path)) {203 auto meta = json::parse(std::ifstream(meta_path));204 if (Sha256::of_file(data_path) == meta["sha256"].get<std::string>()) { res.export_id = meta["export_id"]; res.path = data_path; res.sha256 = meta["sha256"]; res.dataset_version = meta["dataset_version"]; res.rows = meta["rows"]; res.bytes = meta["bytes"]; res.from_cache = true; return res; }205 }206 const usize log_before = log_.size();207 json body = {{"dataset_id", req.dataset_id}, {"symbols", req.symbols}, {"start", req.start.iso()}, {"end", req.end.iso()}, {"adjustment", req.adjustment}, {"format", "parquet"}};208 auto r = request("POST", "/exports", body.dump()); auto j = json::parse(r.body); res.export_id = j["id"].get<std::string>();209 json st; double wait = 1.0;210 for (int i = 0; i < 3600; ++i) {211 st = json::parse(request("GET", "/exports/" + res.export_id).body); const std::string s = st["status"].get<std::string>();212 if (s == "ready") break; RAID_REQUIRE(s != "failed" && s != "expired", api, "export " + s + ": " + st["error"].dump());213 std::this_thread::sleep_for(std::chrono::duration<double>(wait)); wait = std::min(wait * 1.5, 10.0);214 }215 RAID_REQUIRE(st["status"] == "ready", api, "export did not become ready");216 fs::create_directories(fs::path(data_path).parent_path().empty() ? "." : fs::path(data_path).parent_path());217 request("GET", "/exports/" + res.export_id + "/download", "", "application/json", data_path);218 res.path = data_path; res.sha256 = Sha256::of_file(data_path); res.rows = st.value("row_count", 0); res.bytes = st.value("bytes", 0);219 if (st.contains("snapshot") && st["snapshot"].contains("version")) res.dataset_version = st["snapshot"]["version"].get<i64>();220 res.exchanges.assign(log_.begin() + static_cast<long>(log_before), log_.end());221 RAID_REQUIRE(fs::file_size(data_path) == res.bytes || res.bytes == 0, io, "export size mismatch: " + std::to_string(fs::file_size(data_path)) + " vs " + std::to_string(res.bytes));222 json meta = {{"key", key}, {"export_id", res.export_id}, {"sha256", res.sha256}, {"dataset_version", res.dataset_version}, {"dataset_id", req.dataset_id}, {"rows", res.rows}, {"bytes", res.bytes}, {"request", body}, {"status", st}, {"fetched_at", now_utc_iso()}, {"key_fingerprint", key_fingerprint()}, {"request_ids", json::array()}};223 for (auto& e : res.exchanges) meta["request_ids"].push_back(e.request_id);224 std::ofstream(meta_path) << meta.dump(1);225 res.seconds_total = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();226 return res;227}228229std::map<std::string, BarSeries> Client::export_and_read(const ExportRequest& req) {230#ifdef RAID_WITH_ARROW231 auto e = export_bars(req); auto pb = data::read_bars_parquet(e.path); return std::move(pb.series);232#else233 (void)req; throw RaidError(ErrorCode::unsupported, "export_and_read requires the Arrow build");234#endif235}236237Provenance Client::provenance(const std::string& dataset_id) const {238 Provenance p; p.dataset_id = dataset_id; p.source = "FirstRate Data via FinData OS (" + cfg_.base_url + ")"; p.code_version = RAID_VERSION;239 for (auto& e : log_) { if (!e.request_id.empty()) p.request_ids.push_back(e.request_id); if (e.dataset_version > 0 && (dataset_id.empty() || e.dataset_id == dataset_id)) p.dataset_version = e.dataset_version; }240 p.extra = {{"key_fingerprint", key_fingerprint()}, {"n_requests", log_.size()}};241 return p;242}243244// ---------------------------------------------------------------- CLI245namespace {246std::string arg(const std::vector<std::string>& a, const std::string& k, const std::string& d = "") { for (usize i = 0; i + 1 < a.size(); ++i) if (a[i] == k) return a[i + 1]; return d; }247}248int cli_main(const std::vector<std::string>& a) {249 if (a.empty()) { std::fprintf(stderr, "raid data catalog|health|sessions SYM --start --end|coverage [--min-sessions N] [--dataset ID]|bars SYM --start --end [--interval 1m] [--adjustment raw]|export SYMS --start --end [--dataset ID] [--out file.parquet]|fund ENTITY --as-of ISO [--metrics a,b]|query \"SQL\" [--max-rows N]|log\n"); return 2; }250 Client c; const std::string cmd = a[0];251 auto out = [](const json& j) { std::cout << j.dump(1) << "\n"; };252 if (cmd == "catalog") { out(c.catalog_datasets()); return 0; }253 if (cmd == "health") { out(c.health()); return 0; }254 if (cmd == "sessions") { out(c.sessions(a.at(1), interval_from_string(arg(a, "--interval", "1m")), date_from_iso(arg(a, "--start")), date_from_iso(arg(a, "--end")))); return 0; }255 if (cmd == "coverage") { out(c.coverage_symbols_all(arg(a, "--dataset", "market_bars.stock.1m.raw"), std::stoi(arg(a, "--min-sessions", "250")), static_cast<usize>(std::stoul(arg(a, "--max", "100000"))))); return 0; }256 if (cmd == "bars") { BarsRequest r; r.symbol = a.at(1); r.interval = interval_from_string(arg(a, "--interval", "1m")); r.start = date_from_iso(arg(a, "--start")); r.end = date_from_iso(arg(a, "--end")); r.adjustment = arg(a, "--adjustment", "raw");257 auto res = c.bars(r); json j = {{"symbol", r.symbol}, {"rows", res.bars.size()}, {"sessions", res.bars.n_sessions()}, {"dataset_id", res.dataset_id}, {"dataset_version", res.dataset_version}, {"duplicates_dropped", res.duplicates_dropped}, {"from_cache", res.from_cache}, {"cache_path", res.cache_path}, {"requests", res.exchanges.size()}};258 if (!arg(a, "--out").empty()) { data::write_bars_csv(arg(a, "--out"), res.bars); j["out"] = arg(a, "--out"); } out(j); return 0; }259 if (cmd == "export") { ExportRequest r; std::string s = a.at(1); usize p = 0; for (usize i = 0; i <= s.size(); ++i) if (i == s.size() || s[i] == ',') { r.symbols.push_back(s.substr(p, i - p)); p = i + 1; }260 r.dataset_id = arg(a, "--dataset", "market_bars.stock.1m.raw"); r.start = date_from_iso(arg(a, "--start")); r.end = date_from_iso(arg(a, "--end")); r.adjustment = arg(a, "--adjustment", "raw");261 auto e = c.export_bars(r, arg(a, "--out")); out({{"export_id", e.export_id}, {"path", e.path}, {"sha256", e.sha256}, {"dataset_version", e.dataset_version}, {"rows", e.rows}, {"bytes", e.bytes}, {"from_cache", e.from_cache}, {"seconds", e.seconds_total}}); return 0; }262 if (cmd == "fund") { std::vector<std::string> m; std::string ms = arg(a, "--metrics"); usize p = 0; for (usize i = 0; i <= ms.size() && !ms.empty(); ++i) if (i == ms.size() || ms[i] == ',') { m.push_back(ms.substr(p, i - p)); p = i + 1; } out(c.fundamentals(a.at(1), arg(a, "--as-of", "2026-01-01T00:00:00Z"), m, arg(a, "--period", ""))); return 0; }263 if (cmd == "query") { out(c.query(a.at(1), static_cast<usize>(std::stoul(arg(a, "--max-rows", "1000"))))); return 0; }264 if (cmd == "log") { json j = json::array(); for (auto& e : c.log()) j.push_back({{"method", e.method}, {"path", e.path}, {"status", e.status}, {"request_id", e.request_id}}); out(j); return 0; }265 std::fprintf(stderr, "unknown data subcommand %s\n", cmd.c_str()); return 2;266}267268} // namespace raid::findata269