SPB Git

spb/wp40_uqo

Public MIT

WP40 RAID: Phase 8 fix — data layer sources were ignored by a too-broad 'data/' pattern (found by the clean-checkout reproduction); track include/raid/data, src/data

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 2, 2026) parent 1eed3a1 Browse files

17 changed files +1,145 −1

modified .gitignore +1 −1
@@ -22,7 +22,7 @@ __pycache__/
22 22 # Data — FirstRate private license: never commit real bars
23 23 datasets/cache/
24 24 datasets/real/
25 −data/
25 +/data/
26 26 experiments/results/raw/
27 27 *.parquet
28 28 *.arrow
added include/raid/data/bar_series.hpp +50 −0
@@ -0,0 +1,50 @@
1 +// BarSeries — OHLCV bars for one instrument (columnar), with session structure derived from the MarketCalendar.
2 +#pragma once
3 +#include <vector>
4 +#include <span>
5 +#include <string>
6 +#include "raid/core/types.hpp"
7 +#include "raid/data/calendar.hpp"
8 +#include "raid/data/quality.hpp"
9 +
10 +namespace raid {
11 +
12 +struct BarSeries {
13 + std::string symbol;
14 + std::string instrument_id;
15 + Interval interval{Interval::m1};
16 + std::string adjustment{"raw"};
17 + std::vector<Timestamp> t_open; // bar OPEN, UTC
18 + std::vector<Date> session; // exchange-local session date
19 + std::vector<f64> open, high, low, close, volume;
20 + std::vector<u32> flags; // bitmask (see QualityFlag)
21 + // Session index: session_offsets[k]..session_offsets[k+1] are the bars of session k (built by index_sessions()).
22 + std::vector<i64> session_offsets;
23 + std::vector<Date> session_dates;
24 +
25 + usize size() const { return close.size(); }
26 + bool empty() const { return close.empty(); }
27 + void reserve(usize n) { t_open.reserve(n); session.reserve(n); open.reserve(n); high.reserve(n); low.reserve(n); close.reserve(n); volume.reserve(n); flags.reserve(n); }
28 + void push_back(Timestamp t, f64 o, f64 h, f64 l, f64 c, f64 v, u32 f = 0) {
29 + t_open.push_back(t); session.push_back(MarketCalendar::session_of(t)); open.push_back(o); high.push_back(h); low.push_back(l); close.push_back(c); volume.push_back(v); flags.push_back(f);
30 + }
31 + usize n_sessions() const { return session_dates.size(); }
32 + std::span<const f64> session_closes(usize k) const { return std::span<const f64>(close).subspan(static_cast<usize>(session_offsets[k]), static_cast<usize>(session_offsets[k + 1] - session_offsets[k])); }
33 + std::span<const Timestamp> session_times(usize k) const { return std::span<const Timestamp>(t_open).subspan(static_cast<usize>(session_offsets[k]), static_cast<usize>(session_offsets[k + 1] - session_offsets[k])); }
34 +
35 + /// Sorts by time, drops exact duplicate timestamps (keeps the first), rebuilds the session index. Returns counts.
36 + struct NormalizeStats { usize duplicates_removed; usize reordered; };
37 + NormalizeStats normalize();
38 + void index_sessions();
39 + /// Validates OHLCV, interval grid, session membership and coverage; does not modify data.
40 + QualityReport validate(const MarketCalendar& cal, SessionPolicy policy = SessionPolicy::regular, f64 jump_threshold = 0.25) const;
41 + /// Keeps only bars of the requested session policy (regular: 09:30 ≤ open ≤ close time, i.e. includes the 16:00 print).
42 + BarSeries filter_session(const MarketCalendar& cal, SessionPolicy policy) const;
43 +};
44 +
45 +enum QualityFlag : u32 {
46 + kFlagOhlcInconsistent = 1u << 0, kFlagNonpositivePrice = 1u << 1, kFlagNegativeVolume = 1u << 2, kFlagZeroVolume = 1u << 3,
47 + kFlagOffInterval = 1u << 4, kFlagOutsideSession = 1u << 5, kFlagNonTradingDay = 1u << 6, kFlagPriceJump = 1u << 7, kFlagVendorFlag = 1u << 8,
48 +};
49 +
50 +} // namespace raid
added include/raid/data/calendar.hpp +58 −0
@@ -0,0 +1,58 @@
1 +// MarketCalendar — XNYS (NYSE/Nasdaq) trading days, early closes, regular-session bounds and America/New_York DST.
2 +// Rules are computed (no external data): NYSE holiday rules since 2000, special closures listed explicitly,
3 +// US DST rules (2007+: 2nd Sunday March → 1st Sunday November; 1987–2006: 1st Sunday April → last Sunday October).
4 +// Validation: tests/reference/calendar_vs_exchange_calendars.cpp compares against the Python exchange_calendars fixture.
5 +#pragma once
6 +#include <vector>
7 +#include <string>
8 +#include <optional>
9 +#include "raid/core/types.hpp"
10 +
11 +namespace raid {
12 +
13 +struct SessionBounds {
14 + Date session;
15 + Timestamp open_utc; // 09:30 ET
16 + Timestamp close_utc; // 16:00 ET (13:00 on early closes) — the close bar OPENS at this instant (closing print)
17 + Timestamp ext_open_utc; // 04:00 ET
18 + Timestamp ext_close_utc; // 20:00 ET (bars open until 19:59). On early-close days the vendor stops at 17:00.
19 + bool early_close{false};
20 + i64 utc_offset_seconds{0}; // ET offset that day at the open (−18000 EST / −14400 EDT)
21 +};
22 +
23 +class MarketCalendar {
24 + public:
25 + enum class Market { XNYS };
26 + explicit MarketCalendar(Market m = Market::XNYS) : market_(m) {}
27 + Market market() const { return market_; }
28 +
29 + bool is_trading_day(Date d) const;
30 + bool is_early_close(Date d) const;
31 + std::optional<std::string> holiday_name(Date d) const;
32 + /// Session bounds for a trading day (throws if not a trading day).
33 + SessionBounds session(Date d) const;
34 + /// Regular-hours bars expected for one session at the given interval (1m: 391 normal, 211 early close).
35 + usize expected_regular_bars(Date d, Interval iv) const;
36 + /// Trading sessions in [start, end] inclusive.
37 + std::vector<Date> sessions(Date start, Date end) const;
38 + /// Next / previous trading day strictly after / before d.
39 + Date next_session(Date d) const;
40 + Date previous_session(Date d) const;
41 + /// America/New_York offset (seconds east of UTC) at a UTC instant.
42 + static i64 new_york_offset(Timestamp utc);
43 + /// Local New York civil date and minute-of-day of a UTC instant.
44 + struct Local { Date date; int minute_of_day; i64 offset_seconds; };
45 + static Local to_new_york(Timestamp utc);
46 + /// UTC instant of a New York wall-clock time (assumes the time exists; gap hour maps to post-transition).
47 + static Timestamp from_new_york(Date d, int minute_of_day);
48 + /// Session date of a bar-open timestamp under the US equity convention (04:00–20:00 ET same civil day).
49 + static Date session_of(Timestamp bar_open_utc) { return to_new_york(bar_open_utc).date; }
50 +
51 + private:
52 + Market market_;
53 +};
54 +
55 +/// Convenience: trading-day distance between two sessions (number of sessions strictly between + 1).
56 +usize trading_days_between(const MarketCalendar& cal, Date a, Date b);
57 +
58 +} // namespace raid
added include/raid/data/findata.hpp +110 −0
@@ -0,0 +1,110 @@
1 +// FinData OS client (https://findata.spboucher.ai/api/v1) — bearer auth from FINDATA_API_KEY / FINDATA_API_KEY_FILE,
2 +// TLS via libcurl, redacted logs, token-bucket rate limiting, bounded concurrency, retries with exponential backoff
3 +// honouring Retry-After, cursor pagination, 31-day chunking for 1m bars, asynchronous exports with polling/download,
4 +// on-disk cache keyed by dataset version, SHA-256 of every downloaded artifact, and provenance (request ids, versions).
5 +#pragma once
6 +#include <string>
7 +#include <vector>
8 +#include <map>
9 +#include <optional>
10 +#include <mutex>
11 +#include <chrono>
12 +#include <functional>
13 +#include <nlohmann/json.hpp>
14 +#include "raid/core/types.hpp"
15 +#include "raid/core/estimate.hpp"
16 +#include "raid/data/bar_series.hpp"
17 +
18 +namespace raid::findata {
19 +
20 +struct Config {
21 + std::string base_url{"https://findata.spboucher.ai/api/v1"};
22 + std::string api_key; // empty → FINDATA_API_KEY, then FINDATA_API_KEY_FILE
23 + std::string cache_dir; // empty → $RAID_CACHE_DIR or ./datasets/cache
24 + double requests_per_second{5.0}; // token bucket rate (600/min limit → 5/s leaves headroom)
25 + double burst{10.0};
26 + int max_retries{5};
27 + long timeout_seconds{120};
28 + unsigned max_concurrency{4};
29 + bool verify_tls{true};
30 + bool offline{false}; // only serve from cache; any network call throws (for CI)
31 + static Config from_env();
32 +};
33 +
34 +/// Provenance of one HTTP exchange (no secrets).
35 +struct Exchange {
36 + std::string method, path; // query string without the key (the key is a header)
37 + long status{0};
38 + std::string request_id, dataset_id; // X-Request-ID, X-Dataset-Id
39 + i64 dataset_version{-1}; // X-Dataset-Version
40 + std::string next_cursor; // X-Next-Cursor (arrow pages)
41 + double seconds{0};
42 + usize bytes{0};
43 + int attempts{1};
44 +};
45 +
46 +class TokenBucket {
47 + public:
48 + TokenBucket(double rate, double burst) : rate_(rate), capacity_(burst), tokens_(burst), last_(std::chrono::steady_clock::now()) {}
49 + /// Blocks until a token is available; returns the wait in seconds.
50 + double acquire();
51 + private:
52 + double rate_, capacity_, tokens_; std::chrono::steady_clock::time_point last_; std::mutex m_;
53 +};
54 +
55 +/// Splits [start, end] (inclusive dates) into chunks of at most `max_days` calendar days (API constraint: 1m ≤ 31 d,
56 +/// 5m ≤ 186 d, 1d ≤ 10 y; exports ≤ 12 y).
57 +std::vector<std::pair<Date, Date>> chunk_dates(Date start, Date end, int max_days);
58 +int max_days_per_call(Interval iv);
59 +
60 +struct BarsRequest {
61 + std::string symbol; Interval interval{Interval::m1}; Date start, end; std::string adjustment{"raw"}; usize page_limit{10000}; std::optional<i64> version;
62 +};
63 +struct BarsResult { BarSeries bars; std::vector<Exchange> exchanges; i64 dataset_version{-1}; std::string dataset_id; usize duplicates_dropped{0}; std::string cache_path; bool from_cache{false}; };
64 +
65 +struct ExportRequest { std::string dataset_id{"market_bars.stock.1m.raw"}; std::vector<std::string> symbols; Date start, end; std::string adjustment{"raw"}; };
66 +struct ExportResult { std::string export_id, path, sha256; i64 dataset_version{-1}; usize rows{0}; usize bytes{0}; std::vector<Exchange> exchanges; bool from_cache{false}; double seconds_total{0}; };
67 +
68 +class Client {
69 + public:
70 + explicit Client(Config cfg = Config::from_env());
71 + ~Client();
72 + const Config& config() const { return cfg_; }
73 + std::string key_fingerprint() const; // first 12 hex of sha256(key): identifies the key without revealing it
74 +
75 + nlohmann::json health();
76 + nlohmann::json openapi();
77 + nlohmann::json catalog_datasets();
78 + nlohmann::json dataset(const std::string& id);
79 + nlohmann::json sessions(const std::string& symbol, Interval iv, Date start, Date end);
80 + nlohmann::json coverage_symbols(const std::string& dataset_id, int min_sessions, usize limit = 200, const std::string& cursor = "");
81 + /// All symbols with ≥ min_sessions sessions (follows cursors); returns {symbol, sessions, d0, d1, bars}.
82 + nlohmann::json coverage_symbols_all(const std::string& dataset_id, int min_sessions, usize max_symbols = 100000);
83 + nlohmann::json fundamentals(const std::string& entity, const std::string& as_of_iso, const std::vector<std::string>& metrics = {}, const std::string& period_type = "");
84 + /// Bounded read-only SQL (server: 30 s, ≤ 100 000 rows). Use only for universe/coverage questions.
85 + nlohmann::json query(const std::string& sql, usize max_rows = 1000);
86 +
87 + /// Bars via GET /market/bars?format=arrow with cursor pagination and ≤ max_days chunking; cached per dataset version.
88 + BarsResult bars(const BarsRequest& req);
89 + /// Asynchronous export (POST /exports → poll → download) with ≤ 10-year chunking handled by the caller; cached.
90 + ExportResult export_bars(const ExportRequest& req, const std::string& out_path = "");
91 + /// Convenience: export, then read into BarSeries per symbol (requires Arrow build).
92 + std::map<std::string, BarSeries> export_and_read(const ExportRequest& req);
93 +
94 + const std::vector<Exchange>& log() const { return log_; }
95 + Provenance provenance(const std::string& dataset_id) const;
96 +
97 + struct Response { long status{0}; std::string body; std::map<std::string, std::string> headers; double seconds{0}; int attempts{1}; };
98 + Response request(const std::string& method, const std::string& path_and_query, const std::string& body = "", const std::string& content_type = "application/json", const std::string& download_to = "");
99 +
100 + private:
101 + Response request_once(const std::string& method, const std::string& url, const std::string& body, const std::string& content_type, const std::string& download_to);
102 + std::string cache_file(const std::string& kind, const std::string& key_material, const std::string& ext) const;
103 + Config cfg_; std::string key_; TokenBucket bucket_; std::vector<Exchange> log_; std::mutex log_m_;
104 + void* curl_share_{nullptr};
105 +};
106 +
107 +/// `raid data …` subcommands (returns exit code).
108 +int cli_main(const std::vector<std::string>& args);
109 +
110 +} // namespace raid::findata
added include/raid/data/io.hpp +24 −0
@@ -0,0 +1,24 @@
1 +// Readers: CSV fixtures (always available) and Parquet/Arrow IPC (when RAID_WITH_ARROW).
2 +#pragma once
3 +#include <string>
4 +#include <vector>
5 +#include <map>
6 +#include "raid/data/bar_series.hpp"
7 +
8 +namespace raid::data {
9 +
10 +/// CSV fixture format (synthetic, committed): header
11 +/// symbol,timestamp_utc,open,high,low,close,volume (timestamp ISO-8601 UTC, bar open)
12 +std::map<std::string, BarSeries> read_bars_csv(const std::string& path, Interval iv = Interval::m1);
13 +void write_bars_csv(const std::string& path, const BarSeries& bars);
14 +
15 +#ifdef RAID_WITH_ARROW
16 +/// Reads a FinData bars Parquet file (schema v1) into one BarSeries per instrument_id; `symbols` filters (empty = all).
17 +/// Returns also the dataset metadata found in the file footer (findata.schema_version, timestamp_convention).
18 +struct ParquetBars { std::map<std::string, BarSeries> series; std::map<std::string, std::string> metadata; usize rows_read{0}; usize duplicates_dropped{0}; };
19 +ParquetBars read_bars_parquet(const std::string& path, const std::vector<std::string>& symbols = {}, Interval iv = Interval::m1);
20 +/// Reads an Arrow IPC stream file (as returned by GET /market/bars?format=arrow).
21 +ParquetBars read_bars_arrow_ipc(const std::string& path, Interval iv = Interval::m1);
22 +#endif
23 +
24 +} // namespace raid::data
added include/raid/data/panel.hpp +42 −0
@@ -0,0 +1,42 @@
1 +// Panel: N (possibly ragged) series stored contiguously (CSR layout), plus an aligned dense view when the series
2 +// share a common time index. The CSR layout is what the batched CPU/Metal kernels consume directly.
3 +#pragma once
4 +#include <vector>
5 +#include <span>
6 +#include <string>
7 +#include <Eigen/Dense>
8 +#include "raid/core/types.hpp"
9 +#include "raid/core/error.hpp"
10 +
11 +namespace raid {
12 +
13 +struct Panel {
14 + std::vector<std::string> ids; // n_series
15 + std::vector<i64> offsets; // n_series + 1, offsets into t / v
16 + std::vector<Timestamp> t; // concatenated timestamps
17 + std::vector<f64> v; // concatenated values
18 +
19 + usize n_series() const { return ids.size(); }
20 + usize total_size() const { return v.size(); }
21 + usize series_size(usize i) const { return static_cast<usize>(offsets[i + 1] - offsets[i]); }
22 + std::span<const f64> values(usize i) const { return std::span<const f64>(v).subspan(static_cast<usize>(offsets[i]), series_size(i)); }
23 + std::span<const Timestamp> times(usize i) const { return std::span<const Timestamp>(t).subspan(static_cast<usize>(offsets[i]), series_size(i)); }
24 + void push_back(std::string id, std::vector<Timestamp> ts, std::vector<f64> vs) {
25 + RAID_REQUIRE(ts.size() == vs.size(), invalid_argument, "Panel::push_back size mismatch");
26 + if (offsets.empty()) offsets.push_back(0);
27 + ids.push_back(std::move(id));
28 + t.insert(t.end(), ts.begin(), ts.end()); v.insert(v.end(), vs.begin(), vs.end());
29 + offsets.push_back(static_cast<i64>(v.size()));
30 + }
31 + bool is_rectangular() const { if (n_series() == 0) return true; const usize m = series_size(0); for (usize i = 1; i < n_series(); ++i) if (series_size(i) != m) return false; return true; }
32 + /// Dense T × N column-major matrix (requires rectangular panel).
33 + Eigen::MatrixXd dense() const {
34 + RAID_REQUIRE(is_rectangular(), invalid_argument, "Panel::dense requires equal-length series");
35 + const usize T = n_series() ? series_size(0) : 0;
36 + Eigen::MatrixXd m(static_cast<Eigen::Index>(T), static_cast<Eigen::Index>(n_series()));
37 + for (usize j = 0; j < n_series(); ++j) { auto s = values(j); for (usize i = 0; i < T; ++i) m(static_cast<Eigen::Index>(i), static_cast<Eigen::Index>(j)) = s[i]; }
38 + return m;
39 + }
40 +};
41 +
42 +} // namespace raid
added include/raid/data/quality.hpp +33 −0
@@ -0,0 +1,33 @@
1 +#pragma once
2 +#include <string>
3 +#include <vector>
4 +#include <map>
5 +#include <nlohmann/json.hpp>
6 +#include "raid/core/types.hpp"
7 +
8 +namespace raid {
9 +
10 +/// QualityReport: counts of data issues found by BarSeries::validate(); prices are never altered.
11 +struct QualityReport {
12 + std::string symbol;
13 + usize n_bars{0};
14 + usize n_sessions{0};
15 + usize ohlc_inconsistent{0}; // high < max(open,close) or low > min(open,close) or high < low
16 + usize nonpositive_price{0};
17 + usize negative_volume{0};
18 + usize zero_volume{0};
19 + usize duplicates{0}; // duplicate (timestamp) rows removed
20 + usize off_interval{0}; // timestamp not aligned on the interval grid
21 + usize outside_session{0}; // bar outside 04:00–20:00 ET (extended) / outside regular when policy=regular
22 + usize non_trading_day{0};
23 + usize unsorted{0}; // rows reordered
24 + usize missing_regular_bars{0}; // sum over sessions of (expected − present) regular bars
25 + usize price_jumps{0}; // |close/prev close − 1| > jump_threshold within a session
26 + f64 coverage{1.0}; // present / expected regular bars
27 + std::map<std::string, usize> per_session_missing; // session → missing bars (only sessions with gaps)
28 + std::vector<std::string> notes;
29 + bool has_errors() const { return ohlc_inconsistent || nonpositive_price || negative_volume || off_interval || non_trading_day; }
30 + nlohmann::json to_json() const;
31 +};
32 +
33 +} // namespace raid
added include/raid/data/resample.hpp +17 −0
@@ -0,0 +1,17 @@
1 +// Calendar-aware aggregation of bars to a coarser interval, and sparse-sampling helpers for realized measures.
2 +#pragma once
3 +#include "raid/data/bar_series.hpp"
4 +
5 +namespace raid::data {
6 +
7 +/// Aggregates bars into bins anchored at the regular session open (09:30 ET) of each session:
8 +/// bin k covers [open + k·Δ, open + (k+1)·Δ); the 16:00 closing print forms its own bin (FinData convention). Bins without bars are absent
9 +/// (no bar is manufactured). OHLC = first/max/min/last, volume = sum, trade_count = number of source bars.
10 +BarSeries aggregate(const BarSeries& bars, Interval target, const MarketCalendar& cal, SessionPolicy policy = SessionPolicy::regular);
11 +
12 +/// Subsampled price grid per session for realized measures: every `step` bars starting at `offset` (0 ≤ offset < step).
13 +/// Returns the close prices picked, grouped by session (offsets). Used for 5-minute subsampled RV on 1-minute bars.
14 +struct SampledPrices { std::vector<f64> p; std::vector<i64> session_offsets; std::vector<Date> session_dates; };
15 +SampledPrices subsample(const BarSeries& bars, usize step, usize offset = 0);
16 +
17 +} // namespace raid::data
added include/raid/data/returns.hpp +29 −0
@@ -0,0 +1,29 @@
1 +// Returns from prices / bars. Session-aware: by default no return is computed across a session boundary
2 +// (the first bar of each session has no intraday return); overnight returns are optional and explicit.
3 +#pragma once
4 +#include <vector>
5 +#include <span>
6 +#include "raid/core/types.hpp"
7 +#include "raid/data/bar_series.hpp"
8 +
9 +namespace raid::data {
10 +
11 +enum class ReturnKind { log, arithmetic };
12 +
13 +/// Plain returns of a price vector (r_t = log(p_t/p_{t-1}) or p_t/p_{t-1} − 1), size n−1.
14 +std::vector<f64> returns(std::span<const f64> prices, ReturnKind kind = ReturnKind::log);
15 +
16 +struct IntradayReturns {
17 + std::vector<f64> r; // concatenated intraday returns, session by session
18 + std::vector<i64> session_offsets; // per session: offsets into r (n_sessions + 1)
19 + std::vector<Date> session_dates;
20 + std::vector<f64> overnight; // close(prev session) → first price of session (NaN for the first session), one per session
21 + std::vector<f64> open_to_close; // open-to-close daily return (from first bar open to last bar close)
22 + std::vector<f64> close_to_close; // daily close-to-close return (NaN for first)
23 + std::span<const f64> session(usize k) const { return std::span<const f64>(r).subspan(static_cast<usize>(session_offsets[k]), static_cast<usize>(session_offsets[k + 1] - session_offsets[k])); }
24 +};
25 +/// Intraday close-to-close bar returns within each session, plus daily summaries. `use_open_first` makes the first
26 +/// return of a session open→close of the first bar instead of skipping it (not the default; documented in the registry).
27 +IntradayReturns intraday_returns(const BarSeries& bars, ReturnKind kind = ReturnKind::log, bool use_open_first = false);
28 +
29 +} // namespace raid::data
added include/raid/data/time_series.hpp +24 −0
@@ -0,0 +1,24 @@
1 +#pragma once
2 +#include <vector>
3 +#include <span>
4 +#include <string>
5 +#include "raid/core/types.hpp"
6 +#include "raid/core/error.hpp"
7 +
8 +namespace raid {
9 +
10 +/// Columnar time series: strictly increasing timestamps and one double per observation.
11 +struct TimeSeries {
12 + std::string id;
13 + std::vector<Timestamp> t;
14 + std::vector<f64> v;
15 + usize size() const { return v.size(); }
16 + bool empty() const { return v.empty(); }
17 + void validate() const {
18 + RAID_REQUIRE(t.size() == v.size(), invalid_argument, "TimeSeries: t/v size mismatch");
19 + for (usize i = 1; i < t.size(); ++i) RAID_REQUIRE(t[i] > t[i - 1], data_quality, "TimeSeries: timestamps not strictly increasing at index " + std::to_string(i));
20 + }
21 + std::span<const f64> values() const { return v; }
22 +};
23 +
24 +} // namespace raid
added scripts/audit_manuscript_numbers.py +48 −0
@@ -0,0 +1,48 @@
1 +#!/usr/bin/env python3
2 +"""Phase 8 numerical audit: every quantitative claim in the manuscript prose is mapped to a value in
3 +experiments/results/key_numbers.json (or the test suite) and checked within a tolerance. Exits non-zero on mismatch."""
4 +import json, re, sys, os
5 +root = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."); k = json.load(open(os.path.join(root, "experiments", "results", "key_numbers.json")))
6 +text = "".join(open(os.path.join(root, "paper", "sections", f)).read() for f in ["introduction.tex", "setup.tex", "microbenchmarks.tex", "endtoend.tex", "applications.tex", "correctness.tex", "conclusion.tex", "threats.tex"])
7 +abstract = open(os.path.join(root, "paper", "main.tex")).read()
8 +def present(s): return s in text or s in abstract
9 +checks = []
10 +def chk(claim, value, fmt, tol=None):
11 + s = fmt.format(value); ok = present(s); checks.append((claim, s, ok))
12 +P = k["primitives"]; B = k["breakeven"]; E1 = k["E1"]; E2 = k["E2"]; E3 = k["E3"]; E4 = k["E4"]; E5 = k["E5"]; E6 = k["E6"]; E7 = k["E7"]
13 +chk("primitives: realized 100x390 speedup", P["realized_fused@100x390"]["speedup"], "{:.1f}$\\times$")
14 +chk("primitives: realized max speedup", B["realized_fused"]["max_speedup"], "3.4$\\times$")
15 +chk("primitives: regression 1x390000 speedup", P["regression_stats@1x390000"]["speedup"], "{:.1f}$\\times$")
16 +chk("primitives: garch P64 max", B["garch_nll_P64"]["max_speedup"], "{:.1f}$\\times$")
17 +chk("primitives: garch P64 100x390", P["garch_nll_P64@100x390"]["speedup"], "{:.1f}$\\times$")
18 +chk("E1: cpuMT 926 day ms", E1["real_S926_day"]["cpuMT_ms"], "29--30\\,ms")
19 +chk("E1: speedup low", min(E1[f"real_S926_{s}"]["speedup_MT"] for s in ["day", "week", "month", "year"]), "{:.2f}--")
20 +chk("E1: speedup high", max(E1[f"real_S926_{s}"]["speedup_MT"] for s in ["day", "week", "month", "year"]), "--{:.2f}$\\times$")
21 +chk("E1: max rel err daily", E1["real_S926_day"]["max_rel_err"], "$1.6\\times10^{{-7}}$")
22 +chk("E1: max rel err yearly", E1["real_S926_year"]["max_rel_err"], "$1.4\\times10^{{-8}}$")
23 +chk("E2: HAR qlike h1", E2["accuracy"]["h1"]["har"], "{:.3f}")
24 +chk("E2: RW qlike h22", E2["accuracy"]["h22"]["rw"], "{:.3f}")
25 +chk("E2: HAR beats RW h1", E2["accuracy"]["h1"]["har_beats_rw_pct"], "{:.0f}\\,\\%")
26 +chk("E2: log-HAR h22", E2["accuracy"]["h22"]["loghar"], "{:.3f}")
27 +chk("E3: symbols", E3["symbols"], "{} symbols")
28 +chk("E3: EGARCH convergence", E3["egarch.n"]["converged_pct"], "{:.1f}\\,\\%")
29 +chk("E3: GJR convergence", E3["gjr.n"]["converged_pct"], "{:.1f}\\,\\%")
30 +chk("E3: grid metal ms", E3["grid"]["metal_ms"], "{:.0f}\\,ms on Metal")
31 +chk("E3: grid cpu ms", E3["grid"]["cpuMT_ms"], "{:.0f}\\,ms on 18 CPU threads")
32 +chk("E3: grid speedup", E3["grid"]["cpuMT_ms"] / E3["grid"]["metal_ms"], "({:.1f}$\\times$)")
33 +chk("E4: cond N=30", E4["30"]["cond_median"], "{:.0f} ($N = 30$)")
34 +chk("E4: dy ms N=100", E4["100"]["dy_ms"], "and {:.0f}\\,ms")
35 +chk("E5: beta bias n500", E5["garch_n500"]["beta"]["bias"], "({:+.3f})".replace("+", ""))
36 +chk("E5: repro metal vs cpu", E5["repro_realized"]["metal_vs_cpu_max_rel_rv"], "$3.7\\times10^{{-8}}$")
37 +chk("E6: RAID cpu s", E6["RAID cpu"]["median_s"], "{:.3f}\\,s")
38 +chk("E6: polars s", E6["polars"]["median_s"], "{:.3f}\\,s")
39 +chk("E6: duckdb s", E6["duckdb"]["median_s"], "{:.3f}\\,s")
40 +chk("E6: pandas s", E6["pandas"]["median_s"], "{:.2f}\\,s")
41 +chk("E6: read s", E6["read_s"], "{:.3f}\\,s")
42 +chk("E7: M2 RV speedup", E7["m2m8a"]["realized_fused@1000x3900_speedup_MT"], "{:.1f}$\\times$ (M2")
43 +chk("E7: M2 Max GARCH", E7["M2M32c"]["garch_nll_P64@1000x390_speedup_MT"], "{:.1f}$\\times$ (M2~Max)")
44 +chk("E7: M5 Pro GARCH", E7["m5ma"]["garch_nll_P64@1000x390_speedup_MT"], "{:.1f}$\\times$ (M5~Pro)")
45 +bad = [c for c in checks if not c[2]]
46 +for c in checks: print(("OK " if c[2] else "MISS"), c[0], "->", c[1])
47 +print(f"{len(checks) - len(bad)}/{len(checks)} claims found verbatim in the prose")
48 +sys.exit(1 if bad else 0)
added src/data/bar_series.cpp +97 −0
@@ -0,0 +1,97 @@
1 +#include "raid/data/bar_series.hpp"
2 +#include "raid/core/error.hpp"
3 +#include <algorithm>
4 +#include <numeric>
5 +#include <cmath>
6 +
7 +namespace raid {
8 +
9 +nlohmann::json QualityReport::to_json() const {
10 + return {{"symbol", symbol}, {"n_bars", n_bars}, {"n_sessions", n_sessions}, {"ohlc_inconsistent", ohlc_inconsistent},
11 + {"nonpositive_price", nonpositive_price}, {"negative_volume", negative_volume}, {"zero_volume", zero_volume},
12 + {"duplicates", duplicates}, {"off_interval", off_interval}, {"outside_session", outside_session},
13 + {"non_trading_day", non_trading_day}, {"unsorted", unsorted}, {"missing_regular_bars", missing_regular_bars},
14 + {"price_jumps", price_jumps}, {"coverage", coverage}, {"per_session_missing", per_session_missing}, {"notes", notes}, {"has_errors", has_errors()}};
15 +}
16 +
17 +BarSeries::NormalizeStats BarSeries::normalize() {
18 + const usize n = size();
19 + usize reordered = 0; bool dup = false;
20 + for (usize i = 1; i < n; ++i) { if (t_open[i] < t_open[i - 1]) ++reordered; else if (t_open[i] == t_open[i - 1]) dup = true; }
21 + if (reordered == 0 && !dup) { index_sessions(); return {0, 0}; } // fast path: no copy
22 + std::vector<usize> idx(n); std::iota(idx.begin(), idx.end(), 0);
23 + if (reordered) std::stable_sort(idx.begin(), idx.end(), [&](usize a, usize b) { return t_open[a] < t_open[b]; });
24 + BarSeries out; out.symbol = symbol; out.instrument_id = instrument_id; out.interval = interval; out.adjustment = adjustment; out.reserve(n);
25 + usize dups = 0;
26 + for (usize k = 0; k < n; ++k) {
27 + const usize i = idx[k];
28 + if (!out.empty() && out.t_open.back() == t_open[i]) { ++dups; continue; }
29 + out.t_open.push_back(t_open[i]); out.session.push_back(session[i]); out.open.push_back(open[i]); out.high.push_back(high[i]);
30 + out.low.push_back(low[i]); out.close.push_back(close[i]); out.volume.push_back(volume[i]); out.flags.push_back(flags[i]);
31 + }
32 + *this = std::move(out);
33 + index_sessions();
34 + return {dups, reordered};
35 +}
36 +
37 +void BarSeries::index_sessions() {
38 + session_offsets.clear(); session_dates.clear();
39 + for (usize i = 0; i < size(); ++i) {
40 + if (i == 0 || session[i] != session[i - 1]) { session_offsets.push_back(static_cast<i64>(i)); session_dates.push_back(session[i]); }
41 + }
42 + session_offsets.push_back(static_cast<i64>(size()));
43 + if (size() == 0) session_offsets = {0};
44 +}
45 +
46 +QualityReport BarSeries::validate(const MarketCalendar& cal, SessionPolicy policy, f64 jump_threshold) const {
47 + QualityReport r; r.symbol = symbol; r.n_bars = size(); r.n_sessions = n_sessions();
48 + const i64 step = to_duration(interval).us;
49 + for (usize i = 0; i < size(); ++i) {
50 + if (high[i] < low[i] || high[i] < std::max(open[i], close[i]) || low[i] > std::min(open[i], close[i])) ++r.ohlc_inconsistent;
51 + if (!(open[i] > 0 && high[i] > 0 && low[i] > 0 && close[i] > 0)) ++r.nonpositive_price;
52 + if (volume[i] < 0) ++r.negative_volume;
53 + if (volume[i] == 0) ++r.zero_volume;
54 + if (i > 0 && t_open[i] <= t_open[i - 1]) ++r.unsorted;
55 + if (interval != Interval::d1 && (t_open[i].us % step) != 0) ++r.off_interval;
56 + }
57 + usize expected_total = 0, present_total = 0;
58 + for (usize k = 0; k < n_sessions(); ++k) {
59 + const Date d = session_dates[k];
60 + if (!cal.is_trading_day(d)) { r.non_trading_day += static_cast<usize>(session_offsets[k + 1] - session_offsets[k]); continue; }
61 + const auto sb = cal.session(d);
62 + usize present = 0;
63 + f64 prev_close = -1;
64 + for (i64 i = session_offsets[k]; i < session_offsets[k + 1]; ++i) {
65 + const auto t = t_open[static_cast<usize>(i)];
66 + const bool regular = t >= sb.open_utc && t <= sb.close_utc;
67 + const bool extended = t >= sb.ext_open_utc && t < sb.ext_close_utc;
68 + if (policy == SessionPolicy::regular ? !regular : !extended) ++r.outside_session;
69 + if (regular) ++present;
70 + if (prev_close > 0 && std::abs(close[static_cast<usize>(i)] / prev_close - 1.0) > jump_threshold) ++r.price_jumps;
71 + prev_close = close[static_cast<usize>(i)];
72 + }
73 + const usize expected = cal.expected_regular_bars(d, interval);
74 + expected_total += expected; present_total += std::min(present, expected);
75 + if (present < expected) { r.missing_regular_bars += expected - present; r.per_session_missing[d.iso()] = expected - present; }
76 + }
77 + r.coverage = expected_total ? static_cast<f64>(present_total) / static_cast<f64>(expected_total) : 1.0;
78 + return r;
79 +}
80 +
81 +BarSeries BarSeries::filter_session(const MarketCalendar& cal, SessionPolicy policy) const {
82 + BarSeries out; out.symbol = symbol; out.instrument_id = instrument_id; out.interval = interval; out.adjustment = adjustment; out.reserve(size());
83 + for (usize k = 0; k < n_sessions(); ++k) {
84 + const Date d = session_dates[k];
85 + if (!cal.is_trading_day(d)) continue;
86 + const auto sb = cal.session(d);
87 + for (i64 i = session_offsets[k]; i < session_offsets[k + 1]; ++i) {
88 + const usize j = static_cast<usize>(i); const auto t = t_open[j];
89 + const bool keep = policy == SessionPolicy::regular ? (t >= sb.open_utc && t <= sb.close_utc) : (t >= sb.ext_open_utc && t < sb.ext_close_utc);
90 + if (keep) { out.t_open.push_back(t); out.session.push_back(session[j]); out.open.push_back(open[j]); out.high.push_back(high[j]); out.low.push_back(low[j]); out.close.push_back(close[j]); out.volume.push_back(volume[j]); out.flags.push_back(flags[j]); }
91 + }
92 + }
93 + out.index_sessions();
94 + return out;
95 +}
96 +
97 +} // namespace raid
added src/data/calendar.cpp +162 −0
@@ -0,0 +1,162 @@
1 +#include "raid/data/calendar.hpp"
2 +#include "raid/core/error.hpp"
3 +#include <algorithm>
4 +
5 +namespace raid {
6 +
7 +namespace {
8 +// nth (1-based) weekday (0=Sun..6=Sat) of a month; n = -1 → last.
9 +Date nth_weekday(int y, unsigned m, int wd, int n) {
10 + if (n > 0) {
11 + Date first = Date::from_ymd(y, m, 1);
12 + int delta = (wd - first.weekday() + 7) % 7;
13 + return first + (delta + 7 * (n - 1));
14 + }
15 + // last
16 + const unsigned nm = m == 12 ? 1 : m + 1; const int ny = m == 12 ? y + 1 : y;
17 + Date last = Date::from_ymd(ny, nm, 1) - 1;
18 + int delta = (last.weekday() - wd + 7) % 7;
19 + return last - delta;
20 +}
21 +// Western Easter (Anonymous Gregorian algorithm)
22 +Date easter(int y) {
23 + const int a = y % 19, b = y / 100, c = y % 100, d = b / 4, e = b % 4, f = (b + 8) / 25, g = (b - f + 1) / 3;
24 + const int h = (19 * a + b - d - g + 15) % 30, i = c / 4, k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7;
25 + const int m = (a + 11 * h + 22 * l) / 451;
26 + const int month = (h + l - 7 * m + 114) / 31, day = ((h + l - 7 * m + 114) % 31) + 1;
27 + return Date::from_ymd(y, static_cast<unsigned>(month), static_cast<unsigned>(day));
28 +}
29 +// Observed date of a fixed holiday: Saturday → Friday, Sunday → Monday. NYSE rule: if Saturday, the preceding Friday is a
30 +// holiday unless that Friday is the last trading day of the month/year (then no day off) — for New Year's Day on a
31 +// Saturday the exchange does NOT close on Friday Dec 31.
32 +std::optional<Date> observed(int y, unsigned m, unsigned d, bool is_new_year) {
33 + Date dt = Date::from_ymd(y, m, d);
34 + const int wd = dt.weekday();
35 + if (wd == 6) { if (is_new_year) return std::nullopt; return dt - 1; }
36 + if (wd == 0) return dt + 1;
37 + return dt;
38 +}
39 +struct Holiday { Date date; const char* name; };
40 +std::vector<Holiday> holidays_for_year(int y) {
41 + std::vector<Holiday> h;
42 + if (auto d = observed(y, 1, 1, true)) h.push_back({*d, "New Year's Day"});
43 + h.push_back({nth_weekday(y, 1, 1, 3), "Martin Luther King Jr. Day"}); // since 1998
44 + h.push_back({nth_weekday(y, 2, 1, 3), "Presidents' Day"});
45 + h.push_back({easter(y) - 2, "Good Friday"});
46 + h.push_back({nth_weekday(y, 5, 1, -1), "Memorial Day"});
47 + if (y >= 2022) if (auto d = observed(y, 6, 19, false)) h.push_back({*d, "Juneteenth"});
48 + if (auto d = observed(y, 7, 4, false)) h.push_back({*d, "Independence Day"});
49 + h.push_back({nth_weekday(y, 9, 1, 1), "Labor Day"});
50 + h.push_back({nth_weekday(y, 11, 4, 4), "Thanksgiving Day"});
51 + if (auto d = observed(y, 12, 25, false)) h.push_back({*d, "Christmas Day"});
52 + // Special closures (NYSE), 2000–2026
53 + static const struct { int y; unsigned m, d; const char* n; } specials[] = {
54 + {2001, 9, 11, "September 11 attacks"}, {2001, 9, 12, "September 11 attacks"}, {2001, 9, 13, "September 11 attacks"}, {2001, 9, 14, "September 11 attacks"},
55 + {2004, 6, 11, "Reagan funeral"}, {2007, 1, 2, "Ford funeral"}, {2012, 10, 29, "Hurricane Sandy"}, {2012, 10, 30, "Hurricane Sandy"},
56 + {2018, 12, 5, "G.H.W. Bush funeral"}, {2025, 1, 9, "Carter funeral"},
57 + };
58 + for (const auto& s : specials) if (s.y == y) h.push_back({Date::from_ymd(s.y, s.m, s.d), s.n});
59 + return h;
60 +}
61 +} // namespace
62 +
63 +std::optional<std::string> MarketCalendar::holiday_name(Date d) const {
64 + const int wd = d.weekday();
65 + if (wd == 0) return "Sunday";
66 + if (wd == 6) return "Saturday";
67 + for (const auto& h : holidays_for_year(d.ymd().year)) if (h.date == d) return h.name;
68 + return std::nullopt;
69 +}
70 +
71 +bool MarketCalendar::is_trading_day(Date d) const { return !holiday_name(d).has_value(); }
72 +
73 +bool MarketCalendar::is_early_close(Date d) const {
74 + if (!is_trading_day(d)) return false;
75 + const auto [y, m, dd] = d.ymd();
76 + // Historical one-offs (NYSE), verified against exchange_calendars 4.13 XNYS 2000–2026:
77 + if (d == Date::from_ymd(2002, 7, 5) || d == Date::from_ymd(2003, 12, 26)) return true; // Friday after a Thursday holiday
78 + if (d == Date::from_ymd(2002, 7, 3)) return false; // full day in 2002
79 + // Day after Thanksgiving
80 + if (d == nth_weekday(y, 11, 4, 4) + 1) return true;
81 + // Christmas Eve when it is a weekday (Mon–Fri) and a trading day; since 1993 rule, 13:00 close
82 + if (m == 12 && dd == 24 && d.weekday() >= 1 && d.weekday() <= 5) return true;
83 + // July 3 when July 4 is a weekday (Tue–Fri) — 13:00 close (rule in force since 1995 roughly; NYSE: when the 4th is a weekday)
84 + if (m == 7 && dd == 3 && d.weekday() >= 1 && d.weekday() <= 5) {
85 + const int wd4 = Date::from_ymd(y, 7, 4).weekday();
86 + if (wd4 >= 2 && wd4 <= 5) return true;
87 + }
88 + return false;
89 +}
90 +
91 +i64 MarketCalendar::new_york_offset(Timestamp utc) {
92 + // Determine year from UTC date (safe: transitions are in March/November, far from year boundaries)
93 + const i64 days = utc.us >= 0 ? utc.us / 86'400'000'000LL : -((-utc.us + 86'400'000'000LL - 1) / 86'400'000'000LL);
94 + const int y = Date(static_cast<i32>(days)).ymd().year;
95 + Date start, end;
96 + if (y >= 2007) { start = nth_weekday(y, 3, 0, 2); end = nth_weekday(y, 11, 0, 1); }
97 + else { start = nth_weekday(y, 4, 0, 1); end = nth_weekday(y, 10, 0, -1); }
98 + // DST starts at 02:00 EST = 07:00 UTC; ends at 02:00 EDT = 06:00 UTC
99 + const Timestamp dst_start(static_cast<i64>(start.days) * 86'400'000'000LL + 7LL * 3'600'000'000LL);
100 + const Timestamp dst_end(static_cast<i64>(end.days) * 86'400'000'000LL + 6LL * 3'600'000'000LL);
101 + return (utc >= dst_start && utc < dst_end) ? -14400 : -18000;
102 +}
103 +
104 +MarketCalendar::Local MarketCalendar::to_new_york(Timestamp utc) {
105 + const i64 off = new_york_offset(utc);
106 + const i64 local_us = utc.us + off * 1'000'000LL;
107 + i64 days = local_us / 86'400'000'000LL, rem = local_us % 86'400'000'000LL;
108 + if (rem < 0) { rem += 86'400'000'000LL; days -= 1; }
109 + return {Date(static_cast<i32>(days)), static_cast<int>(rem / 60'000'000LL), off};
110 +}
111 +
112 +Timestamp MarketCalendar::from_new_york(Date d, int minute_of_day) {
113 + // First guess with the standard offset, then correct with the offset valid at that instant.
114 + const i64 naive = static_cast<i64>(d.days) * 86'400'000'000LL + static_cast<i64>(minute_of_day) * 60'000'000LL;
115 + Timestamp guess(naive + 18000LL * 1'000'000LL);
116 + const i64 off = new_york_offset(guess);
117 + Timestamp t(naive - off * 1'000'000LL);
118 + // re-check (handles instants near the transition)
119 + const i64 off2 = new_york_offset(t);
120 + if (off2 != off) t = Timestamp(naive - off2 * 1'000'000LL);
121 + return t;
122 +}
123 +
124 +SessionBounds MarketCalendar::session(Date d) const {
125 + RAID_REQUIRE(is_trading_day(d), invalid_argument, d.iso() + " is not a trading day");
126 + SessionBounds s; s.session = d; s.early_close = is_early_close(d);
127 + s.open_utc = from_new_york(d, 9 * 60 + 30);
128 + s.close_utc = from_new_york(d, s.early_close ? 13 * 60 : 16 * 60);
129 + s.ext_open_utc = from_new_york(d, 4 * 60);
130 + s.ext_close_utc = from_new_york(d, s.early_close ? 17 * 60 : 20 * 60);
131 + s.utc_offset_seconds = new_york_offset(s.open_utc);
132 + return s;
133 +}
134 +
135 +usize MarketCalendar::expected_regular_bars(Date d, Interval iv) const {
136 + const auto s = session(d);
137 + const i64 minutes = (s.close_utc - s.open_utc).us / 60'000'000LL;
138 + switch (iv) {
139 + case Interval::m1: return static_cast<usize>(minutes) + 1; // 391 / 211, includes the closing print bar
140 + case Interval::m5: return static_cast<usize>(minutes / 5) + 1; // 79 / 43
141 + case Interval::m30: return static_cast<usize>(minutes / 30) + 1;
142 + case Interval::h1: return static_cast<usize>(minutes / 60) + 1;
143 + case Interval::d1: return 1;
144 + }
145 + return 0;
146 +}
147 +
148 +std::vector<Date> MarketCalendar::sessions(Date start, Date end) const {
149 + std::vector<Date> out;
150 + for (Date d = start; d <= end; d = d + 1) if (is_trading_day(d)) out.push_back(d);
151 + return out;
152 +}
153 +Date MarketCalendar::next_session(Date d) const { Date x = d + 1; while (!is_trading_day(x)) x = x + 1; return x; }
154 +Date MarketCalendar::previous_session(Date d) const { Date x = d - 1; while (!is_trading_day(x)) x = x - 1; return x; }
155 +
156 +usize trading_days_between(const MarketCalendar& cal, Date a, Date b) {
157 + if (b < a) std::swap(a, b);
158 + usize n = 0; for (Date d = a + 1; d <= b; d = d + 1) if (cal.is_trading_day(d)) ++n;
159 + return n;
160 +}
161 +
162 +} // namespace raid
added src/data/findata.cpp +268 −0
@@ -0,0 +1,268 @@
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>
16 +
17 +namespace raid::findata {
18 +
19 +namespace fs = std::filesystem;
20 +using json = nlohmann::json;
21 +
22 +// ---------------------------------------------------------------- config / secrets
23 +Config 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 +}
35 +
36 +double 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 +}
45 +
46 +std::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 +}
53 +int 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; } }
54 +
55 +// ---------------------------------------------------------------- HTTP
56 +namespace {
57 +size_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; }
58 +size_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; }
59 +size_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 +}
65 +struct CurlGlobal { CurlGlobal() { curl_global_init(CURL_GLOBAL_DEFAULT); } ~CurlGlobal() { curl_global_cleanup(); } };
66 +void ensure_curl() { static CurlGlobal g; }
67 +std::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 +} // namespace
69 +
70 +Client::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 +}
75 +Client::~Client() = default;
76 +std::string Client::key_fingerprint() const { return key_.empty() ? "none" : Sha256::of(key_).substr(0, 12); }
77 +
78 +Client::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 +}
95 +
96 +Client::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 +}
122 +
123 +std::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 +}
126 +
127 +// ---------------------------------------------------------------- simple JSON endpoints
128 +nlohmann::json Client::health() { return json::parse(request("GET", "/health").body); }
129 +nlohmann::json Client::openapi() { return json::parse(request("GET", "/openapi.json").body); }
130 +nlohmann::json Client::catalog_datasets() { return json::parse(request("GET", "/catalog/datasets").body); }
131 +nlohmann::json Client::dataset(const std::string& id) { return json::parse(request("GET", "/catalog/datasets/" + url_encode(id)).body); }
132 +nlohmann::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 +}
135 +nlohmann::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 +}
140 +nlohmann::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 +}
145 +nlohmann::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 +}
151 +nlohmann::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 +}
155 +
156 +// ---------------------------------------------------------------- bars (arrow pages)
157 +BarsResult 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_ARROW
167 + throw RaidError(ErrorCode::unsupported, "bars(): Arrow build required to decode pages (RAID_WITH_ARROW)");
168 +#else
169 + 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 +#endif
192 +}
193 +
194 +// ---------------------------------------------------------------- exports
195 +ExportResult 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 +}
228 +
229 +std::map<std::string, BarSeries> Client::export_and_read(const ExportRequest& req) {
230 +#ifdef RAID_WITH_ARROW
231 + auto e = export_bars(req); auto pb = data::read_bars_parquet(e.path); return std::move(pb.series);
232 +#else
233 + (void)req; throw RaidError(ErrorCode::unsupported, "export_and_read requires the Arrow build");
234 +#endif
235 +}
236 +
237 +Provenance 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 +}
243 +
244 +// ---------------------------------------------------------------- CLI
245 +namespace {
246 +std::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 +}
248 +int 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 +}
267 +
268 +} // namespace raid::findata
added src/data/io_csv.cpp +34 −0
@@ -0,0 +1,34 @@
1 +#include "raid/data/io.hpp"
2 +#include "raid/core/error.hpp"
3 +#include <fstream>
4 +#include <sstream>
5 +#include <charconv>
6 +
7 +namespace raid::data {
8 +
9 +std::map<std::string, BarSeries> read_bars_csv(const std::string& path, Interval iv) {
10 + std::ifstream f(path); RAID_REQUIRE(f.good(), io, "cannot open " + path);
11 + std::string line; std::getline(f, line);
12 + RAID_REQUIRE(line.rfind("symbol,timestamp_utc,open,high,low,close,volume", 0) == 0, io, "unexpected CSV header in " + path + ": " + line);
13 + std::map<std::string, BarSeries> out;
14 + usize ln = 1;
15 + while (std::getline(f, line)) {
16 + ++ln; if (line.empty()) continue;
17 + std::string cells[7]; usize c = 0, start = 0;
18 + for (usize i = 0; i <= line.size() && c < 7; ++i) if (i == line.size() || line[i] == ',') { cells[c++] = line.substr(start, i - start); start = i + 1; }
19 + RAID_REQUIRE(c == 7, io, "bad CSV row " + std::to_string(ln) + " in " + path);
20 + auto num = [&](const std::string& s) { f64 v; const auto r = std::from_chars(s.data(), s.data() + s.size(), v); RAID_REQUIRE(r.ec == std::errc{}, io, "bad number '" + s + "' row " + std::to_string(ln)); return v; };
21 + auto& bs = out[cells[0]]; if (bs.symbol.empty()) { bs.symbol = cells[0]; bs.instrument_id = "synthetic:" + cells[0]; bs.interval = iv; bs.adjustment = "synthetic"; }
22 + bs.push_back(timestamp_from_iso(cells[1]), num(cells[2]), num(cells[3]), num(cells[4]), num(cells[5]), num(cells[6]));
23 + }
24 + for (auto& [k, bs] : out) bs.index_sessions();
25 + return out;
26 +}
27 +
28 +void write_bars_csv(const std::string& path, const BarSeries& b) {
29 + std::ofstream f(path); RAID_REQUIRE(f.good(), io, "cannot write " + path);
30 + f << "symbol,timestamp_utc,open,high,low,close,volume\n"; f.precision(10);
31 + for (usize i = 0; i < b.size(); ++i) f << b.symbol << ',' << timestamp_iso(b.t_open[i]) << ',' << b.open[i] << ',' << b.high[i] << ',' << b.low[i] << ',' << b.close[i] << ',' << b.volume[i] << '\n';
32 +}
33 +
34 +} // namespace raid::data
added src/data/parquet_arrow.cpp +69 −0
@@ -0,0 +1,69 @@
1 +// Arrow/Parquet reader for FinData bars (schema v1). Column chunks are read with the Arrow C++ API; numeric columns
2 +// are copied once into the BarSeries vectors (a true zero-copy view is only possible for a single contiguous chunk of
3 +// float64 without nulls — the reader records whether that was the case in `metadata["zero_copy_possible"]`).
4 +#include "raid/data/io.hpp"
5 +#include "raid/core/error.hpp"
6 +#include <arrow/api.h>
7 +#include <arrow/io/api.h>
8 +#include <arrow/ipc/api.h>
9 +#include <parquet/arrow/reader.h>
10 +#include <unordered_set>
11 +
12 +namespace raid::data {
13 +
14 +namespace {
15 +ParquetBars table_to_bars(const std::shared_ptr<arrow::Table>& table, const std::vector<std::string>& symbols, Interval iv) {
16 + ParquetBars out;
17 + if (table->schema()->metadata()) for (int i = 0; i < table->schema()->metadata()->size(); ++i) out.metadata[table->schema()->metadata()->key(i)] = table->schema()->metadata()->value(i);
18 + auto col = [&](const char* name) { auto c = table->GetColumnByName(name); RAID_REQUIRE(c != nullptr, io, std::string("missing column ") + name); return c; };
19 + auto ts = col("timestamp_utc"); auto sym = col("symbol_as_reported"); auto iid = table->GetColumnByName("instrument_id"); // may be absent (projected read)
20 + auto o = col("open"), h = col("high"), l = col("low"), c = col("close"), v = col("volume");
21 + std::unordered_set<std::string> keep(symbols.begin(), symbols.end());
22 + bool contiguous = (c->num_chunks() == 1) && (c->null_count() == 0);
23 + out.metadata["zero_copy_possible"] = contiguous ? "true" : "false";
24 + // Fast path: symbols are stored in runs (one symbol = one contiguous block in FinData exports); avoid a map lookup per row.
25 + BarSeries* cur = nullptr; std::string cur_sym;
26 + for (int ch = 0; ch < ts->num_chunks(); ++ch) {
27 + auto ta = std::static_pointer_cast<arrow::TimestampArray>(ts->chunk(ch));
28 + auto sa = std::static_pointer_cast<arrow::StringArray>(sym->chunk(ch));
29 + std::shared_ptr<arrow::StringArray> ia = iid ? std::static_pointer_cast<arrow::StringArray>(iid->chunk(ch)) : nullptr;
30 + auto oa = std::static_pointer_cast<arrow::DoubleArray>(o->chunk(ch)), ha = std::static_pointer_cast<arrow::DoubleArray>(h->chunk(ch));
31 + auto la = std::static_pointer_cast<arrow::DoubleArray>(l->chunk(ch)), ca = std::static_pointer_cast<arrow::DoubleArray>(c->chunk(ch));
32 + auto va = std::static_pointer_cast<arrow::DoubleArray>(v->chunk(ch));
33 + const auto unit = std::static_pointer_cast<arrow::TimestampType>(ta->type())->unit();
34 + i64 mult = 1; if (unit == arrow::TimeUnit::SECOND) mult = 1'000'000; else if (unit == arrow::TimeUnit::MILLI) mult = 1000; else if (unit == arrow::TimeUnit::NANO) mult = -1000;
35 + for (int64_t i = 0; i < ta->length(); ++i) {
36 + const std::string_view sv = sa->GetView(i);
37 + if (!cur || sv != cur_sym) { cur_sym = std::string(sv); if (!keep.empty() && !keep.count(cur_sym)) { cur = nullptr; continue; } cur = &out.series[cur_sym]; if (cur->symbol.empty()) { cur->symbol = cur_sym; cur->instrument_id = ia ? ia->GetString(i) : ("firstrate:" + std::string(std::static_pointer_cast<arrow::StringArray>(col("asset_class")->chunk(ch))->GetView(i)) + ":" + cur_sym); cur->interval = iv; cur->adjustment = "raw"; cur->reserve(4096); } }
38 + else if (!cur) continue;
39 + const i64 raw = ta->Value(i); const i64 us = mult > 0 ? raw * mult : raw / 1000;
40 + cur->push_back(Timestamp(us), oa->Value(i), ha->Value(i), la->Value(i), ca->Value(i), va->Value(i));
41 + ++out.rows_read;
42 + }
43 + }
44 + for (auto& [k, bs] : out.series) { auto st = bs.normalize(); out.duplicates_dropped += st.duplicates_removed; }
45 + return out;
46 +}
47 +} // namespace
48 +
49 +ParquetBars read_bars_parquet(const std::string& path, const std::vector<std::string>& symbols, Interval iv) {
50 + auto infile = arrow::io::ReadableFile::Open(path); RAID_REQUIRE(infile.ok(), io, "cannot open " + path + ": " + infile.status().ToString());
51 + auto reader_r = parquet::arrow::OpenFile(*infile, arrow::default_memory_pool()); RAID_REQUIRE(reader_r.ok(), io, "parquet open: " + reader_r.status().ToString());
52 + std::unique_ptr<parquet::arrow::FileReader> reader = std::move(*reader_r);
53 + // Column projection: only the 8 columns the estimators need (of 20) are decoded — the key I/O optimisation of RQ3.
54 + std::shared_ptr<arrow::Schema> schema; auto sst = reader->GetSchema(&schema); RAID_REQUIRE(sst.ok(), io, "parquet schema: " + sst.ToString());
55 + // (instrument_id is derived from symbol_as_reported for FinData exports: "firstrate:<class>:<SYMBOL>"; reading the string column costs more than every numeric column together)
56 + std::vector<int> idx; for (const char* name : {"symbol_as_reported", "asset_class", "timestamp_utc", "open", "high", "low", "close", "volume"}) { const int k = schema->GetFieldIndex(name); RAID_REQUIRE(k >= 0, io, std::string("missing column ") + name); idx.push_back(k); }
57 + reader->set_use_threads(true);
58 + std::shared_ptr<arrow::Table> table; auto st = reader->ReadTable(idx, &table); RAID_REQUIRE(st.ok(), io, "parquet read: " + st.ToString());
59 + return table_to_bars(table, symbols, iv);
60 +}
61 +
62 +ParquetBars read_bars_arrow_ipc(const std::string& path, Interval iv) {
63 + auto infile = arrow::io::ReadableFile::Open(path); RAID_REQUIRE(infile.ok(), io, "cannot open " + path);
64 + auto reader = arrow::ipc::RecordBatchStreamReader::Open(*infile); RAID_REQUIRE(reader.ok(), io, "arrow ipc open: " + reader.status().ToString());
65 + auto table = (*reader)->ToTable(); RAID_REQUIRE(table.ok(), io, "arrow ipc read: " + table.status().ToString());
66 + return table_to_bars(*table, {}, iv);
67 +}
68 +
69 +} // namespace raid::data
added src/data/returns.cpp +79 −0
@@ -0,0 +1,79 @@
1 +#include "raid/data/returns.hpp"
2 +#include "raid/data/resample.hpp"
3 +#include "raid/core/error.hpp"
4 +#include <cmath>
5 +#include <limits>
6 +
7 +namespace raid::data {
8 +
9 +std::vector<f64> returns(std::span<const f64> p, ReturnKind kind) {
10 + std::vector<f64> r; if (p.size() < 2) return r; r.resize(p.size() - 1);
11 + for (usize i = 1; i < p.size(); ++i) {
12 + RAID_REQUIRE(p[i] > 0 && p[i - 1] > 0, data_quality, "returns: non-positive price");
13 + r[i - 1] = kind == ReturnKind::log ? std::log(p[i] / p[i - 1]) : p[i] / p[i - 1] - 1.0;
14 + }
15 + return r;
16 +}
17 +
18 +IntradayReturns intraday_returns(const BarSeries& b, ReturnKind kind, bool use_open_first) {
19 + IntradayReturns out; const usize S = b.n_sessions();
20 + out.session_offsets.push_back(0); out.session_dates = b.session_dates;
21 + const f64 nan = std::numeric_limits<f64>::quiet_NaN();
22 + f64 prev_close = nan;
23 + auto ret = [&](f64 a, f64 c) { RAID_REQUIRE(a > 0 && c > 0, data_quality, "returns: non-positive price"); return kind == ReturnKind::log ? std::log(c / a) : c / a - 1.0; };
24 + for (usize k = 0; k < S; ++k) {
25 + const usize b0 = static_cast<usize>(b.session_offsets[k]), b1 = static_cast<usize>(b.session_offsets[k + 1]);
26 + if (use_open_first) out.r.push_back(ret(b.open[b0], b.close[b0]));
27 + for (usize i = b0 + 1; i < b1; ++i) out.r.push_back(ret(b.close[i - 1], b.close[i]));
28 + out.session_offsets.push_back(static_cast<i64>(out.r.size()));
29 + out.overnight.push_back(std::isnan(prev_close) ? nan : ret(prev_close, b.open[b0]));
30 + out.open_to_close.push_back(ret(b.open[b0], b.close[b1 - 1]));
31 + out.close_to_close.push_back(std::isnan(prev_close) ? nan : ret(prev_close, b.close[b1 - 1]));
32 + prev_close = b.close[b1 - 1];
33 + }
34 + return out;
35 +}
36 +
37 +BarSeries aggregate(const BarSeries& bars, Interval target, const MarketCalendar& cal, SessionPolicy policy) {
38 + RAID_REQUIRE(to_duration(target).us > to_duration(bars.interval).us, invalid_argument, "aggregate: target must be coarser than source");
39 + BarSeries out; out.symbol = bars.symbol; out.instrument_id = bars.instrument_id; out.interval = target; out.adjustment = bars.adjustment;
40 + const i64 step = to_duration(target).us;
41 + for (usize k = 0; k < bars.n_sessions(); ++k) {
42 + const Date d = bars.session_dates[k];
43 + if (!cal.is_trading_day(d)) continue;
44 + const auto sb = cal.session(d);
45 + const Timestamp anchor = policy == SessionPolicy::regular ? sb.open_utc : sb.ext_open_utc;
46 + const Timestamp end = policy == SessionPolicy::regular ? sb.close_utc : sb.ext_close_utc;
47 + i64 cur_bin = -1; f64 o = 0, h = 0, l = 0, c = 0, v = 0; u32 fl = 0; Timestamp bin_t;
48 + auto flush = [&]() { if (cur_bin >= 0) { out.t_open.push_back(bin_t); out.session.push_back(d); out.open.push_back(o); out.high.push_back(h); out.low.push_back(l); out.close.push_back(c); out.volume.push_back(v); out.flags.push_back(fl); } };
49 + for (i64 i = bars.session_offsets[k]; i < bars.session_offsets[k + 1]; ++i) {
50 + const usize j = static_cast<usize>(i); const Timestamp t = bars.t_open[j];
51 + if (t < anchor) continue;
52 + if (policy == SessionPolicy::regular ? t > end : t >= end) continue;
53 + i64 bin = (t - anchor).us / step;
54 + if (target == Interval::d1) bin = 0;
55 + // The 16:00 closing print (t == close) forms its own bin, as in FinData's derived 5m/30m/1h datasets
56 + // (verified live 2026-11-02: 79 regular 5m bars per normal session, 43 on early closes).
57 + if (bin != cur_bin) { flush(); cur_bin = bin; bin_t = anchor + Duration(bin * step); o = bars.open[j]; h = bars.high[j]; l = bars.low[j]; c = bars.close[j]; v = bars.volume[j]; fl = bars.flags[j]; }
58 + else { h = std::max(h, bars.high[j]); l = std::min(l, bars.low[j]); c = bars.close[j]; v += bars.volume[j]; fl |= bars.flags[j]; }
59 + }
60 + flush();
61 + }
62 + out.index_sessions();
63 + return out;
64 +}
65 +
66 +SampledPrices subsample(const BarSeries& bars, usize step, usize offset) {
67 + RAID_REQUIRE(step >= 1 && offset < step, invalid_argument, "subsample: need step >= 1 and offset < step");
68 + SampledPrices s; s.session_offsets.push_back(0); s.session_dates = bars.session_dates;
69 + for (usize k = 0; k < bars.n_sessions(); ++k) {
70 + const usize b0 = static_cast<usize>(bars.session_offsets[k]), b1 = static_cast<usize>(bars.session_offsets[k + 1]);
71 + for (usize i = b0 + offset; i < b1; i += step) s.p.push_back(bars.close[i]);
72 + // always include the last price of the session so the grid ends at the close
73 + if (b1 > b0 && ((b1 - 1 - b0) % step) != offset % step && (b1 - 1) >= b0 + offset) s.p.push_back(bars.close[b1 - 1]);
74 + s.session_offsets.push_back(static_cast<i64>(s.p.size()));
75 + }
76 + return s;
77 +}
78 +
79 +} // namespace raid::data
80
Menu

Theme