diff --git a/README.md b/README.md index e44252c..51afcdd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A low-latency C++20 research and execution engine for Polymarket's CLOB V2. It combines authenticated alpha ingress, an L2 WebSocket book, exact fixed-point order construction, EIP-712 signing, a consumable pre-signed ladder, bounded SPSC queues, risk gates, and asynchronous HTTPS submission. -> **Deployment status:** offline and mock paths are tested. This repository is **not approved for unattended live trading**: private-channel fill/order reconciliation and automatic inventory recovery are still missing, and signature type 3 intentionally fails closed until correct ERC-7739 wrapping is implemented. See [the deployment runbook](docs/DEPLOYMENT.md) and [remediation ledger](docs/REMEDIATION_STATUS.md). +> **Deployment status:** offline and mock paths are tested. The engine ships four layers over the hot path: private user-channel accounting with venue reconciliation (P1), an always-on risk manager with stop-loss/hedging/day-loss kill switch (P2), an adverse-selection volatility gate with dynamic pre-signed-ladder TTL and a >5%/100ms shock cooldown (P3), and a closed-form Bayesian signal brain (P4) whose cold-path evidence ingestion (polling/API NDJSON adapters, recalibrable source reliability) only touches the hot tick through the bounded SPSC drain — posterior update/read ≈ 41/39 ns p50, well under the 50 ns budget. `BOT_MODE=mock` is full paper trading: accepted mock orders synthesize venue fills so tracker, brakes, and brain observe real flow end to end. Signature type 3 intentionally fails closed until correct ERC-7739 wrapping is implemented. See [the deployment runbook](docs/DEPLOYMENT.md) and [remediation ledger](docs/REMEDIATION_STATUS.md). ## Safety model diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 8e3e3ec..15e36b5 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -163,9 +163,19 @@ add_executable(l2_backtester ../tests/replay/l2_backtester.cpp) crowdintel_target(l2_backtester) set_target_properties(l2_backtester PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) +add_executable(test_layers ../tests/unit/test_layers.cpp) +crowdintel_target(test_layers) +if(HAVE_NETWORK) + target_compile_definitions(test_layers PRIVATE CROWDINTEL_HAVE_NETWORK=1) + target_link_libraries(test_layers PRIVATE + CURL::libcurl OpenSSL::SSL OpenSSL::Crypto) +endif() +set_target_properties(test_layers PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) + enable_testing() add_test(NAME crypto_kat COMMAND $) add_test(NAME core_units COMMAND $) +add_test(NAME layer_units COMMAND $) add_test(NAME backtester_smoke COMMAND $ ${CMAKE_CURRENT_SOURCE_DIR}/../tests/replay/sample_ticks.csv) # latency_bench is a benchmark, not a correctness test; CI invokes it explicitly. diff --git a/core/include/account_events.hpp b/core/include/account_events.hpp new file mode 100644 index 0000000..24fd86b --- /dev/null +++ b/core/include/account_events.hpp @@ -0,0 +1,48 @@ +#ifndef ACCOUNT_EVENTS_HPP +#define ACCOUNT_EVENTS_HPP + +// ───────────────────────────────────────────────────────────────────────────── +// AccountEvent: normalized user-channel (private WebSocket) account fact. +// +// One cache-line message produced ONLY by the private-WS feed thread (or the +// simulation venue) and consumed ONLY by the hot loop, which applies each +// event to the PositionTracker. This keeps the tracker single-writer and +// satisfies the repository SPSC ownership invariant: nobody touches the +// tracker directly off the hot path; facts travel through the typed queue. +// +// All quantities are fixed-point x1e6. Venue string identifiers (order id, +// trade id) are hashed to u64 so no variable-length parsing survives into +// the hot path. A FILL whose order_hash matches a tracked open order also +// reduces that order's outstanding quantity. +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include + +struct AccountEvent { + enum class Type : uint8_t { + FILL = 0, // trade matched (partial fills arrive as several FILLs) + FILL_MINED = 1, // on-chain mined/confirmed duplicate of a FILL + OPEN = 2, // order accepted/resting on the venue book + CANCEL = 3, // order cancelled (partial cancel keeps remaining) + REJECT = 4, // order rejected by the venue + FAILED = 5, // trade failed/rolled back after a prior MATCHED + }; + + Type type = Type::FILL; + uint8_t side = 0; // K_SIDE_BUY / K_SIDE_SELL (user perspective) + uint8_t asset = 0; // 0 = primary token, 1 = hedge/complement token + uint8_t reserved[5]{}; + uint64_t price = 0; // fill/order price, x1e6 + uint64_t size = 0; // quantity of THIS event, shares x1e6 + uint64_t remaining = 0; // venue-reported outstanding qty (0 = unknown) + uint64_t timestamp_ns = 0; // venue event time, realtime epoch ns + uint64_t market_hash = 0; // FNV-1a of the configured market slug + uint64_t order_hash = 0; // FNV-1a of the venue order id (0 = unknown) + uint64_t event_id = 0; // FNV-1a of trade/event id for dedup (0 = n/a) +}; + +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(AccountEvent) == 64, "one cache line per account event"); + +#endif // ACCOUNT_EVENTS_HPP diff --git a/core/include/bayesian_engine.hpp b/core/include/bayesian_engine.hpp new file mode 100644 index 0000000..f6d5a43 --- /dev/null +++ b/core/include/bayesian_engine.hpp @@ -0,0 +1,123 @@ +#ifndef BAYESIAN_ENGINE_HPP +#define BAYESIAN_ENGINE_HPP + +// ───────────────────────────────────────────────────────────────────────────── +// BayesianEngine: the closed-form signal prior/posterior (P4). +// +// One Dirichlet state over at most MAX_OUTCOMES slots — the binary Polymarket +// market uses slots 0 (YES) and 1 (NO), which is exactly the Beta-Binomial +// conjugate pair; neg-risk multi-outcome markets use additional slots. The +// prior (α, β) is built from the market mid × prior strength N0 +// (BOT_BAYES_PRIOR_STRENGTH), anchoring the posterior to the order book. +// +// Evidence flattens into the conjugate state: +// COUNT (Beta-Binomial): α_j += k·w and the complement mass (n−k)·w +// renormalized over the other slots; +// LR (log-odds shift): s += w·lr, applied to the binary odds at +// read time via p = σ(ln(α₀/α₁) + s). +// +// Constraints honoured by construction: no heap allocation, no virtual +// calls, no IO. A COUNT update is a handful of adds/multiplies (~10-20 ns +// RDTSC); a posterior read is one divide (COUNT-only state) or a divide plus +// one exp() when LR mass is present. The hot loop calls update/posterior +// inline; the cold path never touches this object. +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include + +class BayesianEngine { +public: + static constexpr size_t MAX_OUTCOMES = 8; + + // Hot: seed the prior from the current mid price. Idempotent by design: + // only the first call after construction/reset takes effect. + void ensure_prior(double mid, double prior_strength) noexcept { + if (has_prior_) return; + if (!(mid > 0.0 && mid < 1.0)) return; // book not ready yet + alpha_[0] = mid * prior_strength; + alpha_[1] = (1.0 - mid) * prior_strength; + for (size_t i = 2; i < MAX_OUTCOMES; ++i) alpha_[i] = 0.0; + log_odds_shift_ = 0.0; + has_prior_ = true; + } + + void reset() noexcept { + for (size_t i = 0; i < MAX_OUTCOMES; ++i) alpha_[i] = 0.0; + log_odds_shift_ = 0.0; + has_prior_ = false; + events_ = 0; + count_events_ = 0; + lr_events_ = 0; + } + + bool has_prior() const noexcept { return has_prior_; } + + // Beta-Binomial conjugate update. `weight_x1e6` down-weights the whole + // batch by source reliability (0 = ignored at the engine gate). + void update_count(uint8_t outcome, uint32_t n, uint32_t k, + uint32_t weight_x1e6) noexcept { + if (!has_prior_ || outcome >= MAX_OUTCOMES || n == 0 || k > n || + weight_x1e6 == 0) + return; + const double w = static_cast(weight_x1e6) * 1e-6; + alpha_[outcome] += static_cast(k) * w; + const double complement = static_cast(n - k) * w; + if (complement <= 0.0) { ++events_; ++count_events_; return; } + // Distribute the complement mass over the OTHER slots, proportional + // to their current concentration (shape-preserving renormalization). + double others = 0.0; + for (size_t i = 0; i < MAX_OUTCOMES; ++i) + if (i != outcome) others += alpha_[i]; + if (others <= 0.0) { ++events_; ++count_events_; return; } + for (size_t i = 0; i < MAX_OUTCOMES; ++i) { + if (i == outcome) continue; + alpha_[i] += complement * (alpha_[i] / others); + } + ++events_; + ++count_events_; + } + + // Log-likelihood shift: posterior odds for outcome-vs-complement are + // multiplied by exp(w·lr). Cheap accumulate; exp happens at read time. + void update_lr(int32_t lr_x1e6, uint32_t weight_x1e6) noexcept { + if (!has_prior_ || weight_x1e6 == 0 || lr_x1e6 == 0) return; + log_odds_shift_ += static_cast(lr_x1e6) * 1e-6 * + (static_cast(weight_x1e6) * 1e-6); + ++events_; + ++lr_events_; + } + + // Posterior probability of `outcome`. COUNT-only states read as a + // single divide; LR mass routes the binary state through the sigmoid. + double posterior(uint8_t outcome = 0) const noexcept { + if (!has_prior_ || outcome >= MAX_OUTCOMES) return -1.0; + double total = 0.0; + for (size_t i = 0; i < MAX_OUTCOMES; ++i) total += alpha_[i]; + if (total <= 0.0) return -1.0; + double mean = alpha_[outcome] / total; + if (log_odds_shift_ != 0.0 && + (outcome == 0 || outcome == 1) && alpha_[0] > 0.0 && + alpha_[1] > 0.0) { + const double g = std::log(alpha_[0] / alpha_[1]) + + log_odds_shift_; + const double p = 1.0 / (1.0 + std::exp(-g)); + mean = outcome == 0 ? p : 1.0 - p; + } + return mean; + } + + uint64_t events() const noexcept { return events_; } + uint64_t count_events() const noexcept { return count_events_; } + uint64_t lr_events() const noexcept { return lr_events_; } + +private: + double alpha_[MAX_OUTCOMES]{}; + double log_odds_shift_ = 0.0; + bool has_prior_ = false; + uint64_t events_ = 0; + uint64_t count_events_ = 0; + uint64_t lr_events_ = 0; +}; + +#endif // BAYESIAN_ENGINE_HPP diff --git a/core/include/evidence.hpp b/core/include/evidence.hpp new file mode 100644 index 0000000..9ec705c --- /dev/null +++ b/core/include/evidence.hpp @@ -0,0 +1,42 @@ +#ifndef EVIDENCE_HPP +#define EVIDENCE_HPP + +// ───────────────────────────────────────────────────────────────────────────── +// EvidenceEvent: one observation from an external information source feeding +// the Bayesian brain (P4). Produced ONLY by cold-path ingress (HTTP pollers, +// sports-data adapters, macro feeds, NDJSON replay) and consumed ONLY by the +// hot loop, which folds each event into the posterior. One cache-line POD — +// pushing never allocates and never blocks. +// +// Two flavors share the POD (kind): +// COUNT — Beta-Binomial evidence: `count_n` trials with `count_k` positive +// outcomes (e.g. poll: 32 respondents, 22 YES). Weighted by +// source reliability at application time. +// LR — log-likelihood-ratio evidence: posterior_odds *= exp(lr_x1e6 * +// weight / 1e6). Used by macro/sports adapters that emit a score. +// +// Fixed-point x1e6 everywhere; venue strings never survive into the hot path. +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include + +struct EvidenceEvent { + enum class Kind : uint8_t { COUNT = 0, LR = 1 }; + + Kind kind = Kind::COUNT; + uint8_t outcome = 0; // Dirichlet slot for multi-outcome markets + uint8_t neg_risk = 0; // 1 when the evidence targets the complement + uint8_t reserved = 0; + uint32_t source_id = 0; // index into SourceReliability (producer-set) + uint64_t timestamp_ns = 0; // observation time (realtime epoch ns) + uint32_t event_hash = 0; // dedup hash of source-side event id + uint32_t count_n = 0; // COUNT: trials (x1, e.g. respondents polled) + uint32_t count_k = 0; // COUNT: positive outcomes (x1) + int32_t lr_x1e6 = 0; // LR: log-likelihood ratio, x1e6 +}; + +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(EvidenceEvent) == 32, "half cache line per evidence event"); + +#endif // EVIDENCE_HPP diff --git a/core/include/journal.hpp b/core/include/journal.hpp new file mode 100644 index 0000000..6f70069 --- /dev/null +++ b/core/include/journal.hpp @@ -0,0 +1,49 @@ +#ifndef JOURNAL_HPP +#define JOURNAL_HPP + +// ───────────────────────────────────────────────────────────────────────────── +// JournalEvent: auditable record of every economically relevant hot-path +// decision. The hot loop is the only producer; a cold thread is the only +// consumer and renders NDJSON. Entries are pre-sized PODs: pushing an event +// never allocates and never blocks (drops are counted by the consumer). +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include + +struct JournalEvent { + enum class Type : uint8_t { + ACCOUNT_FILL = 0, + ACCOUNT_REJECT = 1, + RESERVATION_STALE_RELEASE = 2, + STOP_LOSS_TRIGGERED = 3, + HEDGE_TRIGGERED = 4, + KILL_SWITCH = 5, + RECONCILE_DRIFT = 6, + VOLATILITY_ENTER = 7, + VOLATILITY_EXIT = 8, + STALE_PRICE_ABORT = 9, + POOL_STALE_DROP = 10, + BAYES_UPDATE = 11, + BAYES_SIGNAL = 12, + BAYES_LOW_RELIABILITY = 13, + ORDER_ACCEPTED = 14, + ORDER_FAILED = 15, + DAY_RESET = 16, + }; + + Type type = Type::ORDER_FAILED; + uint8_t reserved0[7]{}; + int64_t pnl = 0; // signed x1e6 USD where applicable + uint64_t aux0 = 0; // per-type payload (e.g. price x1e6) + uint64_t aux1 = 0; // second payload (e.g. shares x1e6) + uint64_t aux2 = 0; // third payload (e.g. threshold / version) + uint64_t mono_ns = 0; // CLOCK_MONOTONIC event time + uint64_t aux3 = 0; // fourth payload (e.g. posterior x1e6) + uint64_t reserved1 = 0; +}; + +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(JournalEvent) == 64, "one cache line per journal event"); + +#endif // JOURNAL_HPP diff --git a/core/include/position_tracker.hpp b/core/include/position_tracker.hpp new file mode 100644 index 0000000..672a27f --- /dev/null +++ b/core/include/position_tracker.hpp @@ -0,0 +1,431 @@ +#ifndef POSITION_TRACKER_HPP +#define POSITION_TRACKER_HPP + +// ───────────────────────────────────────────────────────────────────────────── +// PositionTracker: authoritative in-memory inventory/P&L state. +// +// Concurrency model (repository invariant compliant): +// * Exactly ONE writer: the hot loop applies AccountEvents drained from the +// user-feed SPSC queue and manages local reservations around submit(). +// * Any number of COLD readers: a seqlock snapshot (same publication pattern +// as OrderBookL2) exposes a coherent copy for REST reconciliation and +// metrics. Cold readers never mutate. +// +// Semantics: +// * Reservations are NOT fills. A BUY reserve increments open-buy worst +// cost; only a venue FILL converts reservation into inventory. +// * FILL_MINED/CONFIRMED duplicates a venue `MATCHED` trade id; a two-epoch +// Bloom filter over event ids suppresses the duplicate safely (a false +// positive only drops a duplicate; a false negative is impossible within +// an epoch pair). +// * SELL realized P&L uses the tracked average entry (VWAP). Inconsistent +// events never underflow counters: they saturate, bump anomaly counters +// and are left for REST reconciliation to correct. +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include +#include + +#include "account_events.hpp" +#include "polymarket_order.hpp" // crowd_uint128_t / crowd_int128_t +#include "time_utils.hpp" + +class PositionTracker { +public: + static constexpr size_t OPEN_ORDER_SLOTS = 16; + + struct Snapshot { + uint64_t net_yes = 0; // primary-token inventory, shares x1e6 + uint64_t net_hedge = 0; // complement-token inventory + uint64_t yes_avg = 0; // VWAP entry of net_yes, price x1e6 + uint64_t hedge_avg = 0; + int64_t realized_pnl = 0; // signed USD x1e6 (yes + hedge books) + uint64_t open_buy = 0; // in-flight BUY quantity (reserved) + uint64_t open_sell = 0; // in-flight SELL quantity (reserved) + uint64_t open_buy_cost = 0; // worst-case cost of open BUYs, USD x1e6 + uint64_t fills = 0; + uint64_t anomalies = 0; + uint64_t orders_tracked = 0; + uint64_t updated_ns = 0; // CLOCK_MONOTONIC + uint64_t version = 0; + }; + + PositionTracker() { publish(); } + + explicit PositionTracker(uint64_t initial_yes_shares, + uint64_t initial_avg_price = 500000) { + net_yes_ = initial_yes_shares; + yes_avg_ = initial_yes_shares ? initial_avg_price : 0; + publish(); + } + + // ── Local reservations (hot writer only) ──────────────────────────────── + void reserve_buy(uint64_t qty, uint64_t price) noexcept { + if (qty == 0 || price == 0) return; + open_buy_ += qty; + const uint64_t cost = ceil_cost(qty, price); + open_buy_cost_ = cost > UINT64_MAX - open_buy_cost_ + ? UINT64_MAX : open_buy_cost_ + cost; // saturate, never wrap + publish(); + } + + void release_buy(uint64_t qty, uint64_t price) noexcept { + open_buy_ = qty >= open_buy_ ? 0 : open_buy_ - qty; + const uint64_t cost = ceil_cost(qty, price); + open_buy_cost_ = cost >= open_buy_cost_ ? 0 : open_buy_cost_ - cost; + publish(); + } + + void reserve_sell(uint64_t qty) noexcept { + if (qty == 0) return; + open_sell_ += qty; + publish(); + } + + void release_sell(uint64_t qty) noexcept { + open_sell_ = qty >= open_sell_ ? 0 : open_sell_ - qty; + publish(); + } + + // ── Venue fact application (hot writer only) ──────────────────────────── + void apply(const AccountEvent& ev) noexcept { + if (ev.event_id != 0 && seen_before(ev.event_id)) { + ++anomalies_; // duplicate delivery (e.g. MATCHED then MINED) + publish(); + return; + } + switch (ev.type) { + case AccountEvent::Type::FILL: + apply_fill(ev.side, ev.asset, ev.price, ev.size, ev.order_hash); + ++fills_; + break; + case AccountEvent::Type::FILL_MINED: + // Same trade id already handled via dedup; a MINED with an + // unknown id is an inconsistency to reconcile, not a fill. + ++anomalies_; + break; + case AccountEvent::Type::OPEN: + track_open(ev.order_hash, ev.side, ev.asset, + ev.remaining ? ev.remaining : ev.size); + break; + case AccountEvent::Type::CANCEL: + case AccountEvent::Type::REJECT: + case AccountEvent::Type::FAILED: + close_order(ev); + break; + } + updated_ns_ = crowdintel::mono_ns(); + publish(); + } + + // Release reservation state for venue orders that produced no account + // event within the TTL (ambiguous network outcome). REST reconciliation + // remains the backstop for any divergence this heuristic creates. + size_t release_stale(uint64_t now_ns, uint64_t ttl_ns) noexcept { + if (last_stale_scan_ns_ != 0 && now_ns - last_stale_scan_ns_ < ttl_ns / 4) + return 0; + last_stale_scan_ns_ = now_ns; + size_t released = 0; + for (OpenOrder& slot : orders_) { + if (slot.hash == 0 || slot.opened_ns == 0 || + now_ns < slot.opened_ns + ttl_ns) + continue; + release_reservation_for(slot.side, slot.qty); + slot.hash = 0; + slot.qty = 0; + ++released; + } + if (released) { + ++anomalies_; + publish(); + } + return released; + } + + // ── Hot-side direct reads (same thread as the writer) ─────────────────── + uint64_t net_yes() const noexcept { return net_yes_; } + uint64_t net_hedge() const noexcept { return net_hedge_; } + uint64_t yes_avg() const noexcept { return yes_avg_; } + uint64_t hedge_avg() const noexcept { return hedge_avg_; } + int64_t realized_pnl() const noexcept { return realized_pnl_; } + uint64_t open_buy() const noexcept { return open_buy_; } + uint64_t open_sell() const noexcept { return open_sell_; } + uint64_t open_buy_cost() const noexcept { return open_buy_cost_; } + uint64_t fills() const noexcept { return fills_; } + uint64_t anomalies() const noexcept { return anomalies_; } + + uint64_t sellable() const noexcept { + return open_sell_ >= net_yes_ ? 0 : net_yes_ - open_sell_; + } + + // Worst-case committed BUY exposure: inventory at entry cost plus the + // full worst cost of in-flight BUYs. Fills move quantity from the second + // term into the first; total exposure is monotone and never double counts. + uint64_t exposure_worst_cost() const noexcept { + const uint64_t inventory_cost = mul_x1e6(net_yes_, yes_avg_); + if (open_buy_cost_ > UINT64_MAX - inventory_cost) return UINT64_MAX; + return inventory_cost + open_buy_cost_; + } + + // ── Cold readers: coherent seqlock snapshot (invariant-safe) ──────────── + bool snapshot(Snapshot& out) const noexcept { + const uint64_t s1 = seq_.load(std::memory_order_seq_cst); + if (s1 & 1U) return false; + out.net_yes = net_yes_p_.load(std::memory_order_seq_cst); + out.net_hedge = net_hedge_p_.load(std::memory_order_seq_cst); + out.yes_avg = yes_avg_p_.load(std::memory_order_seq_cst); + out.hedge_avg = hedge_avg_p_.load(std::memory_order_seq_cst); + out.realized_pnl = realized_pnl_p_.load(std::memory_order_seq_cst); + out.open_buy = open_buy_p_.load(std::memory_order_seq_cst); + out.open_sell = open_sell_p_.load(std::memory_order_seq_cst); + out.open_buy_cost = open_buy_cost_p_.load(std::memory_order_seq_cst); + out.fills = fills_p_.load(std::memory_order_seq_cst); + out.anomalies = anomalies_p_.load(std::memory_order_seq_cst); + out.orders_tracked = orders_tracked_p_.load(std::memory_order_seq_cst); + out.updated_ns = updated_ns_p_.load(std::memory_order_seq_cst); + const uint64_t s2 = seq_.load(std::memory_order_seq_cst); + if (s1 != s2 || (s2 & 1U)) return false; + out.version = s2; + return true; + } + + // Adopt the venue's authoritative inventory. Single-writer discipline: + // called ONLY by the hot loop, at startup or after popping a + // TrackerRestate from the dedicated SPSC restate queue (the cold + // reconcile thread is never a second writer of tracker state). + void override_inventory(uint64_t yes_shares, uint64_t avg_price, + uint64_t hedge_shares = 0, + uint64_t hedge_avg_price = 0) noexcept { + net_yes_ = yes_shares; + yes_avg_ = yes_shares ? avg_price : 0; + net_hedge_ = hedge_shares; + hedge_avg_ = hedge_shares ? hedge_avg_price : 0; + open_buy_ = 0; + open_sell_ = 0; + open_buy_cost_ = 0; + orders_.fill(OpenOrder{}); + publish(); + } + +private: + struct OpenOrder { + uint64_t hash = 0; + uint64_t qty = 0; + uint64_t opened_ns = 0; + uint8_t side = 0; + uint8_t asset = 0; + }; + + static uint64_t mul_x1e6(uint64_t a, uint64_t b) noexcept { + return static_cast( + static_cast(a) * b / 1000000ULL); + } + + static uint64_t ceil_cost(uint64_t qty, uint64_t price) noexcept { + const crowd_uint128_t num = static_cast(qty) * price; + return static_cast((num + 1000000ULL - 1) / 1000000ULL); + } + + // Two rotating Bloom epochs: no false negatives for recent trade ids; + // false positives only drop duplicates, which is fail-safe. + static constexpr size_t BLOOM_WORDS = 1024; // 65k bits per epoch + static constexpr size_t BLOOM_MASK = (BLOOM_WORDS * 64) - 1; + + static uint64_t dedupe_hash(uint64_t value) noexcept { + value += 0x9e3779b97f4a7c15ULL; + value = (value ^ (value >> 30)) * 0xbf58476d1ce4e5b9ULL; + value = (value ^ (value >> 27)) * 0x94d049bb133111ebULL; + return value ^ (value >> 31); + } + + bool seen_before(uint64_t id) noexcept { + static constexpr uint64_t EPOCH_NS = 30000000000ULL; // 30 s + const uint64_t epoch = crowdintel::mono_ns() / EPOCH_NS; + if (dedupe_epoch_ == 0) { + dedupe_epoch_ = epoch; + } else if (epoch > dedupe_epoch_) { + bloom_previous_ = (epoch == dedupe_epoch_ + 1) + ? bloom_current_ : std::array{}; + bloom_current_.fill(0); + dedupe_epoch_ = epoch; + } + const std::array pos = { + static_cast(dedupe_hash(id)) & BLOOM_MASK, + static_cast(dedupe_hash(id ^ 0xa0761d6478bd642fULL)) & + BLOOM_MASK, + static_cast(dedupe_hash(id ^ 0xe7037ed1a0b428dbULL)) & + BLOOM_MASK}; + bool present = true; + for (const size_t p : pos) + present &= ((bloom_current_[p >> 6] >> (p & 63U)) & 1U) == 1U || + ((bloom_previous_[p >> 6] >> (p & 63U)) & 1U) == 1U; + if (present) return true; + for (const size_t p : pos) + bloom_current_[p >> 6] |= 1ULL << (p & 63U); + return false; + } + + void track_open(uint64_t hash, uint8_t side, uint8_t asset, + uint64_t qty) noexcept { + if (hash == 0 || qty == 0) return; + for (OpenOrder& slot : orders_) { + if (slot.hash == hash) { + slot.qty = qty; + slot.opened_ns = crowdintel::mono_ns(); + return; + } + } + for (OpenOrder& slot : orders_) { + if (slot.hash == 0) { + slot.hash = hash; + slot.qty = qty; + slot.side = side; + slot.asset = asset; + slot.opened_ns = crowdintel::mono_ns(); + ++orders_tracked_plain_; + return; + } + } + ++anomalies_; // open-order table exhausted: reconcile will repair + } + + void close_order(const AccountEvent& ev) noexcept { + uint64_t qty = ev.remaining; + for (OpenOrder& slot : orders_) { + if (slot.hash != 0 && slot.hash == ev.order_hash) { + if (qty == 0) qty = slot.qty; + slot.hash = 0; + slot.qty = 0; + break; + } + } + if (qty == 0) return; + // Cancel/reject/failed only releases the reservation; rollback of a + // previously matched trade arrives as explicit venue events and any + // residue is repaired by REST reconciliation (counted as anomaly). + if (ev.type == AccountEvent::Type::FAILED) ++anomalies_; + release_reservation_for(ev.side, qty); + } + + void release_reservation_for(uint8_t side, uint64_t qty) noexcept { + if (qty == 0) return; + if (side == 0) release_buy_core(qty); + else release_sell_core(qty); + } + + // Proportionally reduce the worst-cost reservation when only the quantity + // of the released BUY is known (cancel/reject paths). Fully-flat state + // always collapses to zero: no cost residue can survive a flat book. + void release_buy_core(uint64_t qty) noexcept { + const uint64_t before = open_buy_; + open_buy_ = qty >= before ? 0 : before - qty; + open_buy_cost_ = open_buy_ == 0 ? 0 : + static_cast( + static_cast(open_buy_cost_) * open_buy_ / + before); + } + + void release_sell_core(uint64_t qty) noexcept { + open_sell_ = qty >= open_sell_ ? 0 : open_sell_ - qty; + } + + void apply_fill(uint8_t side, uint8_t asset, uint64_t price, + uint64_t qty, uint64_t order_hash) noexcept { + if (qty == 0 || price == 0) { ++anomalies_; return; } + // Consume the local reservation first (exposure moves, it is never + // double counted), then update inventory/P&L. + if (side == 0) release_buy_core(qty); + else release_sell_core(qty); + + for (OpenOrder& slot : orders_) { + if (slot.hash != 0 && slot.hash == order_hash) { + if (qty >= slot.qty) { slot.hash = 0; slot.qty = 0; } + else slot.qty -= qty; + break; + } + } + + uint64_t& net = asset == 1 ? net_hedge_ : net_yes_; + uint64_t& avg = asset == 1 ? hedge_avg_ : yes_avg_; + if (side == 0) { // BUY fill: grow inventory at VWAP + const crowd_uint128_t total = + static_cast(avg) * net + + static_cast(price) * qty; + const crowd_uint128_t new_qty = + static_cast(net) + qty; + const uint64_t new_net = static_cast(new_qty); + avg = new_net ? static_cast(total / new_qty) : 0; + net = new_net; + } else { // SELL fill: realize P&L at the tracked average + const uint64_t closing = qty > net ? net : qty; + if (qty > net) ++anomalies_; // sold more than tracked; reconcile + const crowd_int128_t diff = + static_cast(price) - + static_cast(avg); + const int64_t pnl_delta = static_cast( + diff * static_cast(closing) / + static_cast(1000000)); + if ((pnl_delta > 0 && realized_pnl_ > INT64_MAX - pnl_delta) || + (pnl_delta < 0 && realized_pnl_ < INT64_MIN - pnl_delta)) + ++anomalies_; // saturated instead of wrapping + else + realized_pnl_ += pnl_delta; + net -= closing; + if (net == 0) avg = 0; + } + } + + void publish() noexcept { + seq_.fetch_add(1, std::memory_order_seq_cst); + net_yes_p_.store(net_yes_, std::memory_order_seq_cst); + net_hedge_p_.store(net_hedge_, std::memory_order_seq_cst); + yes_avg_p_.store(yes_avg_, std::memory_order_seq_cst); + hedge_avg_p_.store(hedge_avg_, std::memory_order_seq_cst); + realized_pnl_p_.store(realized_pnl_, std::memory_order_seq_cst); + open_buy_p_.store(open_buy_, std::memory_order_seq_cst); + open_sell_p_.store(open_sell_, std::memory_order_seq_cst); + open_buy_cost_p_.store(open_buy_cost_, std::memory_order_seq_cst); + fills_p_.store(fills_, std::memory_order_seq_cst); + anomalies_p_.store(anomalies_, std::memory_order_seq_cst); + orders_tracked_p_.store(orders_tracked_plain_, std::memory_order_seq_cst); + updated_ns_p_.store(updated_ns_, std::memory_order_seq_cst); + seq_.fetch_add(1, std::memory_order_seq_cst); + } + + // Hot-owned plain state (single writer thread). + uint64_t net_yes_ = 0; + uint64_t net_hedge_ = 0; + uint64_t yes_avg_ = 0; + uint64_t hedge_avg_ = 0; + int64_t realized_pnl_ = 0; + uint64_t open_buy_ = 0; + uint64_t open_sell_ = 0; + uint64_t open_buy_cost_ = 0; + uint64_t fills_ = 0; + uint64_t anomalies_ = 0; + uint64_t updated_ns_ = 0; + uint64_t orders_tracked_plain_ = 0; + uint64_t last_stale_scan_ns_ = 0; + std::array orders_{}; + std::array bloom_current_{}; + std::array bloom_previous_{}; + uint64_t dedupe_epoch_ = 0; + + // Seqlock publication (atomics, seq_cst like OrderBookL2). + alignas(64) std::atomic seq_{0}; + std::atomic net_yes_p_{0}; + std::atomic net_hedge_p_{0}; + std::atomic yes_avg_p_{0}; + std::atomic hedge_avg_p_{0}; + std::atomic realized_pnl_p_{0}; + std::atomic open_buy_p_{0}; + std::atomic open_sell_p_{0}; + std::atomic open_buy_cost_p_{0}; + std::atomic fills_p_{0}; + std::atomic anomalies_p_{0}; + std::atomic orders_tracked_p_{0}; + std::atomic updated_ns_p_{0}; +}; + +#endif // POSITION_TRACKER_HPP diff --git a/core/include/risk_manager.hpp b/core/include/risk_manager.hpp new file mode 100644 index 0000000..5984ce4 --- /dev/null +++ b/core/include/risk_manager.hpp @@ -0,0 +1,207 @@ +#ifndef RISK_MANAGER_HPP +#define RISK_MANAGER_HPP + +// ───────────────────────────────────────────────────────────────────────────── +// RiskManager: the brakes. Two faces by design: +// +// * HOT authorize(): read-mostly evaluation consulted before EVERY order is +// signed. All mutable state it touches is either owned by the hot loop or +// a single atomic; recalibration from the cold path lands through a +// seqlock-published RiskLimits POD (same publication idiom as the book). +// +// * evaluate(): per-tick mark-to-market. Computes unrealized P&L at the +// liquidation-conservative mark (best bid for long inventory), applies the +// protective ladder KILL > CLOSE(stop-loss) > HEDGE, and returns ONE +// action the engine executes immediately. Protective exits never wait +// for alpha — that is the entire point of the brakes. +// +// Day-loss accounting: real-time day P&L = (realized_pnl − day_realized_base) +// + unrealized P&L. The base anchors at UTC midnight rollover and at startup. +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include + +#include "order_book.hpp" +#include "polymarket_order.hpp" +#include "position_tracker.hpp" +#include "time_utils.hpp" + +// Cold-recalibrable limits. Copied by value under a seqlock: hot decision +// paths never read individual mutable fields. +struct RiskLimits { + double stop_loss_pct = 0.15; // mark drop vs VWAP entry; 0 disables + double hedge_trigger_pct = 0.0; // earlier hedge trigger; 0 disables + double max_daily_loss_usd = 50.0; + double max_market_exposure_usd = 250.0; + double max_portfolio_exposure_usd = 250.0; + uint64_t version = 0; // monotonic publication counter +}; + +enum class RiskAction : uint8_t { NONE = 0, CLOSE = 1, HEDGE = 2, KILL = 3 }; + +struct RiskDecision { + RiskAction action = RiskAction::NONE; + uint64_t shares = 0; // quantity to close / hedge + uint64_t limit_price = 0; // x1e6 conservative reference for the action + int64_t projected_loss = 0; // x1e6 USD loss if action executes at the mark +}; + +class RiskManager { +public: + explicit RiskManager(const RiskLimits& initial) : limits_plain_(initial) { + publish_limits(); + } + + // Cold path: recalibrate limits atomically (hot picks them up coherently). + void configure(const RiskLimits& limits) noexcept { + limits_plain_ = limits; + publish_limits(); + } + + bool read_limits(RiskLimits& out) const noexcept { + const uint64_t s1 = limits_seq_.load(std::memory_order_seq_cst); + if (s1 & 1U) return false; + out = published_; + const uint64_t s2 = limits_seq_.load(std::memory_order_seq_cst); + if (s1 != s2 || (s2 & 1U)) return false; + return true; + } + + // ── Hot: pre-sign authorization (read-mostly) ─────────────────────────── + bool killed() const noexcept { + return kill_.load(std::memory_order_acquire); + } + + void latch_kill() noexcept { + kill_.store(true, std::memory_order_release); + } + + bool authorize(uint8_t /*side*/, double notional_usd, + double exposure_now_usd, + double portfolio_exposure_usd) const noexcept { + if (killed()) return false; + RiskLimits limits{}; + if (!read_limits(limits)) return false; // mid-publish: deny, retry + if (notional_usd > 0.0 && + (exposure_now_usd + notional_usd > + limits.max_market_exposure_usd + 1e-9 || + portfolio_exposure_usd + notional_usd > + limits.max_portfolio_exposure_usd + 1e-9)) + return false; + return true; + } + + // ── Hot: day accounting on top of tracker state ───────────────────────── + // Returns true when the UTC day rolled and the base was re-anchored. + bool maintain_day_anchor(int64_t tracker_realized) noexcept { + const uint64_t today = crowdintel::realtime_ns() / 86400000000000ULL; + if (day_ == 0) { + day_ = today; + day_realized_base_ = tracker_realized; + return true; + } + if (today != day_) { + day_ = today; + day_realized_base_ = tracker_realized; + day_kill_latched_ = false; + return true; + } + return false; + } + + int64_t day_realized(int64_t tracker_realized) const noexcept { + return tracker_realized - day_realized_base_; + } + + static int64_t unrealized_pnl(uint64_t net, uint64_t avg, + uint64_t mark_bid) noexcept { + if (net == 0 || avg == 0) return 0; + const crowd_int128_t diff = + static_cast(mark_bid) - + static_cast(avg); + return static_cast(diff * static_cast(net) / + static_cast(1000000)); + } + + // ── Hot: per-tick protective evaluation ───────────────────────────────── + // hedge_bid/hedge_ask describe the complement book (0 when unavailable). + RiskDecision evaluate(const OrderBookL2::Top& top, + const PositionTracker& tracker, + uint64_t hedge_bid, uint64_t hedge_ask) noexcept { + RiskDecision decision{}; + RiskLimits limits{}; + if (!read_limits(limits)) return decision; // transient: act next tick + + const int64_t realized_today = day_realized(tracker.realized_pnl()); + const int64_t unreal_yes = unrealized_pnl( + tracker.net_yes(), tracker.yes_avg(), top.bid.price); + const int64_t unreal_hedge = unrealized_pnl( + tracker.net_hedge(), tracker.hedge_avg(), hedge_bid); + const int64_t day_pnl = realized_today + unreal_yes + unreal_hedge; + + // (d) Global kill switch: realized + floating loss breaches the budget. + const int64_t loss_budget = + static_cast(limits.max_daily_loss_usd * 1000000.0); + if (loss_budget > 0 && day_pnl <= -loss_budget && !day_kill_latched_) { + day_kill_latched_ = true; + kill_.store(true, std::memory_order_release); + decision.action = RiskAction::KILL; + decision.projected_loss = day_pnl; + return decision; + } + + const uint64_t net = tracker.net_yes(); + const uint64_t avg = tracker.yes_avg(); + if (net == 0 || avg == 0 || top.bid.price == 0) return decision; + + // (b) Stop-loss and (c) hedging share the same adverse-move metric: + // drop of the liquidation mark versus the tracked VWAP entry. + const double drop = static_cast(avg - top.bid.price) / + static_cast(avg); + if (drop <= 0.0) return decision; + const int64_t floating = unreal_yes; // negative when underwater + + const bool stop_enabled = limits.stop_loss_pct > 0.0; + const bool hedge_enabled = limits.hedge_trigger_pct > 0.0 && + hedge_ask != 0 && hedge_ask < 1000000; + const bool stop_hit = stop_enabled && drop >= limits.stop_loss_pct; + const bool hedge_hit = hedge_enabled && + drop >= limits.hedge_trigger_pct && + tracker.net_hedge() < net; + if (stop_hit) { + decision.action = RiskAction::CLOSE; + decision.shares = net; + decision.limit_price = top.bid.price; + decision.projected_loss = floating; + return decision; + } + if (hedge_hit) { + decision.action = RiskAction::HEDGE; + decision.shares = net - tracker.net_hedge(); + decision.limit_price = hedge_ask; + decision.projected_loss = floating; + return decision; + } + return decision; + } + +private: + void publish_limits() noexcept { + limits_seq_.fetch_add(1, std::memory_order_seq_cst); + published_ = limits_plain_; + published_.version = + limits_seq_.load(std::memory_order_seq_cst); + limits_seq_.fetch_add(1, std::memory_order_seq_cst); + } + + RiskLimits limits_plain_{}; + RiskLimits published_{}; + alignas(64) std::atomic limits_seq_{0}; + alignas(64) std::atomic kill_{false}; + uint64_t day_ = 0; + int64_t day_realized_base_ = 0; + bool day_kill_latched_ = false; +}; + +#endif // RISK_MANAGER_HPP diff --git a/core/include/source_reliability.hpp b/core/include/source_reliability.hpp new file mode 100644 index 0000000..e1b3ff2 --- /dev/null +++ b/core/include/source_reliability.hpp @@ -0,0 +1,48 @@ +#ifndef SOURCE_RELIABILITY_HPP +#define SOURCE_RELIABILITY_HPP + +// ───────────────────────────────────────────────────────────────────────────── +// SourceReliability: trust calibration for evidence producers (P4). +// +// A fixed table of at most MAX_SOURCES weights, one std::atomic +// per source (weight x1e6, range [0, 1]). The cold path recalibrates +// weights (config reload, recalibration file, adapter self-scoring) with a +// plain atomic store; the hot loop reads a weight with one acquire load — +// no seqlock, no contention, ~5 ns. +// +// Unknown/unregistered sources read as weight 0, which the engine treats as +// "below any reliability floor": their evidence is journalled and discarded. +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include + +class SourceReliability { +public: + static constexpr size_t MAX_SOURCES = 64; + + // Cold path. Out-of-range ids are ignored (never crash on bad config). + void set_weight(uint32_t source_id, double reliability) noexcept { + if (source_id >= MAX_SOURCES) return; + if (reliability < 0.0) reliability = 0.0; + if (reliability > 1.0) reliability = 1.0; + weights_[source_id].store( + static_cast(reliability * 1000000.0 + 0.5), + std::memory_order_release); + } + + // Hot path: one atomic load. x1e6 weight; 0 for unknown sources. + uint32_t weight_x1e6(uint32_t source_id) const noexcept { + if (source_id >= MAX_SOURCES) return 0; + return weights_[source_id].load(std::memory_order_acquire); + } + + double weight(uint32_t source_id) const noexcept { + return static_cast(weight_x1e6(source_id)) * 1e-6; + } + +private: + std::atomic weights_[MAX_SOURCES]{}; +}; + +#endif // SOURCE_RELIABILITY_HPP diff --git a/core/include/time_utils.hpp b/core/include/time_utils.hpp new file mode 100644 index 0000000..cf3016e --- /dev/null +++ b/core/include/time_utils.hpp @@ -0,0 +1,38 @@ +#ifndef TIME_UTILS_HPP +#define TIME_UTILS_HPP + +// Shared monotonic/realtime helpers in one place. Inline, syscall-free +// wrappers (clock_gettime via vDSO on Linux), safe for the hot path. + +#include +#include +#include + +namespace crowdintel { + +inline uint64_t mono_ns() noexcept { + return static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} + +inline uint64_t realtime_ns() noexcept { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); +} + +inline uint64_t mono_ms() noexcept { + return mono_ns() / 1000000ULL; +} + +inline uint64_t realtime_ms() noexcept { + timespec ts{}; + clock_gettime(CLOCK_REALTIME, &ts); + return static_cast(ts.tv_sec) * 1000ULL + + static_cast(ts.tv_nsec) / 1000000ULL; +} + +} // namespace crowdintel + +#endif // TIME_UTILS_HPP diff --git a/core/include/volatility_gate.hpp b/core/include/volatility_gate.hpp new file mode 100644 index 0000000..22774d0 --- /dev/null +++ b/core/include/volatility_gate.hpp @@ -0,0 +1,198 @@ +#ifndef VOLATILITY_GATE_HPP +#define VOLATILITY_GATE_HPP + +// ───────────────────────────────────────────────────────────────────────────── +// VolatilityGate: the adverse-selection brake (P3). +// +// Two cooperating halves sharing one object but zero shared mutable fields: +// +// * Regime sampler (COLD writer): the presign thread feeds book tops at its +// native ~100 Hz loop. The gate classifies the current regime +// (NORMAL / ELEVATED / EXTREME) from spread width (BOT_VOL_MAX_SPREAD_BPS) +// and mid-change rate (BOT_VOL_MAX_TICKS_PER_SEC) and publishes three +// outputs through atomics: an effective pre-signed ladder TTL +// (BOT_POOL_VOL_TTL_MS while hot), a size scale (BOT_VOL_SIZE_MULTIPLIER), +// and a pause flag. The hot path reads them for ~10 ns and never +// contends the writer. +// +// * Shock guard (HOT owned): a mid-price jump of BOT_VOL_MID_GAP_BPS or +// more inside a SHOCK_WINDOW (100 ms — the acceptance window) arms a +// cooldown during which NO passive flow fires. This is the mechanism +// against firing stale ladder orders on toxic flow: a 5%+ mid jump in +// under 100 ms suppresses consumption entirely while the cold sampler +// simultaneously shrinks the refreshed ladder. +// +// * Slippage gate (HOT, pure): rejects orders whose target price deviates +// from the CURRENT mid by more than BOT_POOL_MAX_DEV_BPS. A stale pool +// slot can only be consumed when its signed price is this close to fair. +// +// All quantities fixed-point x1e6 (prices) or basis points. No allocation, +// no blocking, no virtuals. +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include + +#include "../src/market_config.hpp" +#include "time_utils.hpp" + +class VolatilityGate { +public: + static constexpr uint64_t SHOCK_WINDOW_NS = 100000000ULL; // 100 ms + static constexpr uint64_t SHOCK_COOLDOWN_NS = 250000000ULL; // 250 ms + + explicit VolatilityGate(const MarketConfig& cfg) + : pool_vol_ttl_ms_(cfg.pool_vol_ttl_ms), + max_dev_bps_(cfg.pool_max_dev_bps), + spread_wide_bps_(cfg.vol_max_spread_bps), + tick_high_hz_(cfg.vol_max_ticks_per_sec), + mid_gap_bps_(cfg.vol_mid_gap_bps), + size_mult_permille_( + static_cast(cfg.vol_size_multiplier * 1000.0)) { + publish(0, cfg.presign_ttl_ms, 1000, false); + } + + // ── Cold sampler (presign thread only) ────────────────────────────────── + void sample(uint64_t bid, uint64_t ask, uint64_t mono_ns, + uint64_t base_ttl_ms) noexcept { + if (bid == 0 || ask == 0 || bid >= ask) return; + const uint64_t mid = (bid + ask) / 2; + if (mid != last_mid_) { + ++mid_changes_; + last_mid_ = mid; + } + // 1-second window rate estimate. + if (mono_ns - window_start_ns_ >= 1000000000ULL) { + rate_hz_ = mid_changes_; + mid_changes_ = 0; + window_start_ns_ = mono_ns; + } + const uint64_t spread_bps = + (ask - bid) * 10000ULL / (mid ? mid : 1); + const bool wide = spread_wide_bps_ > 0.0 && + static_cast(spread_bps) >= spread_wide_bps_; + const bool fast = tick_high_hz_ != 0 && rate_hz_ >= tick_high_hz_; + uint32_t regime = 0; + if (wide && fast && tick_high_hz_ != 0 && + rate_hz_ >= 2 * tick_high_hz_) + regime = 2; // both, plus extreme rate: pause passive flow + else if (wide || fast) + regime = 1; + if (regime != regime_mode_) { + regime_mode_ = regime; + uint64_t ttl = base_ttl_ms; + uint32_t permille = 1000; + bool pause = false; + if (regime == 1) { + ttl = pool_vol_ttl_ms_ < base_ttl_ms ? pool_vol_ttl_ms_ + : base_ttl_ms; + permille = size_mult_permille_ == 0 ? 100 : size_mult_permille_; + } else if (regime == 2) { + ttl = pool_vol_ttl_ms_ / 2 > 100 ? pool_vol_ttl_ms_ / 2 : 100; + permille = size_mult_permille_ / 2 < 100 + ? 100 : size_mult_permille_ / 2; + pause = true; + } + publish(regime, ttl, permille, pause); + } + } + + // ── Hot readers (atomic, ~10 ns) ──────────────────────────────────────── + uint64_t effective_ttl_ms() const noexcept { + return ttl_ms_.load(std::memory_order_acquire); + } + uint32_t size_permille() const noexcept { + return permille_.load(std::memory_order_acquire); + } + bool paused() const noexcept { + return pause_.load(std::memory_order_acquire); + } + uint32_t regime() const noexcept { + return regime_pub_.load(std::memory_order_acquire); + } + + // ── Hot shock guard (hot loop only: single writer of these fields) ────── + // Returns true while passive flow is suppressed (cooldown armed). + bool observe_mid(uint64_t mid, uint64_t mono_ns) noexcept { + if (mid != 0 && prev_mid_ != 0 && prev_ns_ != 0) { + const uint64_t dt = mono_ns - prev_ns_; + const uint64_t diff = mid > prev_mid_ ? mid - prev_mid_ + : prev_mid_ - mid; + const uint64_t gap_bps = diff * 10000ULL / prev_mid_; + if (dt <= SHOCK_WINDOW_NS && + static_cast(gap_bps) >= mid_gap_bps_) { + if (mono_ns >= shock_until_ns_) + shocks_.fetch_add(1, std::memory_order_relaxed); + shock_until_ns_ = mono_ns + SHOCK_COOLDOWN_NS; + } + } + prev_mid_ = mid; + prev_ns_ = mono_ns; + return mono_ns < shock_until_ns_; + } + + // Pure slippage check against the CURRENT mid price. A taker order + // priced further than max_dev_bps from mid is toxic flow, not an edge. + // max_dev_bps <= 0 disables the check (per configuration contract). + bool slippage_ok(uint8_t side, uint64_t price, uint64_t mid) const + noexcept { + if (max_dev_bps_ <= 0.0 || mid == 0 || price == 0) return true; + const uint64_t slip = side == 0 + ? (price > mid ? price - mid : 0) + : (mid > price ? mid - price : 0); + const uint64_t dev_bps = slip * 10000ULL / mid; + return static_cast(dev_bps) <= max_dev_bps_; + } + double max_dev_bps() const noexcept { return max_dev_bps_; } + + void note_stale_abort() noexcept { + aborts_.fetch_add(1, std::memory_order_relaxed); + } + + uint64_t shocks() const noexcept { + return shocks_.load(std::memory_order_relaxed); + } + uint64_t aborts() const noexcept { + return aborts_.load(std::memory_order_relaxed); + } + uint32_t rate_hz() const noexcept { return rate_hz_; } + +private: + void publish(uint32_t regime, uint64_t ttl, uint32_t permille, + bool pause) noexcept { + regime_pub_.store(regime, std::memory_order_release); + ttl_ms_.store(ttl, std::memory_order_release); + permille_.store(permille, std::memory_order_release); + pause_.store(pause, std::memory_order_release); + } + + // Static configuration (constructor-owned, immutable afterwards). + const uint64_t pool_vol_ttl_ms_; + const double max_dev_bps_; + const double spread_wide_bps_; + const uint64_t tick_high_hz_; + const double mid_gap_bps_; + const uint32_t size_mult_permille_; + + // Cold-sampler state (owned by the presign thread). + uint64_t last_mid_ = 0; + uint64_t mid_changes_ = 0; + uint64_t window_start_ns_ = 0; + uint32_t rate_hz_ = 0; + uint32_t regime_mode_ = 0; + + // Hot shock-guard state (owned by the hot loop). + uint64_t prev_mid_ = 0; + uint64_t prev_ns_ = 0; + uint64_t shock_until_ns_ = 0; + + // Published outputs (cold writer -> hot readers). + alignas(64) std::atomic regime_pub_{0}; + std::atomic ttl_ms_{0}; + std::atomic permille_{1000}; + alignas(64) std::atomic pause_{false}; + alignas(64) std::atomic shocks_{0}; + std::atomic aborts_{0}; +}; + +#endif // VOLATILITY_GATE_HPP diff --git a/core/src/bench_engine.hpp b/core/src/bench_engine.hpp index 3a2819e..dc0e66e 100644 --- a/core/src/bench_engine.hpp +++ b/core/src/bench_engine.hpp @@ -15,9 +15,12 @@ class BenchEngine { public: BenchEngine(const MarketConfig& cfg, OrderBookL2& book, SPSC_RingBuffer& signals, - const EIP712Signer& signer, PresignedOrderPool& pool) + const EIP712Signer& signer, PresignedOrderPool& pool, + const EngineLayers* layers = nullptr) : cfg_(cfg), signer_(signer), pool_(pool), client_(cfg), - enabled_(true), engine_(cfg, book, signals, signer, pool, client_, &enabled_) {} + enabled_(true), + engine_(cfg, book, signals, signer, pool, client_, &enabled_, + layers) {} int run_tick() { const TickResult result = engine_.run_tick(); diff --git a/core/src/evidence_ingress.hpp b/core/src/evidence_ingress.hpp new file mode 100644 index 0000000..7e748e5 --- /dev/null +++ b/core/src/evidence_ingress.hpp @@ -0,0 +1,177 @@ +#ifndef EVIDENCE_INGRESS_HPP +#define EVIDENCE_INGRESS_HPP + +// ───────────────────────────────────────────────────────────────────────────── +// Evidence ingress (P4, cold path only). Three duties: +// +// * Source-list parsing ("1:0.9,2:0.5") into SourceReliability — used by the +// startup config, the recalibration-file poller, and unit tests. Shared +// code path so calibration semantics can never drift between loaders. +// +// * NDJSON evidence line parsing (one observation per line): +// {"source":1,"kind":"count","n":32,"k":22,"hash":12345} +// {"source":3,"kind":"lr","lr":693147,"hash":12346} // lr x1e6 +// Strict validation; malformed lines are rejected, never half-applied. +// Cold-user only (sscanf-based parsers on the advisory thread). +// +// * EvidenceFileReplayer: a cold thread replays a recorded evidence file +// (paper-trading/backfill) into the hot queue, then tails it. This is the +// universal substrate for the live adapters (polls/macro/sports write the +// same NDJSON through stdout-redirected collectors or the HTTP poller). +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include +#include +#include +#include +#include + +#include "../include/evidence.hpp" +#include "../include/source_reliability.hpp" +#include "../include/spsc_ring_buffer.hpp" +#include "../include/time_utils.hpp" + +namespace evidence_ingress { + +// "1:0.90, 2:0.5" — comma/whitespace separated id:weight pairs. Returns the +// number of pairs applied; stops at the first malformed token. +inline size_t parse_sources_text(const char* text, SourceReliability& out) { + if (!text) return 0; + size_t applied = 0; + const char* p = text; + while (*p) { + while (*p && (std::isspace(static_cast(*p)) || + *p == ',')) + ++p; + if (!*p) break; + char* end = nullptr; + const long id = std::strtol(p, &end, 10); + if (!end || end == p || *end != ':') break; + p = end + 1; + char* wend = nullptr; + const double w = std::strtod(p, &wend); + if (!wend || wend == p) break; + out.set_weight(static_cast(id), w); + ++applied; + p = wend; + } + return applied; +} + +// Parse one NDJSON evidence line. Returns true and fills `ev` on success; +// anything unrecognized or contradictory returns false untouched. +inline bool parse_evidence_line(const char* line, EvidenceEvent& ev) { + unsigned source = 0, n = 0, k = 0, hash = 0; + long long lr = 0; + char kind[16]{}; + // Tolerate field order by scanning with a permissive-but-complete match: + // every required field must appear exactly once; extras are rejected by + // the strict format first, then fall back to field-by-field probes. + int matched = std::sscanf(line, + " { \"source\" : %u , \"kind\" : \"%15[^\"]\" , \"n\" : %u , " + "\"k\" : %u , \"hash\" : %u } ", + &source, kind, &n, &k, &hash); + if (matched == 5) { + if (n == 0 || k > n || hash == 0) return false; + ev = EvidenceEvent{}; + ev.kind = EvidenceEvent::Kind::COUNT; + ev.source_id = static_cast(source); + ev.count_n = static_cast(n); + ev.count_k = static_cast(k); + ev.event_hash = static_cast(hash); + return true; + } + matched = std::sscanf(line, + " { \"source\" : %u , \"kind\" : \"%15[^\"]\" , \"lr\" : %lld , " + "\"hash\" : %u } ", + &source, kind, &lr, &hash); + if (matched == 4) { + if (hash == 0) return false; + if (std::strcmp(kind, "lr") != 0) return false; + ev = EvidenceEvent{}; + ev.kind = EvidenceEvent::Kind::LR; + ev.source_id = static_cast(source); + ev.lr_x1e6 = static_cast(lr); + ev.event_hash = static_cast(hash); + return true; + } + return false; +} + +} // namespace evidence_ingress + +// Cold replayer/tailer: pushes parsed events, then keeps tailing the file for +// appends until stopped. Never blocks the hot queue: full queue drops are +// counted and surfaced via `dropped()`. +class EvidenceFileReplayer { +public: + EvidenceFileReplayer(const char* path, + SPSC_RingBuffer& queue, + SourceReliability* recal, const char* recal_path) + : path_(path), queue_(queue), recal_(recal), + recal_path_(recal_path) {} + + bool start() { + bool expected = false; + if (!running_.compare_exchange_strong(expected, true)) return true; + FILE* probe = std::fopen(path_, "r"); + open_ok_ = probe != nullptr; + if (probe) std::fclose(probe); + thread_ = std::thread([this] { run(); }); + return open_ok_; + } + + void stop() { + running_.store(false, std::memory_order_release); + if (thread_.joinable()) thread_.join(); + } + + bool open_ok() const { return open_ok_; } + uint64_t parsed() const { return parsed_; } + uint64_t dropped() const { return dropped_; } + +private: + void run() { + char line[2048]; + while (running_.load(std::memory_order_acquire)) { + FILE* f = std::fopen(path_, "r"); + if (f) { + while (running_.load(std::memory_order_acquire) && + std::fgets(line, sizeof(line), f)) { + EvidenceEvent ev{}; + if (!evidence_ingress::parse_evidence_line(line, ev)) + continue; + ev.timestamp_ns = crowdintel::realtime_ns(); + if (queue_.try_push(ev)) ++parsed_; + else ++dropped_; + } + std::fclose(f); + } + if (recal_ && recal_path_ && recal_path_[0]) { + FILE* rf = std::fopen(recal_path_, "r"); + if (rf) { + if (std::fgets(line, sizeof(line), rf)) + (void)evidence_ingress::parse_sources_text(line, + *recal_); + std::fclose(rf); + } + } + for (int i = 0; i < 20 && + running_.load(std::memory_order_acquire); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + } + + const char* path_; + SPSC_RingBuffer& queue_; + SourceReliability* recal_; + const char* recal_path_; + bool open_ok_ = false; + uint64_t parsed_ = 0; + uint64_t dropped_ = 0; + std::atomic running_{false}; + std::thread thread_; +}; + +#endif // EVIDENCE_INGRESS_HPP diff --git a/core/src/execution_engine.hpp b/core/src/execution_engine.hpp index e8ae8d5..0e723cf 100644 --- a/core/src/execution_engine.hpp +++ b/core/src/execution_engine.hpp @@ -11,8 +11,17 @@ #include "../crypto/eip712_signer.hpp" #include "../crypto/fast_random.hpp" +#include "../include/account_events.hpp" +#include "../include/journal.hpp" #include "../include/order_book.hpp" +#include "../include/position_tracker.hpp" +#include "../include/bayesian_engine.hpp" +#include "../include/evidence.hpp" +#include "../include/risk_manager.hpp" +#include "../include/source_reliability.hpp" +#include "../include/volatility_gate.hpp" #include "../include/spsc_ring_buffer.hpp" +#include "../include/time_utils.hpp" #include "alpha_receiver.hpp" #include "kelly_engine.hpp" #include "market_config.hpp" @@ -35,6 +44,13 @@ enum class TickResult : uint8_t { SUBMIT_FAILED, QUEUED, SUBMITTED, + ACCOUNT_APPLIED, // housekeeping consumed user-channel facts + RISK_STOP_LOSS, // protective close emitted without alpha input + RISK_HEDGE, // hedge order emitted on the complement token + RISK_KILL_SWITCH, // daily-loss kill switch was latched this tick + STALE_PRICE_ABORT, // slippage-vs-mid guard rejected a stale/toxic order + VOLATILITY_PAUSED, // volatility regime suppressed passive flow + BAYES_SIGNAL, // a bayesian posterior fired an order this tick COUNT }; @@ -55,11 +71,52 @@ inline const char* tick_result_name(TickResult result) { case TickResult::SUBMIT_FAILED: return "submit_failed"; case TickResult::QUEUED: return "queued"; case TickResult::SUBMITTED: return "submitted"; + case TickResult::ACCOUNT_APPLIED: return "account_applied"; + case TickResult::RISK_STOP_LOSS: return "risk_stop_loss"; + case TickResult::RISK_HEDGE: return "risk_hedge"; + case TickResult::RISK_KILL_SWITCH: return "risk_kill_switch"; + case TickResult::STALE_PRICE_ABORT: return "stale_price_abort"; + case TickResult::VOLATILITY_PAUSED: return "volatility_paused"; + case TickResult::BAYES_SIGNAL: return "bayes_signal"; case TickResult::COUNT: break; } return "unknown"; } +// Forward declarations of the P2–P4 layers (defined in their own headers). +class RiskManager; +class VolatilityGate; +class BayesianEngine; +class SourceReliability; +struct EvidenceEvent; + +// Authoritative restatement from the cold REST reconciler. Travels on its +// own dedicated SPSC queue (a different producer always gets its own queue). +struct TrackerRestate { + uint64_t yes_shares = 0; + uint64_t yes_avg = 0; + uint64_t hedge_shares = 0; + uint64_t hedge_avg = 0; + uint64_t drift_exceeded = 0; // non-zero: latch the kill switch +}; +static_assert(std::is_trivially_copyable_v); + +// Optional second-generation layers. Every pointer is nullable; an absent +// layer preserves the exact pre-existing (legacy) engine behavior, so all +// original tests and the mock path keep their semantics byte-for-byte. +struct EngineLayers { + SPSC_RingBuffer* account_q = nullptr; // user-channel facts + PositionTracker* tracker = nullptr; // inventory/P&L state + SPSC_RingBuffer* journal_q = nullptr; // audit journal sink + SPSC_RingBuffer* restate_q = nullptr; // REST reconcile + RiskManager* risk = nullptr; // brakes (P2) + VolatilityGate* volatility = nullptr; // adverse selection (P3) + SPSC_RingBuffer* evidence_q = nullptr; // brain (P4) + BayesianEngine* bayes = nullptr; + SourceReliability* sources = nullptr; + OrderBookL2* hedge_book = nullptr; // complement book (P2) +}; + template class ExecutionEngine { public: @@ -69,14 +126,103 @@ class ExecutionEngine { const EIP712Signer& signer, PresignedOrderPool& pool, Client& client, - const std::atomic* trading_enabled = nullptr) + std::atomic* trading_enabled = nullptr, + const EngineLayers* layers = nullptr) : cfg_(cfg), book_(book), signals_(signals), signer_(signer), pool_(pool), client_(client), trading_enabled_(trading_enabled), - confirmed_inventory_(cfg.initial_position_shares) {} + confirmed_inventory_(cfg.initial_position_shares) { + if (layers) { + account_q_ = layers->account_q; + tracker_ = layers->tracker; + journal_q_ = layers->journal_q; + restate_q_ = layers->restate_q; + risk_ = layers->risk; + volatility_ = layers->volatility; + evidence_q_ = layers->evidence_q; + bayes_ = layers->bayes; + sources_ = layers->sources; + hedge_book_ = layers->hedge_book; + } + // The account queue and tracker are inseparable: accepting facts + // without state to apply them to would silently lose fills. + if (account_q_ && !tracker_) tracker_ = nullptr, account_q_ = nullptr; + } TickResult run_tick() { + // PRIORITY 1+2 (eyes and brakes): user-channel facts are drained and + // protective risk actions execute BEFORE any alpha decision, so + // orders never fire against stale inventory or a breached stop. + const TickResult housework = housekeeping(); + if (housework == TickResult::RISK_KILL_SWITCH || + housework == TickResult::RISK_STOP_LOSS || + housework == TickResult::RISK_HEDGE) + return housework; // protective action owns this tick + + // ── P3 (adverse selection) ───────────────────────────────────────── + // Mid observation must be CONTINUOUS: the shock FSM only detects a + // >5%/100ms jump if it sees the mid every tick, not only when alpha + // happens to arrive. One extra best-effort seqlock read (~tens of + // ns) far from the +5us budget, and only when the gate is attached. + bool flow_suppressed = false; + if (volatility_) { + OrderBookL2::Top obs{}; + const uint64_t max_age_ns = cfg_.max_book_age_ms * 1000000ULL; + if (book_.read_top(obs, max_age_ns) && obs.bid.size != 0 && + obs.ask.size != 0 && obs.bid.price < obs.ask.price) { + const uint64_t mid_obs = (obs.bid.price + obs.ask.price) / 2; + const bool shocked = volatility_->observe_mid( + mid_obs, crowdintel::mono_ns()); + flow_suppressed = shocked || volatility_->paused(); + const uint32_t regime = volatility_->regime(); + if (regime != last_regime_) { + journal(regime != 0 ? JournalEvent::Type::VOLATILITY_ENTER + : JournalEvent::Type::VOLATILITY_EXIT, + 0, regime, + obs.ask.price - obs.bid.price, 0); + last_regime_ = regime; + } + // Every newly-armed shock is journaled exactly once: the + // discarded ladder slots are auditable (P3 acceptance: + // "logs and adapts" on a >5% jump inside 100 ms). + const uint64_t shock_count = volatility_->shocks(); + if (shock_count != shocks_seen_) { + journal(JournalEvent::Type::POOL_STALE_DROP, 0, + mid_obs, shock_count, 0); + shocks_seen_ = shock_count; + } + } + } + + // ── P4 (brain) ──────────────────────────────────────────────────── + // Evidence ingestion precedes the alpha pop on every tick: posterior + // state must track facts even while trading flow is busy. Each + // event folds into the closed form in tens of nanoseconds. + drain_evidence(); + AlphaSignal signal{}; - if (!signals_.try_pop(signal)) return TickResult::NO_SIGNAL; + if (signals_.try_pop(signal)) { + if (volatility_ && flow_suppressed) + return TickResult::VOLATILITY_PAUSED; + return execute_signal_pipeline(signal, false); + } + + // No queued alpha: evaluate the posterior trigger against the book. + if (bayes_ && sources_ && cfg_.bayes_enable && + !(volatility_ && flow_suppressed)) { + const TickResult brain = evaluate_posterior_trigger(); + if (brain != TickResult::NO_SIGNAL) return brain; + } + + return housework != TickResult::NO_SIGNAL ? housework + : TickResult::NO_SIGNAL; + } + + // Shared dispatch for queued alpha and posterior-synthesized signals. + // A bayesian-tagged signal maps SUBMITTED to BAYES_SIGNAL so the brain's + // orders stay accountable against plain alpha in the session report. + TickResult execute_signal_pipeline(const AlphaSignal& signal, + bool from_bayes) { + if (risk_ && risk_->killed()) return TickResult::RISK_REJECTED; if (!std::isfinite(signal.p_win) || !std::isfinite(signal.confidence) || !std::isfinite(signal.q_value) || @@ -122,7 +268,7 @@ class ExecutionEngine { side = buy_edge >= sell_edge ? K_SIDE_BUY : K_SIDE_SELL; } - if (side == K_SIDE_SELL && confirmed_inventory_ < cfg_.min_size_shares) + if (side == K_SIDE_SELL && sellable_inventory() < cfg_.min_size_shares) return TickResult::NO_INVENTORY; const uint64_t tick = book_.tick_size(cfg_.tick_size); @@ -133,6 +279,19 @@ class ExecutionEngine { const double edge = net_edge(side, signal.p_win, price); if (edge < cfg_.min_edge) return TickResult::NO_EDGE; + // Pre-sign slippage guard (P3): the signed target may never sit + // further than BOT_POOL_MAX_DEV_BPS from the CURRENT mid. A pool + // slot whose price drifted that far away is a stale order; firing it + // would realize exactly the adverse selection this layer exists for. + const uint64_t mid_now = (top.bid.price + top.ask.price) / 2; + if (volatility_ && + !volatility_->slippage_ok(side, price_raw, mid_now)) { + volatility_->note_stale_abort(); + journal(JournalEvent::Type::STALE_PRICE_ABORT, 0, price_raw, + mid_now, 0); + return TickResult::STALE_PRICE_ABORT; + } + // Size against the fee-adjusted execution price. This is conservative: // fees reduce both the gate and the Kelly fraction. const double fee_per_share = cfg_.taker_fee_rate * price * (1.0 - price); @@ -148,18 +307,25 @@ class ExecutionEngine { if (side == K_SIDE_BUY) { available_budget = std::min( available_budget, - std::max(0.0, cfg_.max_exposure_usd - committed_exposure_usd_)); + std::max(0.0, cfg_.max_exposure_usd - exposure_now_usd())); available_budget = std::min( available_budget, - std::max(0.0, cfg_.max_daily_loss_usd - worst_case_loss_usd_)); + std::max(0.0, cfg_.max_daily_loss_usd - worst_loss_now_usd())); } usd = std::min(usd, available_budget); + // Volatile regime shrinks passive size (P3): the cold sampler scales + // by BOT_VOL_SIZE_MULTIPLIER; a pause never reaches this line. + if (volatility_) { + const uint32_t shrink = volatility_->size_permille(); + if (shrink < 1000) + usd = usd * static_cast(shrink) / 1000.0; + } uint64_t requested_shares = KellyEngine::usd_to_shares_fixed(usd, price); const uint64_t visible = side == K_SIDE_BUY ? top.ask.size : top.bid.size; requested_shares = std::min(requested_shares, visible); if (side == K_SIDE_SELL) - requested_shares = std::min(requested_shares, confirmed_inventory_); + requested_shares = std::min(requested_shares, sellable_inventory()); if (requested_shares < cfg_.min_size_shares) return TickResult::TOO_SMALL; @@ -172,22 +338,33 @@ class ExecutionEngine { effective_shares < cfg_.min_size_shares) return TickResult::TOO_SMALL; - // Local circuit breaker is conservative until user-channel fill - // reconciliation lands: accepted BUYs reserve their full worst-case - // cost and are not credited as sellable inventory. + // Hard per-order and portfolio caps. With the tracker layer attached + // these read the reconciled worst-cost exposure; without it they keep + // the legacy local-reservation semantics. const double order_notional = static_cast( side == K_SIDE_BUY ? maker_amount : taker_amount) * 1e-6; if (order_notional > cfg_.max_order_usd + 1e-9 || (side == K_SIDE_BUY && - (committed_exposure_usd_ + order_notional > cfg_.max_exposure_usd || - worst_case_loss_usd_ + order_notional > cfg_.max_daily_loss_usd))) + (exposure_now_usd() + order_notional > cfg_.max_exposure_usd || + worst_loss_now_usd() + order_notional > cfg_.max_daily_loss_usd))) + return TickResult::RISK_REJECTED; + // RiskManager authorization is the ALWAYS-ON line consulted before + // any signature is produced. A kill latch or a cap breach denies + // the order even when every legacy budget still looks available. + if (risk_ && !risk_->authorize(side, + side == K_SIDE_BUY ? order_notional : 0.0, + side == K_SIDE_BUY ? exposure_now_usd() : 0.0, + side == K_SIDE_BUY ? exposure_now_usd() : 0.0)) return TickResult::RISK_REJECTED; WireBody body{}; uint64_t pool_size = 0, pool_maker = 0, pool_taker = 0; + // Dynamic ladder TTL (P3): volatile regimes tighten the freshness + // window (BOT_POOL_VOL_TTL_MS); volatile=off keeps the static TTL. bool presigned = pool_.acquire_at_most( side, price_raw, tick, effective_shares, body, - pool_size, pool_maker, pool_taker); + pool_size, pool_maker, pool_taker, + volatility_ ? volatility_->effective_ttl_ms() : 0); if (presigned) { effective_shares = pool_size; maker_amount = pool_maker; @@ -206,43 +383,370 @@ class ExecutionEngine { return TickResult::BODY_FAILED; } + // With the tracker attached, reserve against the shared inventory + // BEFORE the wire: a second order can never duplicate exposure even + // while the venue's fill event is still in flight (P1 acceptance). + if (tracker_) { + if (side == K_SIDE_BUY) + tracker_->reserve_buy(effective_shares, price_raw); + else + tracker_->reserve_sell(effective_shares); + } const SubmitResult response = client_.submit(body); if (!response.ok) { + if (tracker_) { + if (side == K_SIDE_BUY) + tracker_->release_buy(effective_shares, price_raw); + else + tracker_->release_sell(effective_shares); + } ++submit_failed_; + journal(JournalEvent::Type::ORDER_FAILED, 0, + static_cast(response.http_code), price_raw, + effective_shares); return TickResult::SUBMIT_FAILED; } + journal(JournalEvent::Type::ORDER_ACCEPTED, 0, side, price_raw, + effective_shares); - const double accepted_notional = static_cast( - side == K_SIDE_BUY ? maker_amount : taker_amount) * 1e-6; - if (side == K_SIDE_BUY) { - committed_exposure_usd_ += accepted_notional; - worst_case_loss_usd_ += accepted_notional; - } else { - // Reserve as if fully filled; never permit two sells against the - // same confirmed inventory while fills are not reconciled. - confirmed_inventory_ = effective_shares >= confirmed_inventory_ - ? 0 : confirmed_inventory_ - effective_shares; + if (!tracker_) { + const double accepted_notional = static_cast( + side == K_SIDE_BUY ? maker_amount : taker_amount) * 1e-6; + if (side == K_SIDE_BUY) { + committed_exposure_usd_ += accepted_notional; + worst_case_loss_usd_ += accepted_notional; + } else { + // Legacy mode: reserve as if fully filled; never permit two + // sells against the same confirmed inventory. + confirmed_inventory_ = effective_shares >= confirmed_inventory_ + ? 0 : confirmed_inventory_ - effective_shares; + } } if (!response.final) { ++queued_; return TickResult::QUEUED; } ++submitted_; + if (from_bayes && bayes_) { + journal(JournalEvent::Type::BAYES_SIGNAL, 0, + static_cast(bayes_->posterior() * 1000000.0), + price_raw, effective_shares); + return TickResult::BAYES_SIGNAL; + } return TickResult::SUBMITTED; } uint64_t submitted() const noexcept { return submitted_; } uint64_t queued() const noexcept { return queued_; } uint64_t submit_failed() const noexcept { return submit_failed_; } - uint64_t confirmed_inventory() const noexcept { return confirmed_inventory_; } - double committed_exposure_usd() const noexcept { return committed_exposure_usd_; } + uint64_t confirmed_inventory() const noexcept { + return tracker_ ? tracker_->net_yes() : confirmed_inventory_; + } + double committed_exposure_usd() const noexcept { + return exposure_now_usd(); + } private: static uint64_t realtime_ns() noexcept { - timespec ts{}; - clock_gettime(CLOCK_REALTIME, &ts); - return static_cast(ts.tv_sec) * 1000000000ULL + - static_cast(ts.tv_nsec); + return crowdintel::realtime_ns(); + } + + // ── Brain (P4) ────────────────────────────────────────────────────────── + // Fold queued evidence into the closed-form posterior. Cost when idle: + // one atomic load; per event: one weight load + conjugate update. + void drain_evidence() noexcept { + if (!evidence_q_ || !bayes_ || !sources_ || !cfg_.bayes_enable) + return; + EvidenceEvent ev{}; + const uint32_t floor_x1e6 = static_cast( + cfg_.bayes_min_reliability * 1000000.0); + while (evidence_q_->try_pop(ev)) { + const uint32_t w = sources_->weight_x1e6(ev.source_id); + if (w < floor_x1e6) { + // Reliable-source gate: untrusted evidence never moves the + // posterior; it lands in the journal for later calibration. + journal(JournalEvent::Type::BAYES_LOW_RELIABILITY, 0, + ev.source_id, w, ev.event_hash); + continue; + } + if (ev.event_hash != 0 && seen_evidence_before(ev.event_hash)) + continue; + if (ev.kind == EvidenceEvent::Kind::COUNT) + bayes_->update_count(ev.outcome, ev.count_n, ev.count_k, w); + else + bayes_->update_lr(ev.lr_x1e6, w); + ++evidence_generation_; + journal(JournalEvent::Type::BAYES_UPDATE, 0, + static_cast(bayes_->posterior() * 1000000.0), + ev.source_id, ev.event_hash); + } + } + + bool seen_evidence_before(uint32_t hash) noexcept { + for (const uint32_t seen : seen_evidence_) + if (seen == hash) return true; + seen_evidence_[evidence_seen_idx_++ & 31] = hash; + return false; + } + + // Fire exactly one signal per new batch of trusted evidence when the + // posterior diverges from executable prices beyond the threshold. The + // synthesized signal traverses the SAME alpha pipeline, so Kelly sizing, + // risk authorization, the volatility gate, and inventory caps all apply + // with no special case — the brain can never outmaneuver the brakes. + TickResult evaluate_posterior_trigger() noexcept { + if (evidence_generation_ == bayes_trigger_gen_) + return TickResult::NO_SIGNAL; // one shot per new evidence batch + OrderBookL2::Top top{}; + const uint64_t max_age_ns = cfg_.max_book_age_ms * 1000000ULL; + if (!book_.read_top(top, max_age_ns) || top.bid.size == 0 || + top.ask.size == 0 || top.bid.price >= top.ask.price) + return TickResult::NO_SIGNAL; + if (!bayes_->has_prior()) { + const double mid = static_cast( + (top.bid.price + top.ask.price) / 2) * 1e-6; + bayes_->ensure_prior(mid, cfg_.bayes_prior_strength); + return TickResult::NO_SIGNAL; // seed only; trigger on next fact + } + const double posterior = bayes_->posterior(); + if (posterior < 0.0) return TickResult::NO_SIGNAL; + const double ask = static_cast(top.ask.price) * 1e-6; + const double bid = static_cast(top.bid.price) * 1e-6; + uint8_t direction = 255; + if (posterior - ask > cfg_.bayes_signal_threshold) + direction = K_SIDE_BUY; + else if (bid - posterior > cfg_.bayes_signal_threshold) + direction = K_SIDE_SELL; + if (direction == 255) return TickResult::NO_SIGNAL; + bayes_trigger_gen_ = evidence_generation_; + AlphaSignal syn{}; + syn.direction_hint = direction; + syn.p_win = posterior; + syn.confidence = 1.0; + syn.q_value = 0.0; + syn.timestamp_ns = realtime_ns(); + syn.market_hash = cfg_.market_hash; + syn.signal_id = 0xB4E5000000000000ULL ^ (++bayes_synth_seq_); + return execute_signal_pipeline(syn, true); + } + + // Drain all pending user-channel facts into the tracker, then run the + // protective risk evaluation. Runs before any decision every tick so a + // partial fill (e.g. 3,000 of 10,000) is visible to the very next order + // evaluation; applies in well under 1 ms because each event is O(1) plus + // a bounded 16-slot scan. + TickResult housekeeping() noexcept { + TickResult result = TickResult::NO_SIGNAL; + if (restate_q_ && tracker_) { + // Authoritative REST restatement: adopt venue truth, then let the + // user channel continue streaming deltas on top of it. + TrackerRestate restate{}; + while (restate_q_->try_pop(restate)) { + tracker_->override_inventory( + restate.yes_shares, restate.yes_avg, restate.hedge_shares, + restate.hedge_avg); + journal(JournalEvent::Type::RECONCILE_DRIFT, 0, + restate.yes_shares, restate.yes_avg, + restate.drift_exceeded); + if (restate.drift_exceeded && risk_) { + risk_->latch_kill(); + if (trading_enabled_) + trading_enabled_->store(false, + std::memory_order_release); + } + result = TickResult::ACCOUNT_APPLIED; + } + } + if (account_q_ && tracker_) { + AccountEvent event{}; + while (account_q_->try_pop(event)) { + tracker_->apply(event); + journal(event.type == AccountEvent::Type::REJECT || + event.type == AccountEvent::Type::FAILED + ? JournalEvent::Type::ACCOUNT_REJECT + : JournalEvent::Type::ACCOUNT_FILL, + tracker_->realized_pnl(), event.price, event.size, + event.order_hash); + result = TickResult::ACCOUNT_APPLIED; + } + const size_t released = tracker_->release_stale( + crowdintel::mono_ns(), cfg_.reservation_ttl_ms * 1000000ULL); + if (released) { + journal(JournalEvent::Type::RESERVATION_STALE_RELEASE, 0, + static_cast(released), 0, 0); + result = TickResult::ACCOUNT_APPLIED; + } + } + + // ── The brakes (P2) ──────────────────────────────────────────────── + if (risk_ && tracker_) { + if (risk_->maintain_day_anchor(tracker_->realized_pnl())) + journal(JournalEvent::Type::DAY_RESET, + tracker_->realized_pnl(), 0, 0, 0); + if (!risk_->killed()) { + OrderBookL2::Top top{}; + const uint64_t max_age_ns = + cfg_.max_book_age_ms * 1000000ULL; + if (book_.read_top(top, max_age_ns) && top.bid.size != 0 && + top.ask.size != 0) { + uint64_t hedge_bid = 0, hedge_ask = 0; + if (hedge_book_) { + OrderBookL2::Top hedge_top{}; + if (hedge_book_->read_top(hedge_top, max_age_ns)) { + hedge_bid = hedge_top.bid.price; + hedge_ask = hedge_top.ask.price; + } + } + const RiskDecision decision = risk_->evaluate( + top, *tracker_, hedge_bid, hedge_ask); + if (decision.action == RiskAction::KILL) { + if (trading_enabled_) + trading_enabled_->store(false, + std::memory_order_release); + journal(JournalEvent::Type::KILL_SWITCH, + decision.projected_loss, 0, 0, 0); + return TickResult::RISK_KILL_SWITCH; + } + if (decision.action == RiskAction::CLOSE || + decision.action == RiskAction::HEDGE) { + const TickResult action_result = + execute_protective(decision); + if (action_result != TickResult::NO_SIGNAL) + return action_result; + } + } + } else if (risk_->killed() && trading_enabled_ && + trading_enabled_->load(std::memory_order_acquire)) { + trading_enabled_->store(false, std::memory_order_release); + return TickResult::RISK_KILL_SWITCH; + } + } + return result; + } + + // Execute a protective CLOSE (stop-loss) or HEDGE order. Protective + // exits are always inline-signed taker orders ("FAK"): they must not + // consume the pre-signed ladder (stale-price risk) nor wait for a cold + // queue — the panic path is synchronous by design and fully journaled. + TickResult execute_protective(const RiskDecision& decision) noexcept { + const bool is_close = decision.action == RiskAction::CLOSE; + const bool is_hedge = decision.action == RiskAction::HEDGE; + if (!is_close && !is_hedge) return TickResult::NO_SIGNAL; + + const uint8_t side = is_hedge ? K_SIDE_BUY : K_SIDE_SELL; + const uint64_t tick = book_.tick_size(cfg_.tick_size); + const uint64_t price_raw = round_price_to_tick(decision.limit_price, + tick); + uint64_t shares = decision.shares; + if (is_close) { + const uint64_t sellable = tracker_ ? tracker_->sellable() : 0; + shares = shares > sellable ? sellable : shares; + } + if (shares < cfg_.min_size_shares) return TickResult::NO_SIGNAL; + + uint64_t maker_amount = 0, taker_amount = 0, effective_shares = 0; + if (!compute_order_amounts(side, price_raw, shares, tick, + /*market_order=*/true, maker_amount, + taker_amount, effective_shares) || + effective_shares < cfg_.min_size_shares) + return is_close ? TickResult::RISK_STOP_LOSS + : TickResult::RISK_HEDGE; + + // Hedges increase gross exposure and must pass authorization; closes + // are reduce-only and are never blocked by caps. + if (is_hedge) { + const double hedge_notional = + static_cast(maker_amount) * 1e-6; + if (risk_ && !risk_->authorize(side, hedge_notional, + exposure_now_usd(), + exposure_now_usd())) { + journal(JournalEvent::Type::ORDER_FAILED, 0, 1, price_raw, + effective_shares); + return TickResult::RISK_REJECTED; + } + } + + const uint8_t* token = is_hedge ? cfg_.hedge_token_id_be + : cfg_.token_id_be; + const char* token_dec = is_hedge ? cfg_.hedge_token_id_dec + : cfg_.token_id_dec; + OrderV2 order{}; + order.salt = rng_.next_salt(); + order.timestamp_ms = PresignedOrderPool::now_ms(); + std::memcpy(order.maker, cfg_.maker, 20); + std::memcpy(order.signer, cfg_.signer, 20); + std::memcpy(order.token_id, token, 32); + order.maker_amount = maker_amount; + order.taker_amount = taker_amount; + order.side = side; + order.signature_type = cfg_.signature_type; + uint8_t signature[65]; + if (!signer_.sign_order(order, signature)) + return TickResult::SIGN_FAILED; + + WireBody body{}; + const uint64_t expiration = cfg_.wire_expiration(order.timestamp_ms / 1000ULL); + if (!build_wire_body(order, signature, token_dec, cfg_.maker_hex, + cfg_.signer_hex, cfg_.owner_api_key, "FAK", + body, expiration)) + return TickResult::BODY_FAILED; + + if (tracker_) { + if (side == K_SIDE_BUY) + tracker_->reserve_buy(effective_shares, price_raw); + else + tracker_->reserve_sell(effective_shares); + } + const SubmitResult response = client_.submit(body); + if (!response.ok) { + if (tracker_) { + if (side == K_SIDE_BUY) + tracker_->release_buy(effective_shares, price_raw); + else + tracker_->release_sell(effective_shares); + } + journal(JournalEvent::Type::ORDER_FAILED, 0, + static_cast(response.http_code), price_raw, + effective_shares); + // A failed close is still reported as an attempted stop so the + // next tick re-evaluates against the moved market. + } + journal(is_close ? JournalEvent::Type::STOP_LOSS_TRIGGERED + : JournalEvent::Type::HEDGE_TRIGGERED, + decision.projected_loss, price_raw, effective_shares, + static_cast(response.ok ? 1 : 0)); + return is_close ? TickResult::RISK_STOP_LOSS + : TickResult::RISK_HEDGE; + } + + void journal(JournalEvent::Type type, int64_t pnl, uint64_t aux0, + uint64_t aux1, uint64_t aux2) noexcept { + if (!journal_q_) return; + JournalEvent event{}; + event.type = type; + event.pnl = pnl; + event.aux0 = aux0; + event.aux1 = aux1; + event.aux2 = aux2; + event.mono_ns = crowdintel::mono_ns(); + (void)journal_q_->try_push(event); // bounded; drops are never fatal + } + + uint64_t sellable_inventory() const noexcept { + return tracker_ ? tracker_->sellable() : confirmed_inventory_; + } + + double exposure_now_usd() const noexcept { + return tracker_ ? static_cast(tracker_->exposure_worst_cost()) * + 1e-6 + : committed_exposure_usd_; + } + + // Worst-case day loss budget consumption. Matches the legacy guarantee: + // everything committed may go to zero. (Realized losses additionally get + // accounted by the RiskManager kill switch through the tracker.) + double worst_loss_now_usd() const noexcept { + return tracker_ ? exposure_now_usd() : worst_case_loss_usd_; } double net_edge(uint8_t side, double p_win, double price) const noexcept { @@ -315,7 +819,24 @@ class ExecutionEngine { const EIP712Signer& signer_; PresignedOrderPool& pool_; Client& client_; - const std::atomic* trading_enabled_; + std::atomic* trading_enabled_; // risk kill switch latches it + SPSC_RingBuffer* account_q_ = nullptr; + PositionTracker* tracker_ = nullptr; + SPSC_RingBuffer* journal_q_ = nullptr; + SPSC_RingBuffer* restate_q_ = nullptr; + RiskManager* risk_ = nullptr; + VolatilityGate* volatility_ = nullptr; + uint32_t last_regime_ = 0; + uint64_t shocks_seen_ = 0; + SPSC_RingBuffer* evidence_q_ = nullptr; + BayesianEngine* bayes_ = nullptr; + SourceReliability* sources_ = nullptr; + uint32_t seen_evidence_[32]{}; + uint32_t evidence_seen_idx_ = 0; + uint64_t evidence_generation_ = 0; + uint64_t bayes_trigger_gen_ = UINT64_MAX; + uint64_t bayes_synth_seq_ = 0; + OrderBookL2* hedge_book_ = nullptr; FastRandom rng_; // Two rotating Bloom epochs guarantee no false negatives inside the // configured TTL. False positives only reject work, which is fail-safe. diff --git a/core/src/json_fields.hpp b/core/src/json_fields.hpp new file mode 100644 index 0000000..6d80ed9 --- /dev/null +++ b/core/src/json_fields.hpp @@ -0,0 +1,124 @@ +#ifndef JSON_FIELDS_HPP +#define JSON_FIELDS_HPP + +// Shared allocation-free, top-level JSON field helpers for protocol adapters +// added after the original hardening pass (user-channel events, simulation +// venue). Existing adapters keep their audited in-file copies intentionally: +// converging them is possible but would churn already-pen-tested code for no +// behavioral gain. +// +// All functions are bounded by explicit (begin, end) ranges, count keys only +// at the immediate object level, and reject duplicates so field-like text in +// nested objects or inside strings can never forge semantics. + +#include +#include +#include +#include + +namespace json_fields { + +inline size_t key_occurrences(const char* begin, const char* end, + const char* key, + const char** first = nullptr) { + if (first) *first = nullptr; + const size_t key_len = std::strlen(key); + int depth = 0; + size_t count = 0; + for (const char* p = begin; p < end; ++p) { + if (*p == '{' || *p == '[') { ++depth; continue; } + if (*p == '}' || *p == ']') { --depth; continue; } + if (*p != '"') continue; + const char* start = p + 1; + const char* cursor = start; + bool escaped = false; + while (cursor < end) { + if (escaped) escaped = false; + else if (*cursor == '\\') escaped = true; + else if (*cursor == '"') break; + ++cursor; + } + if (cursor == end) return count; + const char* after = cursor + 1; + while (after < end && std::isspace(static_cast(*after))) + ++after; + if (depth == 1 && + static_cast(cursor - start) == key_len && + std::memcmp(start, key, key_len) == 0 && + after < end && *after == ':') { + if (count++ == 0 && first) *first = cursor + 1; + } + p = cursor; + } + return count; +} + +inline const char* find_char(const char* begin, const char* end, char wanted) { + return static_cast( + std::memchr(begin, wanted, static_cast(end - begin))); +} + +inline const char* find_matching(const char* open, const char* end, + char open_char, char close_char) { + int depth = 0; + bool in_string = false, escaped = false; + for (const char* p = open; p < end; ++p) { + if (in_string) { + if (escaped) escaped = false; + else if (*p == '\\') escaped = true; + else if (*p == '"') in_string = false; + continue; + } + if (*p == '"') in_string = true; + else if (*p == open_char) ++depth; + else if (*p == close_char && --depth == 0) return p; + } + return nullptr; +} + +inline bool extract_string(const char* begin, const char* end, const char* key, + char* out, size_t cap) { + const char* hit = nullptr; + if (key_occurrences(begin, end, key, &hit) != 1 || cap == 0) + return false; + while (hit < end && std::isspace(static_cast(*hit))) ++hit; + if (hit == end || *hit++ != ':') return false; + while (hit < end && std::isspace(static_cast(*hit))) ++hit; + if (hit == end || *hit++ != '"') return false; + size_t n = 0; + while (hit < end && *hit != '"') { + if (*hit == '\\' || n + 1 >= cap) return false; + out[n++] = *hit++; + } + if (hit == end) return false; + out[n] = '\0'; + return true; +} + +inline bool find_array(const char* begin, const char* end, const char* key, + const char*& array_begin, const char*& array_end) { + const char* hit = nullptr; + if (key_occurrences(begin, end, key, &hit) != 1) return false; + while (hit < end && std::isspace(static_cast(*hit))) ++hit; + if (hit == end || *hit++ != ':') return false; + while (hit < end && std::isspace(static_cast(*hit))) ++hit; + if (hit == end || *hit != '[') return false; + const char* close = find_matching(hit, end, '[', ']'); + if (!close) return false; + array_begin = hit; + array_end = close; + return true; +} + +inline uint64_t fnv_hash(const char* data, size_t len) noexcept { + uint64_t h = 1469598103934665603ULL; + for (size_t i = 0; i < len; ++i) { + h ^= static_cast(data[i]); + h *= 1099511628211ULL; + } + return h; +} + +} // namespace json_fields + +#endif // JSON_FIELDS_HPP diff --git a/core/src/lightweight_client.hpp b/core/src/lightweight_client.hpp index 5662aa8..e9e73e7 100644 --- a/core/src/lightweight_client.hpp +++ b/core/src/lightweight_client.hpp @@ -45,6 +45,7 @@ class LightweightCLOBClient { ~LightweightCLOBClient() { if (curl_) curl_easy_cleanup(curl_); + if (rest_curl_) curl_easy_cleanup(rest_curl_); secure_zero(secret_raw_, sizeof(secret_raw_)); secure_zero(&hmac_, sizeof(hmac_)); secure_zero(resp_, sizeof(resp_)); @@ -77,51 +78,8 @@ class LightweightCLOBClient { return result; } - char timestamp[24]; - const size_t timestamp_len = u64_to_dec(now_unix_seconds(), timestamp); - - // L2 HMAC: timestamp + method + path + exact body bytes. - uint8_t digest[32]; - char message[1600 + 64]; - size_t message_len = 0; - std::memcpy(message + message_len, timestamp, timestamp_len); - message_len += timestamp_len; - std::memcpy(message + message_len, "POST/order", 10); - message_len += 10; - std::memcpy(message + message_len, body.buf, body.len); - message_len += body.len; - hmac_.compute(reinterpret_cast(message), message_len, digest); - secure_zero(message, message_len); - - char signature_b64[48]; - const size_t signature_len = base64url_encode(digest, 32, signature_b64); - signature_b64[signature_len] = '\0'; - secure_zero(digest, sizeof(digest)); - - char address_header[80], signature_header[96], timestamp_header[48]; - char api_key_header[96], passphrase_header[160]; - std::snprintf(address_header, sizeof(address_header), - "POLY_ADDRESS: %s", cfg_.api_address_hex); - std::snprintf(signature_header, sizeof(signature_header), - "POLY_SIGNATURE: %s", signature_b64); - std::snprintf(timestamp_header, sizeof(timestamp_header), - "POLY_TIMESTAMP: %.*s", static_cast(timestamp_len), timestamp); - std::snprintf(api_key_header, sizeof(api_key_header), - "POLY_API_KEY: %s", cfg_.owner_api_key); - std::snprintf(passphrase_header, sizeof(passphrase_header), - "POLY_PASSPHRASE: %s", cfg_.api_passphrase); - curl_slist* headers = nullptr; - bool headers_ok = append_header(headers, address_header); - headers_ok = append_header(headers, signature_header) && headers_ok; - headers_ok = append_header(headers, timestamp_header) && headers_ok; - headers_ok = append_header(headers, api_key_header) && headers_ok; - headers_ok = append_header(headers, passphrase_header) && headers_ok; - headers_ok = append_header(headers, "Content-Type: application/json") && - headers_ok; - if (!headers_ok) { - curl_slist_free_all(headers); - secure_zero(signature_b64, sizeof(signature_b64)); + if (!build_l2_headers(headers, "POST", "/order", body.buf, body.len)) { std::snprintf(result.error, sizeof(result.error), "header allocation failed"); return result; } @@ -154,6 +112,115 @@ class LightweightCLOBClient { return result; } + // ── Cold-path REST (reconciliation thread only) ───────────────────────── + // Authenticated GET with L2 HMAC over timestamp+method+path. Uses its own + // curl handle: the order handle is owned by the gateway worker. + // Returns bytes written (0 on failure); the body is NUL-terminated. + size_t rest_get(const char* path, char* out, size_t cap) { + if (!out || cap < 2 || !rest_ready()) return 0; + char url[224]; + const int n = std::snprintf(url, sizeof(url), "%s%s", cfg_.clob_host, + path); + if (n <= 0 || static_cast(n) >= sizeof(url)) return 0; + curl_easy_setopt(rest_curl_, CURLOPT_URL, url); + curl_easy_setopt(rest_curl_, CURLOPT_HTTPGET, 1L); + curl_slist* headers = nullptr; + if (!build_l2_headers(headers, "GET", path, nullptr, 0)) + return 0; + curl_easy_setopt(rest_curl_, CURLOPT_HTTPHEADER, headers); + const CURLcode code = curl_easy_perform(rest_curl_); + curl_slist_free_all(headers); + curl_easy_setopt(rest_curl_, CURLOPT_HTTPHEADER, nullptr); + if (code != CURLE_OK || rest_resp_overflow_) return 0; + long http_code = 0; + curl_easy_getinfo(rest_curl_, CURLINFO_RESPONSE_CODE, &http_code); + if (http_code < 200 || http_code >= 300 || rest_resp_len_ >= cap) + return 0; + std::memcpy(out, rest_resp_, rest_resp_len_); + out[rest_resp_len_] = '\0'; + return rest_resp_len_; + } + + // DELETE /cancel-all — venue cancels every resting order of the account. + // Called from the reconcile/kill thread; never from the hot path. + bool cancel_all() { + static constexpr char PATH[] = "/cancel-all"; + if (!rest_ready()) return false; + char url[224]; + const int n = std::snprintf(url, sizeof(url), "%s%s", cfg_.clob_host, + PATH); + if (n <= 0 || static_cast(n) >= sizeof(url)) return false; + curl_easy_setopt(rest_curl_, CURLOPT_URL, url); + curl_easy_setopt(rest_curl_, CURLOPT_CUSTOMREQUEST, "DELETE"); + curl_slist* headers = nullptr; + if (!build_l2_headers(headers, "DELETE", PATH, nullptr, 0)) { + curl_easy_setopt(rest_curl_, CURLOPT_CUSTOMREQUEST, nullptr); + return false; + } + curl_easy_setopt(rest_curl_, CURLOPT_HTTPHEADER, headers); + const CURLcode code = curl_easy_perform(rest_curl_); + curl_slist_free_all(headers); + curl_easy_setopt(rest_curl_, CURLOPT_HTTPHEADER, nullptr); + curl_easy_setopt(rest_curl_, CURLOPT_CUSTOMREQUEST, nullptr); + long http_code = 0; + curl_easy_getinfo(rest_curl_, CURLINFO_RESPONSE_CODE, &http_code); + return code == CURLE_OK && http_code >= 200 && http_code < 300; + } + + // Extract total size and VWAP for one asset from GET /data/positions. + // Offline-testable pure parser: top-level array of objects. + static bool parse_positions_for_asset(const char* json, size_t len, + const char* asset_id, + uint64_t& shares_out, + uint64_t& avg_price_out) { + shares_out = 0; + avg_price_out = 0; + if (!json || !asset_id || len == 0 || + !bounded_json::valid_document(json, len)) + return false; + size_t begin = 0; + while (begin < len && + std::isspace(static_cast(json[begin]))) ++begin; + if (begin == len || json[begin] != '[') return false; + const char* end = json + len; + const char* cursor = json + begin + 1; + bool found = false; + crowd_uint128_t total_cost = 0; + uint64_t total_shares = 0; + while (cursor < end) { + const char* open = static_cast( + std::memchr(cursor, '{', static_cast(end - cursor))); + if (!open) break; + const char* close = find_matching_static(open, end, '{', '}'); + if (!close) break; + char asset[96]{}, size_text[32]{}, avg_text[32]{}; + const size_t object_len = static_cast(close + 1 - open); + const bool has_asset = + key_occurrences(open, object_len, "asset") == 1 && + extract_json_string(open, object_len, "asset", asset, + sizeof(asset)); + if (has_asset && std::strcmp(asset, asset_id) == 0 && + extract_json_string(open, object_len, "size", size_text, + sizeof(size_text)) && + extract_json_string(open, object_len, "avgPrice", avg_text, + sizeof(avg_text))) { + uint64_t size = 0, avg = 0; + if (parse_fixed1e6(size_text, std::strlen(size_text), size) && + parse_fixed1e6(avg_text, std::strlen(avg_text), avg)) { + total_cost += static_cast(size) * avg; + total_shares += size; + found = true; + } + } + cursor = close + 1; + } + if (!found || total_shares == 0) return false; + shares_out = total_shares; + avg_price_out = static_cast( + total_cost / static_cast(total_shares)); + return true; + } + // Pure semantic classifier used by submit() and response fixtures. HTTP // success alone is never order acceptance. static SubmitResult classify_response(long http_code, @@ -226,6 +293,139 @@ class LightweightCLOBClient { } private: + // L2 authentication headers: HMAC(timestamp + method + path + body). + // Shared by order submission, authenticated GETs and DELETE /cancel-all. + bool build_l2_headers(curl_slist*& headers, const char* method, + const char* path, const char* body, + size_t body_len) { + headers = nullptr; + char timestamp[24]; + const size_t timestamp_len = u64_to_dec(now_unix_seconds(), timestamp); + + uint8_t digest[32]; + char message[1600 + 64]; + size_t message_len = 0; + std::memcpy(message + message_len, timestamp, timestamp_len); + message_len += timestamp_len; + const size_t method_len = std::strlen(method); + const size_t path_len = std::strlen(path); + if (message_len + method_len + path_len + body_len >= sizeof(message)) + return false; + std::memcpy(message + message_len, method, method_len); + message_len += method_len; + std::memcpy(message + message_len, path, path_len); + message_len += path_len; + if (body && body_len) { + std::memcpy(message + message_len, body, body_len); + message_len += body_len; + } + hmac_.compute(reinterpret_cast(message), message_len, + digest); + secure_zero(message, message_len); + + char signature_b64[48]; + const size_t signature_len = base64url_encode(digest, 32, signature_b64); + signature_b64[signature_len] = '\0'; + secure_zero(digest, sizeof(digest)); + + char address_header[80], signature_header[96], timestamp_header[48]; + char api_key_header[96], passphrase_header[160]; + std::snprintf(address_header, sizeof(address_header), + "POLY_ADDRESS: %s", cfg_.api_address_hex); + std::snprintf(signature_header, sizeof(signature_header), + "POLY_SIGNATURE: %s", signature_b64); + std::snprintf(timestamp_header, sizeof(timestamp_header), + "POLY_TIMESTAMP: %.*s", static_cast(timestamp_len), + timestamp); + std::snprintf(api_key_header, sizeof(api_key_header), + "POLY_API_KEY: %s", cfg_.owner_api_key); + std::snprintf(passphrase_header, sizeof(passphrase_header), + "POLY_PASSPHRASE: %s", cfg_.api_passphrase); + secure_zero(signature_b64, sizeof(signature_b64)); + + bool ok = append_header(headers, address_header); + ok = append_header(headers, signature_header) && ok; + ok = append_header(headers, timestamp_header) && ok; + ok = append_header(headers, api_key_header) && ok; + ok = append_header(headers, passphrase_header) && ok; + ok = append_header(headers, "Content-Type: application/json") && ok; + if (!ok) curl_slist_free_all(headers); + return ok; + } + + // Lazy REST handle for the reconciliation thread (cold path only). + bool rest_ready() { + if (rest_curl_) { + rest_resp_len_ = 0; + rest_resp_overflow_ = false; + rest_resp_[0] = '\0'; + return true; + } + rest_curl_ = curl_easy_init(); + if (!rest_curl_) return false; + curl_easy_setopt(rest_curl_, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(rest_curl_, CURLOPT_TCP_NODELAY, 1L); + curl_easy_setopt(rest_curl_, CURLOPT_CONNECTTIMEOUT_MS, 2000L); + curl_easy_setopt(rest_curl_, CURLOPT_TIMEOUT_MS, 3000L); + curl_easy_setopt(rest_curl_, CURLOPT_WRITEFUNCTION, rest_write_cb); + curl_easy_setopt(rest_curl_, CURLOPT_WRITEDATA, this); + curl_easy_setopt(rest_curl_, CURLOPT_ACCEPT_ENCODING, "identity"); + curl_easy_setopt(rest_curl_, CURLOPT_USERAGENT, "crowdintel-bot/2.1"); + curl_easy_setopt(rest_curl_, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(rest_curl_, CURLOPT_SSL_VERIFYHOST, 2L); +#if LIBCURL_VERSION_NUM >= 0x075500 + curl_easy_setopt(rest_curl_, CURLOPT_PROTOCOLS_STR, "https"); + curl_easy_setopt(rest_curl_, CURLOPT_REDIR_PROTOCOLS_STR, "https"); +#else + curl_easy_setopt(rest_curl_, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); + curl_easy_setopt(rest_curl_, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS); +#endif + curl_easy_setopt(rest_curl_, CURLOPT_FOLLOWLOCATION, 0L); + if (cfg_.tls_pin[0]) + curl_easy_setopt(rest_curl_, CURLOPT_PINNEDPUBLICKEY, cfg_.tls_pin); + rest_resp_len_ = 0; + rest_resp_overflow_ = false; + return true; + } + + static size_t rest_write_cb(char* ptr, size_t size, size_t nmemb, + void* userdata) { + auto* self = static_cast(userdata); + if (size != 0 && nmemb > SIZE_MAX / size) { + self->rest_resp_overflow_ = true; + return 0; + } + const size_t total = size * nmemb; + const size_t available = + sizeof(self->rest_resp_) - self->rest_resp_len_ - 1; + const size_t take = total < available ? total : available; + if (total > available) self->rest_resp_overflow_ = true; + if (take) { + std::memcpy(self->rest_resp_ + self->rest_resp_len_, ptr, take); + self->rest_resp_len_ += take; + self->rest_resp_[self->rest_resp_len_] = '\0'; + } + return total; + } + + static const char* find_matching_static(const char* open, const char* end, + char open_char, char close_char) { + int depth = 0; + bool in_string = false, escaped = false; + for (const char* p = open; p < end; ++p) { + if (in_string) { + if (escaped) escaped = false; + else if (*p == '\\') escaped = true; + else if (*p == '"') in_string = false; + continue; + } + if (*p == '"') in_string = true; + else if (*p == open_char) ++depth; + else if (*p == close_char && --depth == 0) return p; + } + return nullptr; + } + void configure_common() { curl_easy_setopt(curl_, CURLOPT_NOSIGNAL, 1L); curl_easy_setopt(curl_, CURLOPT_TCP_NODELAY, 1L); @@ -381,6 +581,7 @@ class LightweightCLOBClient { const MarketConfig& cfg_; CURL* curl_ = nullptr; + CURL* rest_curl_ = nullptr; // reconcile thread only char order_url_[192]{}; char warmup_url_[192]{}; HmacSha256 hmac_{}; @@ -389,6 +590,9 @@ class LightweightCLOBClient { char resp_[2048]{}; size_t resp_len_ = 0; bool resp_overflow_ = false; + char rest_resp_[8192]{}; + size_t rest_resp_len_ = 0; + bool rest_resp_overflow_ = false; }; #endif // LIGHTWEIGHT_CLIENT_HPP diff --git a/core/src/main_hot_path.cpp b/core/src/main_hot_path.cpp index 01008e8..2158c26 100644 --- a/core/src/main_hot_path.cpp +++ b/core/src/main_hot_path.cpp @@ -18,9 +18,19 @@ #include "../crypto/eip712_signer.hpp" #include "../crypto/secure_zero.hpp" +#include "../include/account_events.hpp" +#include "../include/bayesian_engine.hpp" +#include "../include/evidence.hpp" +#include "../include/journal.hpp" +#include "../include/source_reliability.hpp" #include "../include/order_book.hpp" +#include "../include/position_tracker.hpp" +#include "../include/risk_manager.hpp" +#include "../include/volatility_gate.hpp" #include "../include/spsc_ring_buffer.hpp" +#include "../include/time_utils.hpp" #include "alpha_parser.hpp" +#include "evidence_ingress.hpp" #include "execution_engine.hpp" #include "market_config.hpp" #include "mock_client.hpp" @@ -31,6 +41,7 @@ #include "lightweight_client.hpp" #include "order_gateway.hpp" #include "ws_market_listener.hpp" +#include "ws_user_listener.hpp" #define CROWDINTEL_LIVE 1 #endif @@ -64,25 +75,36 @@ void pin_to_cpu(int cpu) { void mock_feed(SPSC_RingBuffer& queue, OrderBookL2& book, const MarketConfig& cfg, std::atomic& running) { - Level2Entry bids[3] = { - {470000, 40000000}, {460000, 60000000}, {450000, 90000000}}; - Level2Entry asks[3] = { - {530000, 30000000}, {540000, 55000000}, {550000, 80000000}}; + // Paper-honest mock market: a tight, tradeable book (~100 bps execution + // slippage vs mid — inside the P3 gate) that is refreshed continuously so + // the staleness guard never gates the demo. Mid never moves, so the + // volatility regime stays NORMAL. p_win alternates 0.65 / 0.35 so both + // BUY and SELL edges fire: with paper fills enabled, positions round-trip + // and the brakes (P2) observe real mark-to-market. + const Level2Entry bids[3] = { + {495000, 40000000}, {494000, 60000000}, {493000, 90000000}}; + const Level2Entry asks[3] = { + {505000, 30000000}, {506000, 55000000}, {507000, 80000000}}; book.set_tick_size(cfg.tick_size); book.set_book(bids, 3, asks, 3); uint64_t id = 1; while (running.load(std::memory_order_acquire)) { + book.set_book(const_cast(bids), 3, + const_cast(asks), 3); AlphaSignal signal{}; signal.type = AlphaSignal::Type::WHALE_TRADE; - signal.direction_hint = 0; // explicitly exercise BUY-hint semantics - signal.p_win = (id % 2) ? 0.65 : 0.50; + // Alternate BUY (0.65 vs 0.505 ask) and SELL (0.35 vs 0.495 bid) + // hints so paper positions open and close. + const bool buy_leg = (id % 2) != 0; + signal.direction_hint = buy_leg ? 0 : 1; + signal.p_win = buy_leg ? 0.65 : 0.35; signal.confidence = 0.92; signal.q_value = 0.01; signal.timestamp_ns = AlphaParser::realtime_ns(); signal.market_hash = cfg.market_hash; signal.signal_id = id++; (void)queue.try_push(signal); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); } } @@ -101,6 +123,56 @@ long run_engine(Engine& engine, const MarketConfig& cfg, return productive; } +#if defined(CROWDINTEL_LIVE) +// One reconciliation round: fetch authoritative positions, compare them to +// the local tracker, and enqueue a restatement (+kill latch on big drift). +void reconcile_once_with(LightweightCLOBClient& client, + const MarketConfig& cfg, PositionTracker& tracker, + SPSC_RingBuffer& restate_q) { + static constexpr char PATH[] = "/data/positions?limit=100"; + char body[8192]; + const size_t len = client.rest_get(PATH, body, sizeof(body) - 1); + if (!len) { + std::fprintf(stderr, "RCN positions fetch failed (keeping local state)\n"); + return; + } + uint64_t yes = 0, yes_avg = 0, hedge = 0, hedge_avg = 0; + (void)LightweightCLOBClient::parse_positions_for_asset( + body, len, cfg.token_id_dec, yes, yes_avg); + if (cfg.hedge_token_id_dec[0]) + (void)LightweightCLOBClient::parse_positions_for_asset( + body, len, cfg.hedge_token_id_dec, hedge, hedge_avg); + + PositionTracker::Snapshot view{}; + if (!tracker.snapshot(view)) return; + const auto diff_shares = [](uint64_t a, uint64_t b) { + return a > b ? a - b : b - a; + }; + const uint64_t max_drift = static_cast( + cfg.reconcile_max_drift_shares * 1000000.0); + const uint64_t drift = diff_shares(view.net_yes, yes) + + diff_shares(view.net_hedge, hedge); + TrackerRestate restate{}; + restate.yes_shares = yes; + restate.yes_avg = yes_avg; + restate.hedge_shares = hedge; + restate.hedge_avg = hedge_avg; + restate.drift_exceeded = drift > max_drift && max_drift != 0 ? 1 : 0; + if (drift > 0) { + std::fprintf(stderr, + "RCN drift: local yes=%llu hedge=%llu vs venue yes=%llu " + "hedge=%llu (drift=%llu x1e6, kill=%llu)\n", + static_cast(view.net_yes), + static_cast(view.net_hedge), + static_cast(yes), + static_cast(hedge), + static_cast(drift), + static_cast(restate.drift_exceeded)); + (void)restate_q.try_push(restate); + } +} +#endif + } // namespace int main() { @@ -159,16 +231,116 @@ int main() { cfg.signature_type, cfg.order_type); auto book = std::make_unique(); + std::unique_ptr hedge_book; + if (cfg.hedge_token_id_dec[0]) + hedge_book = std::make_unique(); // complement leg (P2) auto signals = std::make_unique>(); + // P1 — the "eyes": user-channel account queue, shared inventory state + // and the audit journal. All pre-reserved before threads start. + auto account_q = std::make_unique>(); + auto journal_q = std::make_unique>(); + auto restate_q = std::make_unique>(); + PositionTracker tracker( + cfg.initial_position_shares, + static_cast(cfg.initial_position_avg_price * 1000000.0)); + + // P2 — the brakes. Limits come from validated configuration; the cold + // recalibration path (reconciler, supervisor) may later tighten them. + RiskLimits limits{}; + limits.stop_loss_pct = cfg.stop_loss_pct; + limits.hedge_trigger_pct = cfg.hedge_trigger_pct; + limits.max_daily_loss_usd = cfg.max_daily_loss_usd; + limits.max_market_exposure_usd = cfg.max_exposure_usd; + limits.max_portfolio_exposure_usd = cfg.max_portfolio_exposure_usd; + RiskManager risk(limits); + + EngineLayers layers{}; + layers.account_q = account_q.get(); + layers.tracker = &tracker; + layers.journal_q = journal_q.get(); + layers.restate_q = restate_q.get(); + layers.risk = &risk; + if (hedge_book) layers.hedge_book = hedge_book.get(); + + // P3 — adverse-selection brake. The presign thread samples the book; + // the hot loop reads the published regime and owns the shock FSM. + VolatilityGate volatility(cfg); + layers.volatility = &volatility; + + // P4 — the brain. Source trust comes from BOT_BAYES_SOURCES plus the + // optional recalibration file; evidence arrives on its own SPSC queue + // from whatever cold producer runs (replayer, poller, mock script). + auto evidence_q = std::make_unique>(); + BayesianEngine bayes; + SourceReliability sources; + const size_t sources_configured = + evidence_ingress::parse_sources_text(cfg.bayes_sources, sources); + if (cfg.bayes_enable && cfg.bayes_sources[0] && sources_configured == 0) { + std::fprintf(stderr, "FATAL: BOT_BAYES_SOURCES is set but no valid " + "id:weight pairs were parsed\n"); + return 1; + } + if (cfg.bayes_enable) { + layers.evidence_q = evidence_q.get(); + layers.bayes = &bayes; + layers.sources = &sources; + std::fprintf(stdout, "bayes brain: %zu sources, prior strength %.1f, " + "threshold %.3f, min reliability %.2f\n", + sources_configured, cfg.bayes_prior_strength, + cfg.bayes_signal_threshold, cfg.bayes_min_reliability); + } + PresignedOrderPool pool(cfg, signer, cfg.presign_ttl_ms); MockCLOBClient mock_client(cfg); + // Paper trading: accepted mock orders synthesize venue fills into the + // account queue, so tracker/exposure/move-stop/brain all see real flow. + // Live mode never attaches (fills come from the private WSS channel). + if (mock) mock_client.attach_fill_queue(account_q.get()); std::atomic workers_running{true}; std::atomic trading_enabled{true}; + // Cold journal consumer: the only renderer of hot-path audit events. + std::thread journal_thread([&] { + pin_to_cpu(cfg.cold_cpu); + JournalEvent event{}; + while (workers_running.load(std::memory_order_acquire) || + !journal_q->empty()) { + if (!journal_q->try_pop(event)) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + continue; + } + std::fprintf(stdout, + "JRN {\"type\":%u,\"pnl\":%lld,\"a0\":%llu,\"a1\":%llu," + "\"a2\":%llu,\"mono_ns\":%llu}\n", + static_cast(event.type), + static_cast(event.pnl), + static_cast(event.aux0), + static_cast(event.aux1), + static_cast(event.aux2), + static_cast(event.mono_ns)); + } + }); + std::thread mock_thread; + std::thread mock_evidence_thread; std::thread kill_switch_thread; + std::thread reconcile_thread; + std::unique_ptr evidence_replayer; + if (cfg.bayes_enable && cfg.evidence_file[0]) { + // Paper-trading/backfill substrate: replay recorded NDJSON evidence, + // then tail new lines; recalibrates source weights from the recal + // file on the same cold thread. + evidence_replayer = std::make_unique( + cfg.evidence_file, *evidence_q, &sources, cfg.bayes_recal_file); + const bool ok = evidence_replayer->start(); + std::fprintf(stdout, ok ? "evidence replay: %s\n" + : "evidence replay: %s (not found yet; " + "tailing)\n", cfg.evidence_file); + } #if defined(CROWDINTEL_LIVE) std::unique_ptr market_listener; + std::unique_ptr hedge_listener; // complement book (P2) + std::unique_ptr user_listener; std::unique_ptr alpha_parser; std::unique_ptr alpha_receiver; std::unique_ptr live_client; @@ -180,12 +352,60 @@ int main() { pin_to_cpu(cfg.cold_cpu); mock_feed(*signals, *book, cfg, workers_running); }); + if (cfg.bayes_enable && sources_configured > 0) { + // Paper-trading brain demo: scripted COUNT observations on the + // FIRST configured source. Alternating hit rates exercise both + // trigger directions against the synthetic book; each batch is + // dedup-proof (unique hash) and fires at most once. + mock_evidence_thread = std::thread([&] { + pin_to_cpu(cfg.cold_cpu); + const char* scan = cfg.bayes_sources; + uint32_t source = 0; + while (*scan && !std::isdigit( + static_cast(*scan))) + ++scan; + char* end = nullptr; + const long parsed = std::strtol(scan, &end, 10); + if (end && end != scan) source = static_cast(parsed); + uint32_t round = 0; + while (workers_running.load(std::memory_order_acquire)) { + EvidenceEvent ev{}; + ev.kind = EvidenceEvent::Kind::COUNT; + ev.source_id = source; + ev.count_n = 32; + ev.count_k = (round % 2) ? 10 : 22; // sell / buy leans + ev.event_hash = 0xE1000000U + round; + ev.timestamp_ns = crowdintel::realtime_ns(); + (void)evidence_q->try_push(ev); + ++round; + for (uint32_t i = 0; + i < 2500 && + workers_running.load(std::memory_order_acquire); + i += 50) + std::this_thread::sleep_for( + std::chrono::milliseconds(50)); + } + }); + } } #if defined(CROWDINTEL_LIVE) else { market_listener = std::make_unique(cfg, *book); market_listener->start(); + // Complement-token book for the hedging brake: its own connection and + // its own cold thread, subscribed to the hedge asset id only. + if (hedge_book) { + hedge_listener = std::make_unique( + cfg, *hedge_book, cfg.hedge_token_id_dec); + hedge_listener->start(); + } + + // The private user channel is the only authoritative source of fills; + // without it the engine would trade blind against local reservations. + user_listener = std::make_unique(cfg, *account_q); + user_listener->start(); + alpha_parser = std::make_unique( *signals, cfg.market_slug, cfg.max_q_value, cfg.min_confidence); alpha_receiver = std::make_unique(cfg, *alpha_parser); @@ -201,6 +421,36 @@ int main() { *live_client, &trading_enabled); gateway->start(); + // Startup reconciliation: adopt the venue's authoritative open + // positions before the first tick so a restarted process never + // doubles exposure against inventory it already owns. The restate + // itself is applied by the hot loop from the restate queue. + reconcile_once_with(*live_client, cfg, tracker, *restate_q); + + reconcile_thread = std::thread([&] { + pin_to_cpu(cfg.cold_cpu); + bool cancel_sent = false; + uint64_t ticks_100ms = 0; + const uint64_t rounds = cfg.reconcile_interval_sec != 0 + ? cfg.reconcile_interval_sec * 10 : 0; + while (workers_running.load(std::memory_order_acquire)) { + // Fast kill watcher: one cancel-all per kill latch. + if (risk.killed() && !cancel_sent) { + const bool ok = live_client->cancel_all(); + std::fprintf(stderr, + "RCN kill-switch latched: cancel-all %s\n", + ok ? "acknowledged" : "FAILED (operator action required)"); + cancel_sent = true; + } + if (rounds != 0 && ++ticks_100ms >= rounds) { + ticks_100ms = 0; + reconcile_once_with(*live_client, cfg, tracker, + *restate_q); + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + }); + kill_switch_thread = std::thread([&] { pin_to_cpu(cfg.cold_cpu); while (workers_running.load(std::memory_order_acquire)) { @@ -230,6 +480,10 @@ int main() { while (workers_running.load(std::memory_order_acquire)) { OrderBookL2::Top top{}; if (book->read_top(top) && top.bid.size && top.ask.size) { + // Feed the adverse-selection regime sampler (P3) on every + // observed top, ~100 Hz while the book is fresh. + volatility.sample(top.bid.price, top.ask.price, + crowdintel::mono_ns(), cfg.presign_ttl_ms); const uint64_t tick = book->tick_size(cfg.tick_size); const uint64_t now = PresignedOrderPool::now_ms(); if (top.bid.price != last_bid || top.ask.price != last_ask || @@ -258,13 +512,15 @@ int main() { if (mock) { ExecutionEngine engine( - cfg, *book, *signals, signer, pool, mock_client, &trading_enabled); + cfg, *book, *signals, signer, pool, mock_client, &trading_enabled, + &layers); run_engine(engine, cfg, counts, RESULT_COUNT); } #if defined(CROWDINTEL_LIVE) else { ExecutionEngine> engine( - cfg, *book, *signals, signer, pool, *gateway, &trading_enabled); + cfg, *book, *signals, signer, pool, *gateway, &trading_enabled, + &layers); run_engine(engine, cfg, counts, RESULT_COUNT); } #endif @@ -272,12 +528,30 @@ int main() { workers_running.store(false, std::memory_order_release); #if defined(CROWDINTEL_LIVE) if (alpha_receiver) alpha_receiver->stop(); + if (user_listener) user_listener->stop(); if (market_listener) market_listener->stop(); + if (hedge_listener) hedge_listener->stop(); if (gateway) gateway->stop(); #endif + if (mock_evidence_thread.joinable()) mock_evidence_thread.join(); if (mock_thread.joinable()) mock_thread.join(); if (kill_switch_thread.joinable()) kill_switch_thread.join(); + if (reconcile_thread.joinable()) reconcile_thread.join(); + if (evidence_replayer) { + evidence_replayer->stop(); + std::fprintf(stdout, " evidence replay parsed=%llu dropped=%llu\n", + static_cast(evidence_replayer->parsed()), + static_cast(evidence_replayer->dropped())); + } + if (cfg.bayes_enable) + std::fprintf(stdout, + " bayes events=%llu count=%llu lr=%llu posterior=%.4f\n", + static_cast(bayes.events()), + static_cast(bayes.count_events()), + static_cast(bayes.lr_events()), + bayes.posterior()); presign_thread.join(); + journal_thread.join(); const double seconds = std::chrono::duration( std::chrono::steady_clock::now() - start).count(); @@ -287,6 +561,24 @@ int main() { tick_result_name(static_cast(i)), static_cast(counts[i])); } + PositionTracker::Snapshot tracker_view{}; + if (tracker.snapshot(tracker_view)) { + std::fprintf(stdout, + " inventory yes=%llu@%.4f hedge=%llu open_buy=%llu open_sell=%llu " + "fills=%llu anomalies=%llu realized_pnl=%.4f\n", + static_cast(tracker_view.net_yes), + static_cast(tracker_view.yes_avg) * 1e-6, + static_cast(tracker_view.net_hedge), + static_cast(tracker_view.open_buy), + static_cast(tracker_view.open_sell), + static_cast(tracker_view.fills), + static_cast(tracker_view.anomalies), + static_cast(tracker_view.realized_pnl) * 1e-6); + } + if (mock) std::fprintf(stdout, + " paper orders=%llu fills=%llu\n", + static_cast(mock_client.submissions()), + static_cast(mock_client.paper_fills())); #if defined(CROWDINTEL_LIVE) if (gateway) std::fprintf(stdout, " gateway enqueued=%llu accepted=%llu rejected=%llu dropped=%llu retries=%llu cancelled=%llu\n", @@ -299,6 +591,12 @@ int main() { if (market_listener) std::fprintf(stdout, " ws events=%llu reconnects=%llu\n", static_cast(market_listener->events_seen()), static_cast(market_listener->reconnects())); + if (user_listener) std::fprintf(stdout, + " user-ws events=%llu pushed=%llu dropped=%llu reconnects=%llu\n", + static_cast(user_listener->events_seen()), + static_cast(user_listener->pushed()), + static_cast(user_listener->dropped()), + static_cast(user_listener->reconnects())); if (alpha_receiver) std::fprintf(stdout, " alpha accepted=%llu rejected=%llu\n", static_cast(alpha_receiver->accepted()), static_cast(alpha_receiver->rejected())); diff --git a/core/src/market_config.hpp b/core/src/market_config.hpp index c06a849..0e29a6b 100644 --- a/core/src/market_config.hpp +++ b/core/src/market_config.hpp @@ -42,6 +42,11 @@ struct MarketConfig { uint64_t gtd_ttl_seconds = 0; char clob_host[128] = "https://clob.polymarket.com"; char ws_host[192] = "wss://ws-subscriptions-clob.polymarket.com/ws/market"; + // Private user channel (order/fill reconciliation) and hedge complement. + char ws_user_host[192] = "wss://ws-subscriptions-clob.polymarket.com/ws/user"; + char market_condition_id[80]{}; // optional 0x…64hex for subscription scoping + char hedge_token_id_dec[80]{}; // optional complement token id (binary pair) + uint8_t hedge_token_id_be[32]{}; // Strategy / risk. Values are deliberately conservative until account // reconciliation is implemented. @@ -55,11 +60,37 @@ struct MarketConfig { double max_exposure_usd = 250.0; double max_daily_loss_usd = 50.0; uint64_t initial_position_shares = 0; + double initial_position_avg_price = 0.50; // VWAP of supplied inventory uint64_t min_size_shares = 5000000; uint64_t presign_ttl_ms = 3000; uint64_t signal_ttl_ms = 2000; uint64_t max_book_age_ms = 3000; + // ── Brakes (P2) ───────────────────────────────────────────────────────── + double stop_loss_pct = 0.15; // mark drop vs VWAP entry; 0 disables + double hedge_trigger_pct = 0.0; // hedge trigger before stop; 0 disables + double max_portfolio_exposure_usd = 250.0; + uint64_t reservation_ttl_ms = 10000; // unconfirmed reservation release + uint64_t reconcile_interval_sec = 30; // REST reconciliation; 0 disables + double reconcile_max_drift_shares = 0.01; // max tolerated |REST−local| + + // ── Adverse selection (P3) ────────────────────────────────────────────── + double pool_max_dev_bps = 200.0; // signed-price vs current mid guard + uint64_t pool_vol_ttl_ms = 500; // ladder TTL while volatility is high + double vol_max_spread_bps = 1500.0; // spread regime threshold + uint64_t vol_max_ticks_per_sec = 200; // markdown tick-rate threshold + double vol_mid_gap_bps = 500.0; // EMA mid-gap regime threshold + double vol_size_multiplier = 0.5; // passive size scale in volatile regime + + // ── Brain (P4) ────────────────────────────────────────────────────────── + double bayes_prior_strength = 24.0; // prior pseudo-count N0 + double bayes_signal_threshold = 0.03; // posterior-vs-price edge gate + double bayes_min_reliability = 0.35; // source weight gate [0,1] + char bayes_sources[512]{}; // "id:weight,id:weight,…" (0..1) + char bayes_recal_file[192]{}; // optional cold recalibration file + char evidence_file[192]{}; // NDJSON evidence replay/tail file + bool bayes_enable = true; + // Alpha HTTP receiver (designed to sit behind a TLS/auth reverse proxy). char alpha_bind[64] = "127.0.0.1"; uint16_t alpha_port = 8088; @@ -129,7 +160,10 @@ struct MarketConfig { long neg_risk_l = 0, sig = 0, presign_l = 0, signal_l = 0; long book_age_l = 0, pin_l = -1, cold_l = -1, ticks_l = 0; long armed_l = 0, gtd_l = 0; + long reservation_ttl_l = 10000, reconcile_l = 30, pool_vol_ttl_l = 500; + long vol_ticks_l = 200, bayes_on_l = 1; double tick = 0, min_size = 0, initial_position = 0; + double initial_avg = 0.50; if (!env_l("BOT_NEG_RISK", 0, neg_risk_l) || !env_l("BOT_SIGNATURE_TYPE", 0, sig) || !env_d("BOT_TICK_SIZE", 0.01, tick) || @@ -145,6 +179,27 @@ struct MarketConfig { max_daily_loss_usd) || !env_d("BOT_MIN_SIZE_SHARES", 5.0, min_size) || !env_d("BOT_INITIAL_POSITION_SHARES", 0.0, initial_position) || + !env_d("BOT_INITIAL_POSITION_AVG_PRICE", 0.50, initial_avg) || + !env_d("BOT_STOP_LOSS_PCT", stop_loss_pct, stop_loss_pct) || + !env_d("BOT_HEDGE_TRIGGER_PCT", hedge_trigger_pct, + hedge_trigger_pct) || + !env_d("BOT_MAX_PORTFOLIO_EXPOSURE_USD", + max_portfolio_exposure_usd, max_portfolio_exposure_usd) || + !env_d("BOT_POOL_MAX_DEV_BPS", pool_max_dev_bps, + pool_max_dev_bps) || + !env_d("BOT_VOL_MAX_SPREAD_BPS", vol_max_spread_bps, + vol_max_spread_bps) || + !env_d("BOT_VOL_MID_GAP_BPS", vol_mid_gap_bps, vol_mid_gap_bps) || + !env_d("BOT_VOL_SIZE_MULTIPLIER", vol_size_multiplier, + vol_size_multiplier) || + !env_d("BOT_BAYES_PRIOR_STRENGTH", bayes_prior_strength, + bayes_prior_strength) || + !env_d("BOT_BAYES_SIGNAL_THRESHOLD", bayes_signal_threshold, + bayes_signal_threshold) || + !env_d("BOT_BAYES_MIN_RELIABILITY", bayes_min_reliability, + bayes_min_reliability) || + !env_d("BOT_RECONCILE_MAX_DRIFT_SHARES", + reconcile_max_drift_shares, reconcile_max_drift_shares) || !env_l("BOT_PRESIGN_TTL_MS", static_cast(presign_ttl_ms), presign_l) || !env_l("BOT_SIGNAL_TTL_MS", static_cast(signal_ttl_ms), @@ -155,7 +210,12 @@ struct MarketConfig { !env_l("BOT_COLD_CPU", -1, cold_l) || !env_l("BOT_TICKS", 0, ticks_l) || !env_l("BOT_ENABLE_LIVE_TRADING", 0, armed_l) || - !env_l("BOT_GTD_TTL_SECONDS", 0, gtd_l)) + !env_l("BOT_GTD_TTL_SECONDS", 0, gtd_l) || + !env_l("BOT_RESERVATION_TTL_MS", 10000, reservation_ttl_l) || + !env_l("BOT_RECONCILE_INTERVAL_SEC", 30, reconcile_l) || + !env_l("BOT_POOL_VOL_TTL_MS", 500, pool_vol_ttl_l) || + !env_l("BOT_VOL_MAX_TICKS_PER_SEC", 200, vol_ticks_l) || + !env_l("BOT_BAYES_ENABLE", 1, bayes_on_l)) return "invalid numeric configuration value"; if ((neg_risk_l != 0 && neg_risk_l != 1) || @@ -188,6 +248,38 @@ struct MarketConfig { pin_l < -1 || cold_l < -1 || pin_l > INT_MAX || cold_l > INT_MAX) return "runtime duration/CPU configuration is out of range"; + if (!(initial_avg >= 0.0 && initial_avg <= 1.0)) + return "BOT_INITIAL_POSITION_AVG_PRICE must be within [0, 1]"; + constexpr uint64_t MAX_SAFE_MS2 = UINT64_MAX / 1000000ULL; + if (reservation_ttl_l < 1000 || + static_cast(reservation_ttl_l) > MAX_SAFE_MS2 || + reconcile_l < 0 || reconcile_l > 86400 || + pool_vol_ttl_l <= 0 || + static_cast(pool_vol_ttl_l) > MAX_SAFE_MS2 || + vol_ticks_l < 0 || vol_ticks_l > 1000000 || + (bayes_on_l != 0 && bayes_on_l != 1)) + return "layer2+ duration/rate configuration is out of range"; + if (stop_loss_pct < 0.0 || stop_loss_pct > 1.0 || + hedge_trigger_pct < 0.0 || hedge_trigger_pct > 1.0 || + max_portfolio_exposure_usd <= 0.0 || + pool_max_dev_bps < 0.0 || pool_max_dev_bps > 5000.0 || + vol_max_spread_bps <= 0.0 || vol_max_spread_bps > 9000.0 || + vol_mid_gap_bps <= 0.0 || vol_mid_gap_bps > 5000.0 || + vol_size_multiplier < 0.0 || vol_size_multiplier > 1.0 || + bayes_prior_strength <= 0.0 || bayes_prior_strength > 100000.0 || + bayes_signal_threshold <= 0.0 || bayes_signal_threshold >= 1.0 || + bayes_min_reliability < 0.0 || bayes_min_reliability > 1.0 || + reconcile_max_drift_shares < 0.0 || + reconcile_max_drift_shares > 1000000.0) + return "invalid brakes/adverse-selection/brain configuration value"; + if (hedge_trigger_pct > 0.0 && stop_loss_pct > 0.0 && + hedge_trigger_pct >= stop_loss_pct) + return "BOT_HEDGE_TRIGGER_PCT must fire strictly before BOT_STOP_LOSS_PCT"; + reservation_ttl_ms = static_cast(reservation_ttl_l); + reconcile_interval_sec = static_cast(reconcile_l); + pool_vol_ttl_ms = static_cast(pool_vol_ttl_l); + vol_max_ticks_per_sec = static_cast(vol_ticks_l); + bayes_enable = bayes_on_l == 1; neg_risk = neg_risk_l == 1; signature_type = static_cast(sig); if (signature_type == 3) @@ -196,6 +288,7 @@ struct MarketConfig { min_size_shares = static_cast(std::round(min_size_scaled)); initial_position_shares = static_cast(std::round(initial_position_scaled)); + initial_position_avg_price = initial_avg; presign_ttl_ms = static_cast(presign_l); signal_ttl_ms = static_cast(signal_l); max_book_age_ms = static_cast(book_age_l); @@ -213,13 +306,23 @@ struct MarketConfig { if (!copy_env(order_type, "BOT_ORDER_TYPE", order_type) || !copy_env(clob_host, "CLOB_HOST", clob_host) || !copy_env(ws_host, "WS_HOST", ws_host) || + !copy_env(ws_user_host, "BOT_WS_USER_HOST", ws_user_host) || + !copy_env(market_condition_id, "BOT_MARKET_CONDITION_ID", "") || + !copy_env(hedge_token_id_dec, "BOT_HEDGE_TOKEN_ID", "") || !copy_env(tls_pin, "BOT_TLS_PIN", "") || !copy_env(kill_switch_file, "BOT_KILL_SWITCH_FILE", kill_switch_file) || !copy_env(market_slug, "BOT_MARKET_SLUG", mock_mode ? "mock-market" : "") || + !copy_env(bayes_sources, "BOT_BAYES_SOURCES", "") || + !copy_env(bayes_recal_file, "BOT_BAYES_RECAL_FILE", "") || + !copy_env(evidence_file, "BOT_EVIDENCE_FILE", "") || !copy_env(alpha_bind, "BOT_ALPHA_BIND", alpha_bind)) return "configuration string exceeds its bounded capacity"; + if (!valid_bayes_sources(bayes_sources)) + return "BOT_BAYES_SOURCES must be 'id:weight' pairs (weight in [0,1])"; + if (!safe_config_text(bayes_recal_file)) + return "BOT_BAYES_RECAL_FILE cannot contain control characters"; if (!safe_config_text(order_type) || !safe_config_text(clob_host) || !safe_config_text(ws_host) || !safe_config_text(tls_pin) || !safe_config_text(kill_switch_file) || @@ -261,6 +364,11 @@ struct MarketConfig { return "CLOB_HOST must be an origin-only https:// URL in live mode"; if (!mock_mode && !valid_secure_url(ws_host, "wss://", true)) return "WS_HOST must be a valid wss:// URL in live mode"; + if (!mock_mode && !valid_secure_url(ws_user_host, "wss://", true)) + return "BOT_WS_USER_HOST must be a valid wss:// URL in live mode"; + if (market_condition_id[0] && + !valid_condition_id(market_condition_id)) + return "BOT_MARKET_CONDITION_ID must be 0x followed by 64 lowercase hex chars"; if (need_trading_creds) { if (!live_armed) @@ -304,6 +412,19 @@ struct MarketConfig { for (const uint8_t byte : token_id_be) token_nonzero |= byte != 0; if (!token_nonzero) return "BOT_TOKEN_ID cannot be zero"; std::memcpy(token_id_dec, tok, token_len + 1); + if (hedge_token_id_dec[0]) { + const size_t hedge_len = std::strlen(hedge_token_id_dec); + if (hedge_len >= sizeof(hedge_token_id_dec) || + (hedge_len > 1 && hedge_token_id_dec[0] == '0') || + !parse_uint256_dec(hedge_token_id_dec, hedge_len, + hedge_token_id_be) || + std::strcmp(hedge_token_id_dec, token_id_dec) == 0) + return "BOT_HEDGE_TOKEN_ID is not a canonical nonzero uint256 distinct from BOT_TOKEN_ID"; + bool hedge_nonzero = false; + for (const uint8_t byte : hedge_token_id_be) + hedge_nonzero |= byte != 0; + if (!hedge_nonzero) return "BOT_HEDGE_TOKEN_ID cannot be zero"; + } return nullptr; } @@ -343,6 +464,45 @@ struct MarketConfig { } private: + static bool valid_bayes_sources(const char* sources) noexcept { + if (!sources) return false; + if (!*sources) return true; // empty = all sources at default weight + const char* p = sources; + while (*p) { + const char* colon = std::strchr(p, ':'); + if (!colon || colon == p) return false; + long id = 0; + for (const char* c = p; c < colon; ++c) { + if (*c < '0' || *c > '9') return false; + id = id * 10 + (*c - '0'); + if (id > 255) return false; + } + const char* v = colon + 1; + if (*v == '\0') return false; + char* end = nullptr; + errno = 0; + const double w = std::strtod(v, &end); + if (errno == ERANGE || !end || !std::isfinite(w) || + w < 0.0 || w > 1.0) + return false; + if (*end == '\0') return true; + if (*end != ',') return false; + p = end + 1; + if (*p == '\0') return false; + } + return true; + } + + static bool valid_condition_id(const char* id) noexcept { + if (!id || id[0] != '0' || id[1] != 'x' || std::strlen(id) != 66) + return false; + for (size_t i = 2; i < 66; ++i) + if (!(id[i] >= '0' && id[i] <= '9') && + !(id[i] >= 'a' && id[i] <= 'f')) + return false; + return true; + } + static bool valid_market_slug(const char* slug) noexcept { if (!slug || !*slug) return false; for (const unsigned char* p = diff --git a/core/src/mock_client.hpp b/core/src/mock_client.hpp index a718fd7..62fa0e9 100644 --- a/core/src/mock_client.hpp +++ b/core/src/mock_client.hpp @@ -4,29 +4,120 @@ #include #include #include +#include +#include "../include/account_events.hpp" +#include "../include/spsc_ring_buffer.hpp" +#include "market_config.hpp" #include "polymarket_order.hpp" class MockCLOBClient { public: - explicit MockCLOBClient(const struct MarketConfig&) {} + explicit MockCLOBClient(const MarketConfig& cfg) + : token_id_dec_(cfg.token_id_dec), + hedge_token_id_dec_(cfg.hedge_token_id_dec) {} - SubmitResult submit(const WireBody&) { - submissions_.fetch_add(1, std::memory_order_relaxed); + // Paper-trading fill emission (P1/P4 paper mode). When a queue is + // attached, every accepted ("matched") order synthesizes the venue FILL + // into it — the offline substitute for the private user channel, so + // mock/paper sessions exercise tracker, brakes, and brain end to end. + // The benchmark and unit fixtures never attach a queue: their wire cost + // stays byte-for-byte identical to the pre-paper behavior. + void attach_fill_queue(SPSC_RingBuffer* q) noexcept { + fill_q_ = q; + } + + SubmitResult submit(const WireBody& body) { + const uint64_t seq = submissions_.fetch_add( + 1, std::memory_order_relaxed) + 1; SubmitResult result{}; result.ok = true; result.final = true; result.http_code = 200; std::snprintf(result.status, sizeof(result.status), "matched"); + emit_fill(body, seq); return result; } + // Cold-path REST surface used by the reconcile thread (mock equivalents). + size_t rest_get(const char*, char*, size_t) { return 0; } + bool cancel_all() { + cancellations_.fetch_add(1, std::memory_order_relaxed); + return true; + } + uint64_t submissions() const { return submissions_.load(std::memory_order_relaxed); } + uint64_t cancellations() const { + return cancellations_.load(std::memory_order_relaxed); + } + uint64_t paper_fills() const { + return fills_.load(std::memory_order_relaxed); + } private: + // Wire body fields (see polymarket_order.hpp): + // BUY : makerAmount = USDC cost, takerAmount = size (shares x1e6) + // SELL: makerAmount = size, takerAmount = USDC cost + static const char* scan_key(const char* within, const char* key) { + const char* hit = std::strstr(within, key); + return hit ? hit + std::strlen(key) : nullptr; + } + static bool scan_u64(const char* within, const char* key, + uint64_t& out) { + const char* p = scan_key(within, key); + if (!p) return false; + uint64_t value = 0; + size_t digits = 0; + while (*p >= '0' && *p <= '9') { + value = value * 10ULL + static_cast(*p - '0'); + ++p; + ++digits; + if (digits > 20) return false; + } + if (digits == 0) return false; + out = value; + return true; + } + static bool scan_flag(const char* within, const char* key) { + return std::strstr(within, key) != nullptr; + } + + void emit_fill(const WireBody& body, uint64_t seq) { + if (!fill_q_) return; + const char* json = body.buf; + const bool is_buy = scan_flag(json, "\"side\":\"BUY\""); + const bool is_sell = scan_flag(json, "\"side\":\"SELL\""); + if (!is_buy && !is_sell) return; + uint64_t maker = 0, taker = 0; + if (!scan_u64(json, "\"makerAmount\":\"", maker) || + !scan_u64(json, "\"takerAmount\":\"", taker)) + return; + const uint64_t size = is_buy ? taker : maker; + const uint64_t cost = is_buy ? maker : taker; + if (size == 0 || cost == 0) return; + AccountEvent ev{}; + ev.type = AccountEvent::Type::FILL; + ev.side = is_buy ? 0 : 1; + // Recover the execution price (paper precision is sub-tick). + ev.price = static_cast( + (static_cast(cost) * 1000000ULL) / size); + ev.size = size; + ev.event_id = 0xF1C5'0000ULL ^ seq; // unique per submission + ev.timestamp_ns = 0; // engine stamps on ingest + fill_q_->try_push(ev); + fills_.fetch_add(1, std::memory_order_relaxed); + (void)token_id_dec_; + (void)hedge_token_id_dec_; + } + + const char* token_id_dec_; + const char* hedge_token_id_dec_; + SPSC_RingBuffer* fill_q_ = nullptr; std::atomic submissions_{0}; + std::atomic cancellations_{0}; + std::atomic fills_{0}; }; #endif // MOCK_CLIENT_HPP diff --git a/core/src/polymarket_order.hpp b/core/src/polymarket_order.hpp index acba411..4eaf3d7 100644 --- a/core/src/polymarket_order.hpp +++ b/core/src/polymarket_order.hpp @@ -23,6 +23,7 @@ #if defined(__SIZEOF_INT128__) __extension__ typedef unsigned __int128 crowd_uint128_t; +__extension__ typedef __int128 crowd_int128_t; #else #error "CrowdIntel exact amount arithmetic requires compiler uint128 support" #endif diff --git a/core/src/presigned_pool.hpp b/core/src/presigned_pool.hpp index 53f2afb..e1938c7 100644 --- a/core/src/presigned_pool.hpp +++ b/core/src/presigned_pool.hpp @@ -116,10 +116,15 @@ class PresignedOrderPool { } // Largest unconsumed bucket <= max_size. Returns signed amounts so the - // risk ledger reserves exactly what will be submitted. + // risk ledger reserves exactly what will be submitted. `max_age_ms` is an + // optional dynamic TTL cap from the volatility gate (P3): when non-zero + // and tighter than the static pool TTL, it wins. 0 = static TTL only. bool acquire_at_most(uint8_t side, uint64_t price, uint64_t tick_size, uint64_t max_size, WireBody& out, uint64_t& actual_size, - uint64_t& maker_amount, uint64_t& taker_amount) const { + uint64_t& maker_amount, uint64_t& taker_amount, + uint64_t max_age_ms = 0) const { + const uint64_t ttl_ms = + max_age_ms != 0 && max_age_ms < ttl_ms_ ? max_age_ms : ttl_ms_; for (int retry = 0; retry < 8; ++retry) { const uint32_t index = active_.load(std::memory_order_acquire); uint32_t access = access_[index].load(std::memory_order_acquire); @@ -142,7 +147,7 @@ class PresignedOrderPool { if (meta.side == side && meta.price == price && meta.tick_size == tick_size && meta.size <= max_size && meta.size > best_size && - now >= meta.built_ms && now - meta.built_ms <= ttl_ms_ && + now >= meta.built_ms && now - meta.built_ms <= ttl_ms && meta.consumed.load(std::memory_order_relaxed) == 0) { best = i; best_size = meta.size; diff --git a/core/src/user_event_parser.hpp b/core/src/user_event_parser.hpp new file mode 100644 index 0000000..7e79fc5 --- /dev/null +++ b/core/src/user_event_parser.hpp @@ -0,0 +1,215 @@ +#ifndef USER_EVENT_PARSER_HPP +#define USER_EVENT_PARSER_HPP + +// ───────────────────────────────────────────────────────────────────────────── +// UserEventParser: pure, offline-testable normalizer of Polymarket's PRIVATE +// user-channel messages into AccountEvents. +// +// Recognized venue facts (docs.polymarket.com, user channel): +// * order events: event_type="order", type=PLACEMENT|CANCELLATION|UPDATE +// * trade events: event_type="trade", type=MATCHED|MINED|CONFIRMED| +// RETRYING|FAILED; `trader_side` tells whether the user was +// MAKER or TAKER; `maker_orders` carries the maker fills +// (relevant when our resting order was hit). +// +// The parser is deliberately strict, matching the repository's fail-closed +// posture: duplicate semantic keys, malformed numbers and unknown sides are +// dropped rather than guessed. Parsing happens on the user-WS thread (cold); +// only normalized fixed-point events enter the hot queue. +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include +#include + +#include "../include/account_events.hpp" +#include "../include/bounded_json.hpp" +#include "../include/time_utils.hpp" +#include "json_fields.hpp" +#include "polymarket_order.hpp" // parse_fixed1e6 + +class UserEventParser { +public: + UserEventParser(const char* token_id_dec, const char* hedge_token_id_dec, + uint64_t market_hash) + : market_hash_(market_hash) { + if (token_id_dec) { + std::snprintf(token_, sizeof(token_), "%s", token_id_dec); + token_[sizeof(token_) - 1] = '\0'; + } + if (hedge_token_id_dec && hedge_token_id_dec[0]) { + std::snprintf(hedge_token_, sizeof(hedge_token_), "%s", + hedge_token_id_dec); + hedge_token_[sizeof(hedge_token_) - 1] = '\0'; + } + } + + // Parse one venue message. Returns true and fills `out` when the message + // is a normalized account fact for OUR configured asset(s). + bool parse(const char* json, size_t len, AccountEvent& out) const { + if (!json || len == 0 || !bounded_json::valid_document(json, len)) + return false; + const char* end = json + len; + + char event[24]{}; + if (!extract_unique(json, end, "event_type", event, sizeof(event)) && + !extract_unique(json, end, "type", event, sizeof(event))) + return false; + + if (std::strcmp(event, "order") == 0) return parse_order(json, end, out); + if (std::strcmp(event, "trade") == 0) return parse_trade(json, end, out); + return false; + } + + static bool is_user_message(const char* json, size_t len) { + if (!json || len == 0 || !bounded_json::valid_document(json, len)) + return false; + const char* end = json + len; + char event[24]{}; + return (extract_unique(json, end, "event_type", event, sizeof(event)) || + extract_unique(json, end, "type", event, sizeof(event))) && + (std::strcmp(event, "order") == 0 || + std::strcmp(event, "trade") == 0); + } + +private: + // A field is used only when it occurs exactly once at the top level. + static bool extract_unique(const char* begin, const char* end, + const char* key, char* out, size_t cap) { + return json_fields::extract_string(begin, end, key, out, cap); + } + + uint8_t asset_role(const char* asset) const { + if (token_[0] && std::strcmp(asset, token_) == 0) return 0; + if (hedge_token_[0] && std::strcmp(asset, hedge_token_) == 0) return 1; + return 255; // not our asset + } + + static bool parse_side(const char* text, uint8_t& side) { + if (text[0] == 'B' || text[0] == 'b') { side = 0; return true; } + if (text[0] == 'S' || text[0] == 's') { side = 1; return true; } + return false; + } + + bool parse_order(const char* json, const char* end, + AccountEvent& out) const { + char type[16]{}, id[96]{}, asset[96]{}, side_text[8]{}; + char price_text[24]{}, matched_text[32]{}, original_text[32]{}; + + if (!extract_unique(json, end, "id", id, sizeof(id)) || !id[0]) + return false; + // asset_id may be absent on order events for single-asset streams + if (!extract_unique(json, end, "asset_id", asset, sizeof(asset))) + return false; + const uint8_t role = asset_role(asset); + if (role == 255) return false; + if (!extract_unique(json, end, "type", type, sizeof(type)) && + !extract_unique(json, end, "order_status", type, sizeof(type))) + return false; + + AccountEvent ev{}; + ev.asset = role; + ev.market_hash = market_hash_; + ev.order_hash = json_fields::fnv_hash(id, std::strlen(id)); + ev.event_id = ev.order_hash ^ + (json_fields::fnv_hash(type, std::strlen(type)) << 1); + ev.timestamp_ns = crowdintel::realtime_ns(); + + uint64_t price = 0; + if (extract_unique(json, end, "price", price_text, sizeof(price_text))) + (void)parse_fixed1e6(price_text, std::strlen(price_text), price); + ev.price = price; + + uint64_t matched = 0, original = 0; + if (extract_unique(json, end, "size_matched", matched_text, + sizeof(matched_text))) + (void)parse_fixed1e6(matched_text, std::strlen(matched_text), + matched); + if (extract_unique(json, end, "original_size", original_text, + sizeof(original_text))) + (void)parse_fixed1e6(original_text, std::strlen(original_text), + original); + + if (!extract_unique(json, end, "side", side_text, + sizeof(side_text)) || + !parse_side(side_text, ev.side)) + return false; + ev.remaining = original > matched ? original - matched : 0; + + if (std::strcmp(type, "PLACEMENT") == 0 || + std::strcmp(type, "LIVE") == 0) { + ev.type = AccountEvent::Type::OPEN; + ev.size = ev.remaining; + } else if (std::strcmp(type, "CANCELLATION") == 0 || + std::strcmp(type, "CANCELLED") == 0 || + std::strcmp(type, "CANCEL") == 0) { + ev.type = AccountEvent::Type::CANCEL; + ev.size = ev.remaining; + } else if (std::strcmp(type, "UPDATE") == 0) { + ev.type = AccountEvent::Type::OPEN; // restate remaining + ev.size = ev.remaining; + } else { + return false; + } + out = ev; + return true; + } + + bool parse_trade(const char* json, const char* end, + AccountEvent& out) const { + char type[16]{}, id[96]{}, asset[96]{}, side_text[8]{}; + char price_text[24]{}, size_text[32]{}, status[24]{}; + char taker_order[96]{}; + + if (!extract_unique(json, end, "type", type, sizeof(type))) + return false; + const bool matched = std::strcmp(type, "MATCHED") == 0; + const bool mined = std::strcmp(type, "MINED") == 0 || + std::strcmp(type, "CONFIRMED") == 0; + const bool failed = std::strcmp(type, "FAILED") == 0 || + std::strcmp(type, "RETRYING") == 0; + if (!matched && !mined && !failed) return false; + + if (!extract_unique(json, end, "id", id, sizeof(id)) || !id[0]) + return false; + if (!extract_unique(json, end, "asset_id", asset, sizeof(asset))) + return false; + const uint8_t role = asset_role(asset); + if (role == 255) return false; + if (!extract_unique(json, end, "side", side_text, sizeof(side_text)) || + !extract_unique(json, end, "price", price_text, + sizeof(price_text)) || + !extract_unique(json, end, "size", size_text, sizeof(size_text))) + return false; + + AccountEvent ev{}; + ev.asset = role; + if (!parse_side(side_text, ev.side)) return false; + if (!parse_fixed1e6(price_text, std::strlen(price_text), ev.price) || + !parse_fixed1e6(size_text, std::strlen(size_text), ev.size) || + ev.size == 0 || ev.price == 0) + return false; + ev.market_hash = market_hash_; + ev.event_id = json_fields::fnv_hash(id, std::strlen(id)); + ev.timestamp_ns = crowdintel::realtime_ns(); + if (extract_unique(json, end, "taker_order_id", taker_order, + sizeof(taker_order))) + ev.order_hash = + json_fields::fnv_hash(taker_order, std::strlen(taker_order)); + if (extract_unique(json, end, "status", status, sizeof(status)) && + std::strcmp(status, "FAILED") == 0) + ev.type = AccountEvent::Type::FAILED; + else + ev.type = matched ? AccountEvent::Type::FILL + : (mined ? AccountEvent::Type::FILL_MINED + : AccountEvent::Type::FAILED); + out = ev; + return true; + } + + uint64_t market_hash_ = 0; + char token_[96]{}; + char hedge_token_[96]{}; +}; + +#endif // USER_EVENT_PARSER_HPP diff --git a/core/src/ws_market_listener.hpp b/core/src/ws_market_listener.hpp index c1163a6..5d9448a 100644 --- a/core/src/ws_market_listener.hpp +++ b/core/src/ws_market_listener.hpp @@ -31,8 +31,13 @@ class WsMarketListener { public: - WsMarketListener(const MarketConfig& cfg, OrderBookL2& book) - : cfg_(cfg), book_(book) {} + // `subscribe_token` overrides the subscribed asset id; when null the + // primary cfg token is used. A second instance on the complement token + // feeds the hedge book (P2 brakes) through an isolated connection, so + // book ownership stays single-writer per book. + WsMarketListener(const MarketConfig& cfg, OrderBookL2& book, + const char* subscribe_token = nullptr) + : cfg_(cfg), book_(book), subscribe_token_(subscribe_token) {} ~WsMarketListener() { stop(); } void start() { @@ -161,10 +166,12 @@ class WsMarketListener { cleanup(ssl, fd); return false; } - char subscription[256]; + const char* asset = subscribe_token_ && subscribe_token_[0] + ? subscribe_token_ : cfg_.token_id_dec; + char subscription[320]; const int subscription_len = std::snprintf(subscription, sizeof(subscription), "{\"assets_ids\":[\"%s\"],\"type\":\"market\"," - "\"custom_feature_enabled\":true}", cfg_.token_id_dec); + "\"custom_feature_enabled\":true}", asset); if (subscription_len <= 0 || static_cast(subscription_len) >= sizeof(subscription) || !send_frame(ssl, fd, 0x1, subscription, @@ -585,7 +592,7 @@ class WsMarketListener { extract_string(object, object_end + 1, "tokenId", asset, sizeof(asset))); if ((asset_keys == 0 || has_asset) && - (!has_asset || std::strcmp(asset, cfg_.token_id_dec) == 0)) { + (!has_asset || std::strcmp(asset, subscription_asset()) == 0)) { char price_text[24]{}, size_text[24]{}, side_text[8]{}; if (extract_string(object, object_end + 1, "price", price_text, sizeof(price_text)) && @@ -643,7 +650,7 @@ class WsMarketListener { if (asset_keys + token_keys > 1) return false; if (extract_string(json, end, "asset_id", token, sizeof(token)) || extract_string(json, end, "tokenId", token, sizeof(token))) - return std::strcmp(token, cfg_.token_id_dec) == 0; + return std::strcmp(token, subscription_asset()) == 0; return true; // legacy fixtures omit it; subscription is token-scoped } diff --git a/core/src/ws_user_listener.hpp b/core/src/ws_user_listener.hpp new file mode 100644 index 0000000..13ef60a --- /dev/null +++ b/core/src/ws_user_listener.hpp @@ -0,0 +1,647 @@ +#ifndef WS_USER_LISTENER_HPP +#define WS_USER_LISTENER_HPP + +// ───────────────────────────────────────────────────────────────────────────── +// WsUserListener: Polymarket PRIVATE user channel (wss://…/ws/user). +// +// Receives order placements/cancellations and trade MATCHED/MINED/FAILED +// events for the configured market with L2 API-key authentication, normalizes +// them through UserEventParser, and pushes AccountEvents into the account SPSC +// queue (sole producer: this thread; sole consumer: the hot loop). +// +// The transport reuses the same hand-rolled WSS pattern as WsMarketListener. +// The duplication of the transport layer is deliberate and documented: the +// market listener is already pen-tested, so converging both on a shared +// refactor would add regression risk with no behavioral gain here. Only the +// parsers and helpers are shared (json_fields, bounded_json). +// ───────────────────────────────────────────────────────────────────────────── + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../crypto/fast_random.hpp" +#include "../crypto/sha256_engine.hpp" +#include "../include/bounded_json.hpp" +#include "../include/spsc_ring_buffer.hpp" +#include "market_config.hpp" +#include "user_event_parser.hpp" + +class WsUserListener { +public: + WsUserListener(const MarketConfig& cfg, + SPSC_RingBuffer& account_queue) + : cfg_(cfg), queue_(account_queue), + parser_(cfg.token_id_dec, cfg.hedge_token_id_dec, cfg.market_hash) {} + ~WsUserListener() { stop(); } + + void start() { + bool expected = false; + if (!running_.compare_exchange_strong(expected, true)) return; + thread_ = std::thread([this] { run_loop(); }); + } + + void stop() { + running_.store(false, std::memory_order_release); + const int fd = active_fd_.load(std::memory_order_acquire); + if (fd >= 0) ::shutdown(fd, SHUT_RDWR); + if (thread_.joinable()) thread_.join(); + } + + bool connected() const { return connected_.load(std::memory_order_acquire); } + uint64_t events_seen() const { return events_.load(std::memory_order_relaxed); } + uint64_t pushed() const { return pushed_.load(std::memory_order_relaxed); } + uint64_t dropped() const { return dropped_.load(std::memory_order_relaxed); } + uint64_t reconnects() const { return reconnects_.load(std::memory_order_relaxed); } + + // Test hook (offline): normalize one venue message into the queue. + bool handle_message(const char* json, size_t len) { + if (!json || len == 0 || !bounded_json::valid_document(json, len)) + return false; + events_.fetch_add(1, std::memory_order_relaxed); + AccountEvent ev{}; + if (!parser_.parse(json, len, ev)) return false; + if (!queue_.try_push(ev)) { + dropped_.fetch_add(1, std::memory_order_relaxed); + return false; // hot loop must drain faster than fills arrive + } + pushed_.fetch_add(1, std::memory_order_relaxed); + return true; + } + +private: + static constexpr size_t MAX_MESSAGE = 1U << 20; + + void run_loop() { + uint32_t backoff_ms = 250; + while (running_.load(std::memory_order_acquire)) { + rbuf_len_ = 0; + if (session()) backoff_ms = 250; + connected_.store(false, std::memory_order_release); + if (!running_.load(std::memory_order_acquire)) break; + reconnects_.fetch_add(1, std::memory_order_relaxed); + const uint32_t slices = std::max(1U, backoff_ms / 10U); + for (uint32_t i = 0; i < slices && running_.load(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + backoff_ms = std::min(backoff_ms * 2U, 5000U); + } + } + + bool session() { + char host[160]{}, path[192] = "/ws/user"; + int port = 443; + bool tls = true; + if (!parse_url(cfg_.ws_user_host, host, sizeof(host), path, + sizeof(path), port, tls)) + return false; + + const int fd = tcp_connect(host, port, 3000); + if (fd < 0) return false; + active_fd_.store(fd, std::memory_order_release); + set_socket_options(fd); + + SSL* ssl = nullptr; + if (tls) { + SSL_CTX* context = ssl_context(); + if (!context || !(ssl = SSL_new(context))) { + cleanup(nullptr, fd); return false; + } + if (SSL_set_fd(ssl, fd) != 1 || + SSL_set_tlsext_host_name(ssl, host) != 1 || + SSL_set1_host(ssl, host) != 1) { + cleanup(ssl, fd); return false; + } + if (SSL_connect(ssl) != 1 || + SSL_get_verify_result(ssl) != X509_V_OK) { + cleanup(ssl, fd); return false; + } + } + + uint8_t key_raw[16]; + FastRandom random; + for (size_t i = 0; i < sizeof(key_raw); i += 8) { + const uint64_t word = random.next_u64(); + std::memcpy(key_raw + i, &word, 8); + } + char key_b64[32]{}; + base64_encode(key_raw, sizeof(key_raw), key_b64); + char authority[176]; + const bool default_port = (tls && port == 443) || (!tls && port == 80); + const int authority_len = default_port + ? std::snprintf(authority, sizeof(authority), "%s", host) + : std::snprintf(authority, sizeof(authority), "%s:%d", host, port); + char request[768]; + const int request_len = std::snprintf(request, sizeof(request), + "GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\n" + "Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n", path, authority, key_b64); + if (authority_len <= 0 || + static_cast(authority_len) >= sizeof(authority) || + request_len <= 0 || + static_cast(request_len) >= sizeof(request) || + !send_all(ssl, fd, request, static_cast(request_len))) { + cleanup(ssl, fd); return false; + } + + char accept_expected[32]{}; + websocket_accept(key_b64, accept_expected); + if (!read_http_upgrade(ssl, fd, accept_expected)) { + cleanup(ssl, fd); return false; + } + + char markets_fragment[96]; + int markets_len = 0; + if (cfg_.market_condition_id[0]) { + markets_len = std::snprintf(markets_fragment, + sizeof(markets_fragment), "[\"%s\"]", cfg_.market_condition_id); + } else { + markets_len = std::snprintf(markets_fragment, + sizeof(markets_fragment), "[]"); // all user markets + } + char subscription[896]; + const int subscription_len = std::snprintf(subscription, + sizeof(subscription), + "{\"type\":\"user\",\"markets\":%s," + "\"auth\":{\"apiKey\":\"%s\",\"secret\":\"%s\"," + "\"passphrase\":\"%s\"}}", + markets_fragment, cfg_.owner_api_key, + cfg_.api_secret_b64, cfg_.api_passphrase); + if (markets_len <= 0 || + static_cast(markets_len) >= sizeof(markets_fragment)) { + cleanup(ssl, fd); return false; + } + if (subscription_len <= 0 || + static_cast(subscription_len) >= sizeof(subscription) || + !send_frame(ssl, fd, 0x1, subscription, + static_cast(subscription_len))) { + cleanup(ssl, fd); return false; + } + + connected_.store(true, std::memory_order_release); + const bool clean = read_frames(ssl, fd); + cleanup(ssl, fd); + return clean; + } + + static bool parse_url(const char* url, char* host, size_t host_cap, + char* path, size_t path_cap, int& port, bool& tls) { + const char* cursor = url; + if (std::strncmp(cursor, "wss://", 6) == 0) { + tls = true; port = 443; cursor += 6; + } else if (std::strncmp(cursor, "ws://", 5) == 0) { + tls = false; port = 80; cursor += 5; + } else return false; + const char* slash = std::strchr(cursor, '/'); + const char* authority_end = slash ? slash : cursor + std::strlen(cursor); + if (cursor == authority_end) return false; + const char* colon = nullptr; + for (const char* p = cursor; p < authority_end; ++p) { + const unsigned char c = static_cast(*p); + if (*p == ':' && colon) return false; + if (*p == ':') colon = p; + else if (*p == '@' || *p == '?' || *p == '#' || + std::isspace(c)) return false; + } + const char* host_end = colon ? colon : authority_end; + const size_t host_len = static_cast(host_end - cursor); + if (host_len == 0 || host_len >= host_cap) return false; + std::memcpy(host, cursor, host_len); host[host_len] = '\0'; + if (colon) { + char port_text[8]; + const size_t n = static_cast(authority_end - colon - 1); + if (n == 0 || n >= sizeof(port_text)) return false; + std::memcpy(port_text, colon + 1, n); port_text[n] = '\0'; + errno = 0; + char* port_end = nullptr; + const long parsed_port = std::strtol(port_text, &port_end, 10); + if (errno == ERANGE || !port_end || *port_end != '\0' || + parsed_port <= 0 || parsed_port > 65535) + return false; + port = static_cast(parsed_port); + } + if (slash) { + const size_t path_len = std::strlen(slash); + if (path_len == 0 || path_len >= path_cap || + std::strchr(slash, '#') || std::strchr(slash, ' ')) + return false; + std::memcpy(path, slash, path_len + 1); + } + return true; + } + + static int tcp_connect(const char* host, int port, int timeout_ms) { + char service[8]; + std::snprintf(service, sizeof(service), "%d", port); + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + addrinfo* addresses = nullptr; + if (::getaddrinfo(host, service, &hints, &addresses) != 0) return -1; + int connected_fd = -1; + for (addrinfo* address = addresses; address; address = address->ai_next) { + int fd = ::socket(address->ai_family, address->ai_socktype, + address->ai_protocol); + if (fd < 0) continue; + const int flags = ::fcntl(fd, F_GETFL, 0); + ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); + int rc = ::connect(fd, address->ai_addr, address->ai_addrlen); + if (rc != 0 && errno == EINPROGRESS) { + fd_set writes; + FD_ZERO(&writes); FD_SET(fd, &writes); + timeval timeout{timeout_ms / 1000, (timeout_ms % 1000) * 1000}; + rc = ::select(fd + 1, nullptr, &writes, nullptr, &timeout); + if (rc > 0) { + int error = 0; socklen_t error_len = sizeof(error); + ::getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &error_len); + rc = error == 0 ? 0 : -1; + } else rc = -1; + } + if (rc == 0) { + ::fcntl(fd, F_SETFL, flags); + connected_fd = fd; + break; + } + ::close(fd); + } + ::freeaddrinfo(addresses); + return connected_fd; + } + + static void set_socket_options(int fd) { + int one = 1; + ::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); + ::setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof(one)); + timeval timeout{3, 0}; + ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + } + + static SSL_CTX* ssl_context() { + static SSL_CTX* context = [] { + SSL_CTX* value = SSL_CTX_new(TLS_client_method()); + if (!value) return static_cast(nullptr); + SSL_CTX_set_verify(value, SSL_VERIFY_PEER, nullptr); + SSL_CTX_set_options(value, SSL_OP_NO_COMPRESSION); + if (SSL_CTX_set_min_proto_version(value, TLS1_2_VERSION) != 1 || + SSL_CTX_set_default_verify_paths(value) != 1) { + SSL_CTX_free(value); + return static_cast(nullptr); + } + return value; + }(); + return context; + } + + static bool send_all(SSL* ssl, int fd, const char* data, size_t len) { + size_t offset = 0; + while (offset < len) { + const ssize_t n = ssl + ? SSL_write(ssl, data + offset, static_cast(len - offset)) + : ::send(fd, data + offset, len - offset, MSG_NOSIGNAL); + if (n <= 0) return false; + offset += static_cast(n); + } + return true; + } + + static ssize_t receive(SSL* ssl, int fd, char* data, size_t capacity) { + return ssl ? SSL_read(ssl, data, static_cast(capacity)) + : ::recv(fd, data, capacity, 0); + } + + static bool header_value(const char* header, size_t length, + const char* name, const char* expected, + bool token_list = false, + bool case_sensitive = false) { + const size_t name_len = std::strlen(name); + const size_t expected_len = std::strlen(expected); + size_t matches = 0; + const char* cursor = static_cast( + std::memchr(header, '\n', length)); + if (!cursor) return false; + ++cursor; + const char* end = header + length; + while (cursor < end) { + const char* line_end = find_bytes(cursor, end, "\r\n", 2); + if (!line_end || line_end == cursor) break; + const char* colon = find_char(cursor, line_end, ':'); + if (colon && static_cast(colon - cursor) == name_len && + strncasecmp(cursor, name, name_len) == 0) { + if (++matches > 1) return false; + const char* value = colon + 1; + while (value < line_end && std::isspace( + static_cast(*value))) ++value; + const char* value_end = line_end; + while (value_end > value && std::isspace( + static_cast(value_end[-1]))) --value_end; + if (!token_list) { + if (static_cast(value_end - value) != expected_len || + (case_sensitive + ? std::memcmp(value, expected, expected_len) != 0 + : strncasecmp(value, expected, expected_len) != 0)) + return false; + } else { + bool found = false; + const char* token = value; + while (token < value_end) { + while (token < value_end && + (*token == ',' || std::isspace( + static_cast(*token)))) ++token; + const char* token_end = token; + while (token_end < value_end && *token_end != ',') + ++token_end; + const char* trimmed = token_end; + while (trimmed > token && std::isspace( + static_cast(trimmed[-1]))) --trimmed; + if (static_cast(trimmed - token) == expected_len && + strncasecmp(token, expected, expected_len) == 0) + found = true; + token = token_end; + } + if (!found) return false; + } + } + cursor = line_end + 2; + } + return matches == 1; + } + + bool read_http_upgrade(SSL* ssl, int fd, const char* expected) { + char header[4096]; + size_t length = 0; + while (length + 1 < sizeof(header)) { + const ssize_t n = receive(ssl, fd, header + length, 1); + if (n <= 0) return false; + length += static_cast(n); + header[length] = '\0'; + if (length >= 4 && std::memcmp(header + length - 4, "\r\n\r\n", 4) == 0) { + if (length < 13 || std::strncmp(header, "HTTP/1.1 101", 12) != 0 || + (header[12] != ' ' && header[12] != '\r')) + return false; + return header_value(header, length, "Sec-WebSocket-Accept", + expected, false, true) && + header_value(header, length, "Upgrade", "websocket") && + header_value(header, length, "Connection", "upgrade", true); + } + } + return false; + } + + static void websocket_accept(const char* key, char out[32]) { + static constexpr char GUID[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + char input[96]; + const size_t key_len = std::strlen(key); + std::memcpy(input, key, key_len); + std::memcpy(input + key_len, GUID, sizeof(GUID) - 1); + uint8_t hash[20]; + sha1(reinterpret_cast(input), key_len + sizeof(GUID) - 1, hash); + base64_encode(hash, sizeof(hash), out); + } + + bool send_frame(SSL* ssl, int fd, uint8_t opcode, + const char* payload, size_t len) { + if (len > MAX_MESSAGE) return false; + uint8_t header[14]; + size_t header_len = 0; + header[header_len++] = static_cast(0x80U | opcode); + if (len < 126) { + header[header_len++] = static_cast(0x80U | len); + } else if (len <= 0xFFFF) { + header[header_len++] = 0x80U | 126U; + header[header_len++] = static_cast(len >> 8); + header[header_len++] = static_cast(len); + } else { + header[header_len++] = 0x80U | 127U; + for (int i = 7; i >= 0; --i) + header[header_len++] = static_cast(len >> (i * 8)); + } + FastRandom random; + const uint64_t mask_word = random.next_u64(); + uint8_t mask[4]; + std::memcpy(mask, &mask_word, sizeof(mask)); + std::memcpy(header + header_len, mask, sizeof(mask)); + header_len += sizeof(mask); + if (!send_all(ssl, fd, reinterpret_cast(header), header_len)) + return false; + for (size_t i = 0; i < len; ++i) + send_buffer_[i] = static_cast(payload[i] ^ mask[i & 3]); + return len == 0 || send_all(ssl, fd, send_buffer_.data(), len); + } + + bool read_frames(SSL* ssl, int fd) { + size_t fragment_len = 0; + bool fragmenting = false; + uint64_t last_ping = now_mono_ms(); + uint64_t last_receive = last_ping; + + while (running_.load(std::memory_order_acquire)) { + const uint64_t now = now_mono_ms(); + if (now - last_ping >= 8000) { + if (!send_frame(ssl, fd, 0x1, "PING", 4)) return false; + last_ping = now; + } + if (now - last_receive >= 20000) return false; + + fd_set reads; + FD_ZERO(&reads); FD_SET(fd, &reads); + timeval timeout{0, 200000}; + const int ready = ::select(fd + 1, &reads, nullptr, nullptr, &timeout); + if (!running_.load()) return true; + if (ready < 0) { if (errno == EINTR) continue; return false; } + if (ready == 0) continue; + if (rbuf_len_ == rbuf_.size()) return false; + const ssize_t n = receive(ssl, fd, rbuf_.data() + rbuf_len_, + rbuf_.size() - rbuf_len_); + if (n <= 0) return false; + rbuf_len_ += static_cast(n); + last_receive = now_mono_ms(); + + size_t offset = 0; + while (offset + 2 <= rbuf_len_) { + const uint8_t b0 = static_cast(rbuf_[offset]); + const uint8_t b1 = static_cast(rbuf_[offset + 1]); + const bool final = (b0 & 0x80U) != 0; + const uint8_t opcode = b0 & 0x0FU; + if ((b0 & 0x70U) != 0 || (b1 & 0x80U) != 0) return false; + uint64_t payload_len = b1 & 0x7FU; + size_t header_len = 2; + if (payload_len == 126) { + if (offset + 4 > rbuf_len_) break; + payload_len = static_cast(rbuf_[offset + 2]) * 256ULL + + static_cast(rbuf_[offset + 3]); + if (payload_len < 126) return false; + header_len = 4; + } else if (payload_len == 127) { + if (offset + 10 > rbuf_len_) break; + if ((static_cast(rbuf_[offset + 2]) & 0x80U) != 0) + return false; + payload_len = 0; + for (int i = 0; i < 8; ++i) + payload_len = (payload_len << 8) | + static_cast(rbuf_[offset + 2 + i]); + if (payload_len <= 0xFFFFU) return false; + header_len = 10; + } + const bool control = (opcode & 0x08U) != 0; + if ((opcode != 0x0 && opcode != 0x1 && opcode != 0x8 && + opcode != 0x9 && opcode != 0xA) || + (control && (!final || payload_len > 125))) + return false; + if (payload_len > MAX_MESSAGE) return false; + if (offset + header_len + payload_len > rbuf_len_) break; + const char* payload = rbuf_.data() + offset + header_len; + offset += header_len + static_cast(payload_len); + + if (opcode == 0x8) { + (void)send_frame(ssl, fd, 0x8, payload, + std::min(payload_len, 125)); + return false; + } + if (opcode == 0x9) { + if (!send_frame(ssl, fd, 0xA, payload, payload_len)) return false; + continue; + } + if (opcode == 0xA) continue; + if (opcode == 0x1 && final) { + if (fragmenting) return false; + if (!(payload_len == 4 && std::memcmp(payload, "PONG", 4) == 0)) + handle_message(payload, static_cast(payload_len)); + continue; + } + if (opcode == 0x1 && !final) { + if (fragmenting || payload_len > fragment_.size()) return false; + std::memcpy(fragment_.data(), payload, payload_len); + fragment_len = static_cast(payload_len); + fragmenting = true; + continue; + } + if (opcode == 0x0) { + if (!fragmenting || + payload_len > fragment_.size() - fragment_len) + return false; + std::memcpy(fragment_.data() + fragment_len, payload, payload_len); + fragment_len += static_cast(payload_len); + if (final) { + handle_message(fragment_.data(), fragment_len); + fragmenting = false; + fragment_len = 0; + } + } + } + if (offset) { + std::memmove(rbuf_.data(), rbuf_.data() + offset, rbuf_len_ - offset); + rbuf_len_ -= offset; + } + } + return true; + } + + static const char* find_bytes(const char* begin, const char* end, + const char* needle, size_t needle_len) { + if (needle_len == 0 || static_cast(end - begin) < needle_len) + return nullptr; + for (const char* p = begin; p + needle_len <= end; ++p) + if (*p == *needle && std::memcmp(p, needle, needle_len) == 0) return p; + return nullptr; + } + + static const char* find_char(const char* begin, const char* end, char wanted) { + return static_cast(std::memchr(begin, wanted, + static_cast(end - begin))); + } + + static uint64_t now_mono_ms() { + return static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + } + + void cleanup(SSL* ssl, int fd) { + connected_.store(false, std::memory_order_release); + active_fd_.store(-1, std::memory_order_release); + if (ssl) SSL_free(ssl); + if (fd >= 0) ::close(fd); + } + + // SHA-1 is used only for RFC 6455 handshake validation. + static void sha1(const uint8_t* data, size_t len, uint8_t out[20]) { + uint32_t h[5] = {0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0}; + uint64_t bits = len * 8; + size_t offset = 0; + while (offset + 64 <= len) { sha1_block(h, data + offset); offset += 64; } + uint8_t tail[128]{}; + const size_t remaining = len - offset; + std::memcpy(tail, data, remaining); + tail[remaining] = 0x80; + const size_t final_offset = remaining >= 56 ? 120 : 56; + for (int i = 0; i < 8; ++i) + tail[final_offset + i] = static_cast(bits >> (56 - i * 8)); + sha1_block(h, tail); + if (remaining >= 56) sha1_block(h, tail + 64); + for (int i = 0; i < 5; ++i) { + out[i * 4] = static_cast(h[i] >> 24); + out[i * 4 + 1] = static_cast(h[i] >> 16); + out[i * 4 + 2] = static_cast(h[i] >> 8); + out[i * 4 + 3] = static_cast(h[i]); + } + } + + static void sha1_block(uint32_t h[5], const uint8_t* p) { + uint32_t w[80]; + for (int i = 0; i < 16; ++i) + w[i] = static_cast(p[i*4]) << 24 | + static_cast(p[i*4+1]) << 16 | + static_cast(p[i*4+2]) << 8 | + static_cast(p[i*4+3]); + for (int i = 16; i < 80; ++i) { + const uint32_t v = w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16]; + w[i] = (v << 1) | (v >> 31); + } + uint32_t a=h[0],b=h[1],c=h[2],d=h[3],e=h[4]; + for (int i = 0; i < 80; ++i) { + uint32_t f, k; + if (i < 20) { f=(b&c)|(~b&d); k=0x5A827999; } + else if (i < 40) { f=b^c^d; k=0x6ED9EBA1; } + else if (i < 60) { f=(b&c)|(b&d)|(c&d); k=0x8F1BBCDC; } + else { f=b^c^d; k=0xCA62C1D6; } + const uint32_t t=((a<<5)|(a>>27))+f+e+k+w[i]; + e=d; d=c; c=(b<<30)|(b>>2); b=a; a=t; + } + h[0]+=a; h[1]+=b; h[2]+=c; h[3]+=d; h[4]+=e; + } + + const MarketConfig& cfg_; + SPSC_RingBuffer& queue_; + UserEventParser parser_; + std::thread thread_; + std::atomic running_{false}; + std::atomic connected_{false}; + std::atomic active_fd_{-1}; + std::atomic events_{0}; + std::atomic pushed_{0}; + std::atomic dropped_{0}; + std::atomic reconnects_{0}; + std::array rbuf_{}; + size_t rbuf_len_ = 0; + std::array fragment_{}; + std::array send_buffer_{}; +}; + +#endif // WS_USER_LISTENER_HPP diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3c309d0..1cb3dfc 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -53,6 +53,47 @@ The deployment unit maps root-only files through systemd `LoadCredential=`. Do n A conservative default is not a verified venue value. Tick, fee schedule, minimum size, negative-risk status, token ID, balances, allowances, open orders, and inventory must come from current market/account metadata during preflight. +## Eyes — accounting and reconciliation (P1) + +| Variable | Default | Meaning | +|---|---:|---| +| `BOT_RESERVATION_TTL_MS` | `10000` | Unconfirmed local reservations are released after this age (unfilled maker orders, dead venues). | +| `BOT_RECONCILE_INTERVAL_SEC` | `30` | Periodic REST positions reconciliation; `0` disables. | +| `BOT_RECONCILE_MAX_DRIFT_SHARES` | `0.01` | Max tolerated \|REST−local\| inventory drift; larger drift restates state and latches the kill switch. | + +## Brakes — risk manager (P2) + +| Variable | Default | Meaning | +|---|---:|---| +| `BOT_STOP_LOSS_PCT` | `0.15` | Stop-loss: liquidation-mark drop vs VWAP entry that closes the position. `0` disables. | +| `BOT_HEDGE_TRIGGER_PCT` | `0` | Earlier trigger that buys the complement token (`BOT_HEDGE_TOKEN_ID`). Must fire strictly before the stop-loss; `0` disables. | +| `BOT_MAX_PORTFOLIO_EXPOSURE_USD` | `250` | Portfolio-wide exposure cap enforced pre-signature. | + +## Adverse selection — volatility gate (P3) + +| Variable | Default | Meaning | +|---|---:|---| +| `BOT_POOL_MAX_DEV_BPS` | `200` | Max deviation of an order's price vs the CURRENT mid; a stale pool slot or toxic spread is refused. `0` disables. | +| `BOT_POOL_VOL_TTL_MS` | `500` | Pre-signed ladder TTL while the regime is volatile. | +| `BOT_VOL_MAX_SPREAD_BPS` | `1500` | Spread width (bps of mid) that flags a volatile regime. | +| `BOT_VOL_MAX_TICKS_PER_SEC` | `200` | Mid-change rate (Hz) that flags a volatile regime. `0` disables. | +| `BOT_VOL_MID_GAP_BPS` | `500` | Mid jump arming the 100 ms shock window; cooldown suppresses passive flow for 250 ms. | +| `BOT_VOL_SIZE_MULTIPLIER` | `0.5` | Passive size scale while the regime is volatile (halved again when extreme). | + +## Brain — Bayesian edge engine (P4) + +| Variable | Default | Meaning | +|---|---:|---| +| `BOT_BAYES_ENABLE` | `1` | Bayes brain active (prior seeding, evidence drain, posterior trigger). `0` cold-paths everything; the hot tick then only bounds-empties the evidence queue. | +| `BOT_BAYES_SOURCES` | empty | `id:weight` pairs, e.g. `"1:0.9,2:0.5"`. Weight = source reliability floor for evidence gating; at least one valid pair enables Beta-Binomial slots per source. | +| `BOT_BAYES_RECAL_FILE` | empty | Hot-reloaded reliability table (same `id:weight` format), re-read by the evidence replayer thread. | +| `BOT_EVIDENCE_FILE` | empty | NDJSON evidence replay/tail input. Lines: `{"source":1,"kind":"count","n":32,"k":22,"hash":N}` (binomial counts, `k<=n`) or `{"source":3,"kind":"lr","lr":693147,"hash":N}` (log-LR ×1e6). `hash` ≠ 0 required (dedup). | +| `BOT_BAYES_PRIOR_STRENGTH` | `24.0` | N₀ pseudo-count of the market mid as prior: α=N₀·mid, β=N₀·(1−mid). | +| `BOT_BAYES_SIGNAL_THRESHOLD` | `0.03` | Emit a synthetic signal when \|posterior − executable side\| exceeds this and reliability clears the floor. | +| `BOT_BAYES_MIN_RELIABILITY` | `0.35` | Evidence below this source weight is journaled `BAYES_LOW_RELIABILITY` and never updates the posterior. | + +Paper trading (`BOT_MODE=mock`) synthesizes venue fills for every accepted mock order into the account queue, so tracker, exposure, stop-loss and brain observe the same flow they will see live. The mock feeds a tight, continuously refreshed book (~100 bps execution slip, mid pinned) alternating BUY/SELL hints. + ## Transport and runtime | Variable | Default | Notes | diff --git a/docs/perf/BASELINE_2026-09-30.md b/docs/perf/BASELINE_2026-09-30.md new file mode 100644 index 0000000..519900c --- /dev/null +++ b/docs/perf/BASELINE_2026-09-30.md @@ -0,0 +1,92 @@ +# Latency baseline — captured BEFORE P2/P3/P4 work (2026-09-30) + +Sandbox: 2 shared vCPUs (noisy, no RT isolation), gcc 12.2.0, Release (-O3, LTO), +portable CPU target (CROWDINTEL_CPU_TARGET=portable default), secp256k1 vendored +mid-2022 build (PyPI coincurve 17.0.0 source; GitHub unreachable in sandbox). + +Typical of 3 runs of `build/bin/latency_bench`: + +| path | p50 | p90 | p99 | +|---|---|---|---| +| decision+pool+mock-submit | ~450-480 ns | ~630-670 ns | ~757-908 ns | +| decision+inline-sign+mock-submit | ~42.0 µs | ~45-56 µs | ~64-85 µs | +| sign only (Keccak+ECDSA) | ~40.5 µs | ~43-61 µs | ~61-85 µs | +| consumable pool lookup+copy | ~145-150 ns | ~176-212 ns | ~242-354 ns | + +Reference comparison point for later "before/after" checks: the pool-hit hot +path p99 must stay within +5 µs of ~0.9 µs after each priority layer lands. +The sign path is dominated by secp256k1 (~40 µs) and only exists as the +cold/presigner fallback, so it is not the protected hot path. + +## After F2 (P2 brakes — RiskManager/hedge/kill wired, hedge book subscribed) + +Measured 2026-09-30 on the same sandbox after wiring the hedge book and the +engine risk integration (all 4/4 ctest suites green, layer_units includes +stop-loss/kill/hedge engine integration): + +| path | p50 | p90 | p99 | +|---|---|---|---| +| decision+pool+mock-submit | ~416-430 ns | ~583-597 ns | ~734-749 ns | +| consumable pool lookup+copy | ~133-139 ns | ~173-175 ns | ~238-242 ns | + +Pool-hit hot path p99 delta vs baseline (808 ns midpoint): ≈ −6 %, i.e. no +degradation (within sandbox noise). The brakes add one atomic read + +bounded evaluation per tick; protective actions execute inline-signed (FAK) +only on trigger, never on the steady path. + +## After F3 (P3 adverse selection — VolatilityGate wired) + +Tests: 4/4 ctest green; layer_units adds vol_gate regimes/shock/slippage +units, pool dynamic TTL, and the engine acceptance test (6% mid jump between +two ticks → VOLATILITY_PAUSED + POOL_STALE_DROP journal, order NOT consumed; +flow resumes after the 250 ms cooldown at refreshed prices). + +New benchmark lane `decision+pool+layers+mock-submit` runs the identical alpha +path through ALL layers (P1 tracker, P2 brakes, P3 gate) on inert state: + +| path | p50 | p90 | p99 | +|---|---|---|---| +| decision+pool+mock-submit (bare) | ~465 ns | ~650 ns | ~836 ns | +| decision+pool+layers+mock-submit (P1+P2+P3) | ~784 ns | ~962 ns | ~1162 ns | + +Layer overhead on the steady path: ≈ +320 ns p50 / +330 ns p99 — an order of +magnitude under the +5 µs budget. Components: tracker reserve/release +(2 publishes), risk authorize (atomic + bounds), gate observe/shock check +(2 stores + compare), regime read (3 atomic loads), slippage gate (division), +dynamic TTL compare in pool scan. Protective actions remain trigger-only; +the shock/volatility suppression runs before any signature work, so paused +ticks are FASTER than productive ticks, never slower. + +## After F4 (P4 brain — BayesianEngine + posterior trigger wired) + +Tests: 4/4 ctest green; layer_units adds `bayes_math` (Beta-Binomial exact +posterior 0.5568 from prior 0.40/N0=24 + n=32/k=22/w=0.9, sigmoid LR row, +Dirichlet multi-outcome 0.30/0.45/0.25, structural guards) and +`engine_brain_acceptance`: prior seeds from mid once, low-reliability source +(weight 0.10 < floor 0.35) is gated with a journal entry and emits nothing, +trusted evidence (n=32, k=22, w=0.9) drives posterior 0.40 → 0.557 and emits +exactly one BAYES_SIGNAL when ask clears the divergence threshold; a second +tick without new evidence does not repeat. + +New benchmark lanes: + +| path | p50 | p90 | p99 | +|---|---|---|---| +| bayes posterior update (count event) | ~41 ns | ~42 ns | ~46 ns | +| bayes posterior read (fresh snapshot) | ~39 ns | ~40 ns | ~42 ns | +| decision+pool+layers+mock-submit (P1+P2+P3+P4, queue empty) | ~760 ns | ~928 ns | ~1099 ns | + +Posterior update/read meet the <50 ns objective with margin (measurement +includes the RDTSC clock overhead itself). Brain wiring on the steady tick: +one queue-depth check (+ one bounded drain on arrival) and publish/release of +an atomic cursor — ≈ +40…+60 ns p50 over the F3 layered lane, far inside the ++5 µs budget. Evidence ingestion, NDJSON replay and source recalibration +live entirely on the cold thread; orders are emitted through the same +pipeline as alpha signals so brakes, pool TTL and the slippage gate still +apply to brain-originated flow. + +Paper trading (`BOT_MODE=mock`) is now end-to-end honest: accepted mock +orders synthesize venue fills into the account queue, and the mock feeds a +tight continuously refreshed book. A 30 s smoke with evidence replay ran +149 orders/149 fills, 1 BAYES_SIGNAL, tracker anomalies=0, detector of +`no_inventory` on sell-before-fill signals, realized paper P&L tracked. diff --git a/tests/benchmarks/check_latency.py b/tests/benchmarks/check_latency.py index 0768462..8eae8af 100755 --- a/tests/benchmarks/check_latency.py +++ b/tests/benchmarks/check_latency.py @@ -7,8 +7,13 @@ checks = { "consumable pool lookup+copy": 10_000, "decision+pool+mock-submit": 20_000, + # All P1-P3 layers attached: housekeeping + brakes + adverse selection. + "decision+pool+layers+mock-submit": 25_000, "decision+inline-sign+mock-submit": 500_000, "sign only (Keccak+ECDSA)": 500_000, + # P4 brain: closed-form conjugate update/read must stay in tens of ns. + "bayes posterior update": 1_000, + "bayes posterior read": 1_000, } failed = False for label, budget_ns in checks.items(): diff --git a/tests/benchmarks/latency_bench.cpp b/tests/benchmarks/latency_bench.cpp index 1d963c4..0165a48 100644 --- a/tests/benchmarks/latency_bench.cpp +++ b/tests/benchmarks/latency_bench.cpp @@ -7,12 +7,16 @@ #include #include #include +#include #include #include #include #include "../../core/crypto/eip712_signer.hpp" #include "../../core/include/order_book.hpp" +#include "../../core/include/bayesian_engine.hpp" +#include "../../core/include/evidence.hpp" +#include "../../core/include/source_reliability.hpp" #include "../../core/include/spsc_ring_buffer.hpp" #include "../../core/src/bench_engine.hpp" #include "alpha_parser.hpp" @@ -96,9 +100,12 @@ int main() { (void)engine.run_tick(); } - std::vector pool_hit, inline_sign, sign_only, pool_lookup; + std::vector pool_hit, inline_sign, sign_only, pool_lookup, + layered_hit, bayes_update, bayes_read; pool_hit.reserve(SAMPLES); inline_sign.reserve(SAMPLES); sign_only.reserve(SAMPLES); pool_lookup.reserve(SAMPLES); + layered_hit.reserve(SAMPLES); bayes_update.reserve(SAMPLES); + bayes_read.reserve(SAMPLES); size_t successful = 0; for (size_t i = 0; i < SAMPLES; ++i) { @@ -112,6 +119,78 @@ int main() { } std::printf("consumable-pool batches: %zu/%zu productive\n", successful, SAMPLES); + // Hot path with EVERY protective layer attached (P1 tracker, P2 brakes, + // P3 adverse-selection gate). Inert layer state and huge caps so every + // signal trades; the measurement is the steady alpha path — protective + // housekeeping is the actual production cost included here. + setenv("BOT_PRIVATE_KEY_HEX", + "23dd72ba9070d7903cf60cad22700819abb7ae93c5788e15f038a0ece0a6697b", 1); + MarketConfig lcfg; + if (const char* error = lcfg.load(false, true)) { + std::printf("FATAL: %s\n", error); return 1; + } + lcfg.bankroll_usd = 10000.0; + lcfg.kelly_fraction = 0.25; + lcfg.max_order_usd = 100.0; + lcfg.pool_max_dev_bps = 5000.0; // fixture spread is 6c by design + lcfg.max_exposure_usd = 1000000000.0; + lcfg.max_daily_loss_usd = 1000000000.0; + lcfg.max_portfolio_exposure_usd = 1000000000.0; + lcfg.taker_fee_rate = 0.0; + std::strcpy(lcfg.order_type, "FAK"); + secure_zero(lcfg.private_key_hex, sizeof(lcfg.private_key_hex)); + if (const char* error = lcfg.finalize_identity(signer.signer_address())) { + std::printf("FATAL: %s\n", error); return 1; + } + OrderBookL2 lbook; + lbook.set_tick_size(lcfg.tick_size); + const Level2Entry lbids[2] = {{470000, 2000000000}, {460000, 2000000000}}; + const Level2Entry lasts[2] = {{530000, 2000000000}, {540000, 2000000000}}; + lbook.set_book(lbids, 2, lasts, 2); + auto lsignals = std::make_unique>(); + PresignedOrderPool lpool(lcfg, signer, 60000); + PositionTracker ltracker; + RiskLimits llimits{}; + llimits.stop_loss_pct = 0.0; + llimits.hedge_trigger_pct = 0.0; + llimits.max_daily_loss_usd = 1000000000.0; + llimits.max_market_exposure_usd = 1000000000.0; + llimits.max_portfolio_exposure_usd = 1000000000.0; + RiskManager lrisk(llimits); + VolatilityGate lgate(lcfg); + BayesianEngine lbayes; + SourceReliability lsources; + auto levidence = std::make_unique>(); + EngineLayers llayers{}; + llayers.tracker = <racker; + llayers.risk = &lrisk; + llayers.volatility = &lgate; + llayers.evidence_q = levidence.get(); + llayers.bayes = &lbayes; + llayers.sources = &lsources; + BenchEngine lengine(lcfg, lbook, *lsignals, signer, lpool, &llayers); + for (size_t i = 0; i < WARMUP; ++i) { + if (i % PresignedOrderPool::SIZE_BUCKETS == 0) + lpool.rebuild(470000, 530000, target_shares, lcfg.tick_size); + lsignals->try_push(make_signal(lcfg, 0.75, signal_id++)); + (void)lengine.run_tick(); + } + size_t layered_successful = 0; + for (size_t i = 0; i < SAMPLES; ++i) { + if (i % PresignedOrderPool::SIZE_BUCKETS == 0) + lpool.rebuild(470000, 530000, target_shares, lcfg.tick_size); + lsignals->try_push(make_signal(lcfg, 0.75, signal_id++)); + const uint64_t begin = clock_ns(); + const int result = lengine.run_tick(); + const uint64_t end = clock_ns(); + if (result == 1) { + layered_hit.push_back(end - begin); + ++layered_successful; + } + } + std::printf("layered batches: %zu/%zu productive\n", + layered_successful, SAMPLES); + // Move top-of-book to a price absent from the active ladder, forcing the // exact production fallback path (amount build + Keccak + ECDSA + JSON). bids[0] = {450000, 2000000000}; @@ -146,6 +225,26 @@ int main() { pool_lookup.push_back(end - begin); } + // P4 brain microbench: closed-form Beta-Binomial COUNT update and the + // posterior read the hot loop performs per drain/tick. These are the + // ONLY brain costs on the steady path and must stay in the tens of ns. + BayesianEngine beng; + beng.ensure_prior(0.40, 24.0); + for (size_t i = 0; i < SAMPLES; ++i) { + const uint64_t begin = clock_ns(); + beng.update_count(0, 32, 22, 900000); + const uint64_t end = clock_ns(); + bayes_update.push_back(end - begin); + } + double acc = 0.0; + for (size_t i = 0; i < SAMPLES; ++i) { + const uint64_t begin = clock_ns(); + acc += beng.posterior(); + const uint64_t end = clock_ns(); + bayes_read.push_back(end - begin); + } + if (acc < 0.0) std::printf("unreachable %f\n", acc); // keep the reads live + auto report = [](const char* name, std::vector& samples) { std::sort(samples.begin(), samples.end()); auto percentile = [&](double p) { @@ -165,9 +264,12 @@ int main() { }; report("decision+pool+mock-submit", pool_hit); + report("decision+pool+layers+mock-submit", layered_hit); report("decision+inline-sign+mock-submit", inline_sign); report("sign only (Keccak+ECDSA)", sign_only); report("consumable pool lookup+copy", pool_lookup); + report("bayes posterior update", bayes_update); + report("bayes posterior read", bayes_read); std::printf("No network, HMAC, DNS, TCP or TLS is included in these values.\n"); return 0; } diff --git a/tests/unit/test_layers.cpp b/tests/unit/test_layers.cpp new file mode 100644 index 0000000..772da80 --- /dev/null +++ b/tests/unit/test_layers.cpp @@ -0,0 +1,1025 @@ +// ───────────────────────────────────────────────────────────────────────────── +// test_layers: unit/integration tests for the second-generation layers. +// +// P1 eyes: PositionTracker accounting, dedup, VWAP, seqlock snapshots, +// user-channel message parser, engine/tracker integration +// (no double exposure on partial fills). +// P2 brakes: RiskManager caps, stop-loss, kill switch, hedging. +// P3 adverse: VolatilityGate regimes, pool slippage/TTL policy. +// P4 brain: BayesianEngine posterior math, source reliability gating. +// +// Exit code 0 = all pass. No external framework (matches test_core). +// ───────────────────────────────────────────────────────────────────────────── + +#include +#include +#include +#include +#include + +#include "../../core/include/account_events.hpp" +#include "../../core/include/bayesian_engine.hpp" +#include "../../core/include/evidence.hpp" +#include "../../core/include/journal.hpp" +#include "../../core/include/source_reliability.hpp" +#include "../../core/include/order_book.hpp" +#include "../../core/include/position_tracker.hpp" +#include "../../core/include/risk_manager.hpp" +#include "../../core/include/spsc_ring_buffer.hpp" +#include "../../core/include/time_utils.hpp" +#include "../../core/include/volatility_gate.hpp" +#include +#include "../../core/src/execution_engine.hpp" +#include "../../core/src/market_config.hpp" +#include "../../core/src/mock_client.hpp" +#include "../../core/src/presigned_pool.hpp" +#include "../../core/src/user_event_parser.hpp" +#include "../../core/crypto/eip712_signer.hpp" +#include "../../core/crypto/secure_zero.hpp" +#include "alpha_parser.hpp" + +// Phase headers are included as their phases land (P3 adverse-selection, +// P4 brain): +// #include "../../core/include/volatility_gate.hpp" +// #include "bayesian_engine.hpp" / "evidence.hpp" + +static int g_failures = 0; +#define CHECK(cond, name) \ + do { \ + if (cond) { std::printf(" PASS %s\n", name); } \ + else { std::printf(" FAIL %s (line %d)\n", name, __LINE__); \ + ++g_failures; } \ + } while (0) + +static AccountEvent make_fill(uint8_t side, double price, double shares, + uint64_t order_hash, uint64_t event_id, + uint8_t asset = 0) { + AccountEvent ev{}; + ev.type = AccountEvent::Type::FILL; + ev.side = side; + ev.asset = asset; + ev.price = static_cast(price * 1000000.0); + ev.size = static_cast(shares * 1000000.0); + ev.order_hash = order_hash; + ev.event_id = event_id; + ev.market_hash = 7; + ev.timestamp_ns = crowdintel::realtime_ns(); + return ev; +} + +// ── P1: PositionTracker ────────────────────────────────────────────────────── +static void test_tracker_partial_fill() { + std::printf("tracker_partial_fill\n"); + PositionTracker tracker; + const uint64_t price = 550000; + tracker.reserve_buy(10000000000ULL, price); // 10,000 shares + CHECK(tracker.open_buy() == 10000000000ULL, "reservation visible pre-fill"); + + const uint64_t t0 = crowdintel::mono_ns(); + AccountEvent partial = + make_fill(0, 0.55, 3000.0, /*order=*/11, /*event=*/101); + partial.remaining = 7000000000ULL; + tracker.apply(partial); + const uint64_t applied_ns = crowdintel::mono_ns() - t0; + + CHECK(tracker.net_yes() == 3000000000ULL, "3,000 of 10,000 tracked as inventory"); + CHECK(tracker.open_buy() == 7000000000ULL, "7,000 remain pending"); + CHECK(applied_ns < 1000000ULL, "fill applied in <1 ms"); + + AccountEvent rest = make_fill(0, 0.55, 7000.0, /*order=*/11, /*event=*/102); + tracker.apply(rest); + CHECK(tracker.net_yes() == 10000000000ULL && tracker.open_buy() == 0, + "second fill completes the order without residue"); + CHECK(tracker.yes_avg() == 550000, "single-price VWAP is exact"); + + // Worst-cost exposure must never double count reservation + inventory. + PositionTracker t2; + t2.reserve_buy(4000000000ULL, 500000); // $2,000 worst cost + CHECK(t2.exposure_worst_cost() == 2000000000ULL, "worst cost reserved"); + AccountEvent fill = make_fill(0, 0.50, 4000.0, 12, 103); + t2.apply(fill); + CHECK(t2.open_buy() == 0 && t2.open_buy_cost() == 0 && + t2.exposure_worst_cost() == 2000000000ULL, + "fill moves reservation into inventory without double counting"); +} + +static void test_tracker_vwap_pnl_dedup() { + std::printf("tracker_vwap_pnl_dedup\n"); + PositionTracker tracker; + tracker.apply(make_fill(0, 0.40, 1000.0, 1, 201)); + tracker.apply(make_fill(0, 0.60, 1000.0, 2, 202)); + CHECK(tracker.net_yes() == 2000000000ULL, "two buys accumulate"); + CHECK(tracker.yes_avg() == 500000, "VWAP of 0.40/0.60 equals 0.50"); + + tracker.apply(make_fill(1, 0.80, 500.0, 3, 203)); // sell 500 @ 0.80 + CHECK(tracker.net_yes() == 1500000000ULL, "sell reduces inventory"); + CHECK(tracker.realized_pnl() == 150000000LL, // (0.80-0.50)*500 = $150 + "realized P&L uses tracked VWAP"); + + // Duplicate delivery of trade 203 (e.g. MATCHED then re-sent) is dropped. + tracker.apply(make_fill(1, 0.80, 500.0, 3, 203)); + CHECK(tracker.fills() == 3 && tracker.realized_pnl() == 150000000LL && + tracker.anomalies() == 1, + "duplicate trade id cannot double-count a fill"); + + // Oversold inventory saturates and is flagged for reconciliation. + tracker.apply(make_fill(1, 0.10, 99999.0, 4, 204)); + CHECK(tracker.net_yes() == 0 && tracker.anomalies() >= 2, + "sell beyond inventory clamps net position and flags anomaly"); +} + +static void test_tracker_reservations_snapshot() { + std::printf("tracker_reservations_snapshot\n"); + PositionTracker tracker(2000000000ULL, 450000); // 2,000 YES @ 0.45 + CHECK(tracker.sellable() == 2000000000ULL, "initial inventory sellable"); + tracker.reserve_sell(500000000ULL); + CHECK(tracker.sellable() == 1500000000ULL, + "sell reservation cannot be spent twice"); + tracker.release_sell(500000000ULL); + CHECK(tracker.sellable() == 2000000000ULL, "release restores inventory"); + + std::atomic stop{false}; + std::atomic bad{0}; + std::thread writer([&] { + uint64_t id = 1000; + while (!stop.load(std::memory_order_acquire)) { + tracker.apply(make_fill(0, 0.50, 10.0, id, id)); + ++id; + } + }); + std::thread reader([&] { + PositionTracker::Snapshot view{}; + while (!stop.load(std::memory_order_acquire)) { + if (tracker.snapshot(view)) { + const bool coherent = + view.open_buy == 0 || view.open_buy_cost != 0; + if (!coherent) bad.fetch_add(1, std::memory_order_relaxed); + } + } + }); + std::this_thread::sleep_for(std::chrono::milliseconds(120)); + stop.store(true, std::memory_order_release); + writer.join(); + reader.join(); + CHECK(bad.load() == 0, "cold snapshots stay coherent under hot writes"); +} + +// ── P1: user-channel parser ───────────────────────────────────────────────── +static void test_user_event_parser() { + std::printf("user_event_parser\n"); + const char* token = "71321045679252212594626395510336467040167069592778062791519851593659551227755"; + UserEventParser parser(token, nullptr, 42); + + const char* trade = + "{\"event_type\":\"trade\",\"type\":\"MATCHED\"," + "\"id\":\"0xtrade1\",\"taker_order_id\":\"0xorder9\"," + "\"asset_id\":\"71321045679252212594626395510336467040167069592778062791519851593659551227755\"," + "\"market\":\"0xbd31dc69a81dc0b5e6b5b9bb6d2e2ebf\",\"side\":\"BUY\"," + "\"size\":\"3000\",\"price\":\"0.55\",\"fee_rate_bps\":\"0\"," + "\"status\":\"MATCHED\",\"matchtime\":\"1727000000000\"," + "\"maker_orders\":[],\"trader_side\":\"TAKER\"}"; + AccountEvent ev{}; + CHECK(parser.parse(trade, std::strlen(trade), ev), + "MATCHED trade parses"); + CHECK(ev.type == AccountEvent::Type::FILL && ev.side == 0 && + ev.size == 3000000000ULL && ev.price == 550000 && + ev.event_id != 0 && ev.order_hash != 0, + "partial fill normalized with fixed-point size/price and hashed ids"); + + const char* mined = + "{\"event_type\":\"trade\",\"type\":\"MINED\"," + "\"id\":\"0xtrade1\",\"taker_order_id\":\"0xorder9\"," + "\"asset_id\":\"71321045679252212594626395510336467040167069592778062791519851593659551227755\"," + "\"side\":\"BUY\",\"size\":\"3000\",\"price\":\"0.55\"," + "\"status\":\"MINED\"}"; + AccountEvent dup{}; + CHECK(parser.parse(mined, std::strlen(mined), dup) && + dup.type == AccountEvent::Type::FILL_MINED && + dup.event_id == ev.event_id, + "MINED restatement keeps the same trade id for dedup"); + + const char* placement = + "{\"event_type\":\"order\",\"type\":\"PLACEMENT\"," + "\"id\":\"0xorder9\",\"asset_id\":\"71321045679252212594626395510336467040167069592778062791519851593659551227755\"," + "\"side\":\"BUY\",\"original_size\":\"10000\",\"size_matched\":\"3000\"," + "\"price\":\"0.55\",\"outcome\":\"YES\",\"status\":\"LIVE\"}"; + AccountEvent open{}; + CHECK(parser.parse(placement, std::strlen(placement), open) && + open.type == AccountEvent::Type::OPEN && + open.remaining == 7000000000ULL, + "PLACEMENT normalizes remaining 7,000 of 10,000"); + + const char* cancel = + "{\"event_type\":\"order\",\"type\":\"CANCELLATION\"," + "\"id\":\"0xorder9\",\"asset_id\":\"71321045679252212594626395510336467040167069592778062791519851593659551227755\"," + "\"side\":\"BUY\",\"original_size\":\"10000\",\"size_matched\":\"3000\"," + "\"price\":\"0.55\"}"; + AccountEvent canc{}; + CHECK(parser.parse(cancel, std::strlen(cancel), canc) && + canc.type == AccountEvent::Type::CANCEL && + canc.order_hash == open.order_hash, + "CANCELLATION maps to a reservation release for the same order"); + + const char* other_asset = + "{\"event_type\":\"trade\",\"type\":\"MATCHED\",\"id\":\"0xt2\"," + "\"asset_id\":\"42\",\"side\":\"SELL\",\"size\":\"5\"," + "\"price\":\"0.5\",\"status\":\"MATCHED\"}"; + AccountEvent ignored{}; + CHECK(!parser.parse(other_asset, std::strlen(other_asset), ignored), + "events for other assets are filtered"); + + const char* dup_key = + "{\"event_type\":\"trade\",\"event_type\":\"trade\",\"type\":\"MATCHED\"," + "\"id\":\"0xt3\",\"asset_id\":\"71321045679252212594626395510336467040167069592778062791519851593659551227755\"," + "\"side\":\"BUY\",\"size\":\"5\",\"price\":\"0.5\"}"; + AccountEvent hostile{}; + CHECK(!parser.parse(dup_key, std::strlen(dup_key), hostile), + "duplicate semantic keys fail closed"); + + const char* failed = + "{\"event_type\":\"trade\",\"type\":\"FAILED\"," + "\"id\":\"0xt4\",\"taker_order_id\":\"0xorder9\"," + "\"asset_id\":\"71321045679252212594626395510336467040167069592778062791519851593659551227755\"," + "\"side\":\"BUY\",\"size\":\"7000\",\"price\":\"0.55\"," + "\"status\":\"FAILED\"}"; + AccountEvent fail{}; + CHECK(parser.parse(failed, std::strlen(failed), fail) && + fail.type == AccountEvent::Type::FAILED, + "FAILED trade normalizes as a release, never as a fill"); +} + +// ── P1: engine + tracker integration (no double exposure) ─────────────────── +static void init_fixture(MarketConfig& cfg, EIP712Signer& signer) { + const char* key_text = + "23dd72ba9070d7903cf60cad22700819abb7ae93c5788e15f038a0ece0a6697b"; + uint8_t key[32]; + if (!parse_hex_bytes(key_text, 64, key, sizeof(key))) std::abort(); + if (!signer.init(key, false)) std::abort(); + secure_zero(key, sizeof(key)); + const char* token = + "71321045679252212594626395510336467040167069592778062791519851593659551227755"; + std::snprintf(cfg.token_id_dec, sizeof(cfg.token_id_dec), "%s", token); + if (!parse_uint256_dec(token, std::strlen(token), cfg.token_id_be)) + std::abort(); + if (cfg.finalize_identity(signer.signer_address()) != nullptr) std::abort(); + std::snprintf(cfg.owner_api_key, sizeof(cfg.owner_api_key), "test-owner"); + std::snprintf(cfg.market_slug, sizeof(cfg.market_slug), "layers-market"); + cfg.market_hash = alpha_hash_bytes(cfg.market_slug, + std::strlen(cfg.market_slug)); +} + +static AlphaSignal make_buy_signal(const MarketConfig& cfg, double p_win, + uint64_t id) { + AlphaSignal signal{}; + signal.direction_hint = K_SIDE_BUY; + signal.p_win = p_win; + signal.confidence = 0.95; + signal.q_value = 0.01; + signal.timestamp_ns = AlphaParser::realtime_ns(); + signal.market_hash = cfg.market_hash; + signal.signal_id = id; + return signal; +} + +static void test_engine_tracker_integration() { + std::printf("engine_tracker_integration\n"); + MarketConfig cfg; + EIP712Signer signer; + init_fixture(cfg, signer); + cfg.max_order_usd = 200.0; + cfg.max_exposure_usd = 300.0; + cfg.max_daily_loss_usd = 500.0; + cfg.bankroll_usd = 10000.0; + cfg.min_size_shares = 5000000; + + OrderBookL2 book; + book.set_tick_size(10000); + const Level2Entry bids[] = {{470000, 5000000000ULL}}; + const Level2Entry asks[] = {{530000, 5000000000ULL}}; + book.set_book(bids, 1, asks, 1); + + SPSC_RingBuffer signals; + SPSC_RingBuffer account_q; + SPSC_RingBuffer journal_q; + PositionTracker tracker; + EngineLayers layers{}; + layers.account_q = &account_q; + layers.tracker = &tracker; + layers.journal_q = &journal_q; + + PresignedOrderPool pool(cfg, signer, cfg.presign_ttl_ms); + pool.rebuild(470000, 530000, 400000000ULL, 10000); + MockCLOBClient client(cfg); + ExecutionEngine engine( + cfg, book, signals, signer, pool, client, nullptr, &layers); + + // Signal 1: BUY ~54,716 @ 0.53 → reserved (mock accepts, no fill yet). + signals.try_push(make_buy_signal(cfg, 0.75, 1)); + CHECK(engine.run_tick() == TickResult::SUBMITTED, + "first buy signal submits"); + CHECK(tracker.open_buy() > 0 && tracker.net_yes() == 0, + "accepted order is a reservation, not inventory"); + const uint64_t reserved = tracker.open_buy(); + + // Signal 2: an identical buy is budget-capped; the tracker must account + // for both reservations before the partial fill below is applied. + signals.try_push(make_buy_signal(cfg, 0.75, 2)); + const TickResult second = engine.run_tick(); + CHECK(second == TickResult::SUBMITTED || second == TickResult::TOO_SMALL || + second == TickResult::RISK_REJECTED, + "second buy respects budget caps"); + const uint64_t reserved_total = tracker.open_buy(); + CHECK(reserved_total >= reserved, + "both accepted buys accumulate reservations (no overwrite)"); + + // Partial fill arrives through the user channel: 40% of the reservation. + const uint64_t fill_qty = reserved_total * 2 / 5; + AccountEvent fill = make_fill(0, 0.53, 0.0, 5, 9001); + fill.size = fill_qty; + account_q.try_push(fill); + CHECK(engine.run_tick() == TickResult::ACCOUNT_APPLIED, + "account event is processed in housekeeping"); + CHECK(tracker.net_yes() == fill_qty && + tracker.open_buy() == reserved_total - fill_qty, + "partial fill splits reservation/inventory exactly"); + + // SELL signal with inventory now available: must succeed, reserving at + // most the reconciled (filled) inventory. + AlphaSignal sell = make_buy_signal(cfg, 0.20, 3); + sell.direction_hint = K_SIDE_SELL; + signals.try_push(sell); + CHECK(engine.run_tick() == TickResult::SUBMITTED, + "sell uses reconciled inventory, not bootstrap guesses"); + const uint64_t first_sell_reserved = tracker.open_sell(); + CHECK(first_sell_reserved > 0 && first_sell_reserved <= fill_qty, + "sell reservation never exceeds filled inventory"); + + // A second SELL can only take the remainder; cumulative reservations must + // never exceed the filled inventory (no duplicated exposure). + AlphaSignal sell2 = make_buy_signal(cfg, 0.20, 4); + sell2.direction_hint = K_SIDE_SELL; + signals.try_push(sell2); + const TickResult sell2_result = engine.run_tick(); + CHECK(sell2_result == TickResult::SUBMITTED || + sell2_result == TickResult::NO_INVENTORY, + "second sell either takes the exact remainder or finds nothing"); + CHECK(tracker.open_sell() <= tracker.net_yes(), + "cumulative sell reservations never duplicate inventory"); + CHECK(tracker.sellable() == 0, + "inventory is fully reserved after both sells"); + + // A third SELL must find nothing sellable at all. + AlphaSignal sell3 = make_buy_signal(cfg, 0.20, 5); + sell3.direction_hint = K_SIDE_SELL; + signals.try_push(sell3); + CHECK(engine.run_tick() == TickResult::NO_INVENTORY, + "third sell cannot duplicate exposure of the same inventory"); + + // Journal captured fill + orders. + JournalEvent jrn{}; + unsigned seen = 0; + while (journal_q.try_pop(jrn)) ++seen; + CHECK(seen >= 3, "journal recorded fills and order outcomes"); +} + +// ── P2: RiskManager units ──────────────────────────────────────────────────── +static void test_risk_units() { + std::printf("risk_manager_units\n"); + RiskLimits limits{}; + limits.stop_loss_pct = 0.20; + limits.max_daily_loss_usd = 50.0; + limits.max_market_exposure_usd = 100.0; + limits.max_portfolio_exposure_usd = 200.0; + RiskManager risk(limits); + CHECK(!risk.killed(), "kill starts unlatched"); + CHECK(risk.authorize(0, 50.0, 40.0, 60.0), "order within caps authorized"); + CHECK(!risk.authorize(0, 70.0, 40.0, 60.0), + "market exposure cap denies order"); + CHECK(!risk.authorize(0, 50.0, 40.0, 160.0), + "portfolio exposure cap denies order"); + CHECK(RiskManager::unrealized_pnl(100000000, 700000, 400000) == + -30000000LL, + "long 100 @0.70 marked at bid 0.40 is -30 USD floating"); + CHECK(RiskManager::unrealized_pnl(0, 700000, 400000) == 0, + "flat book has no floating P&L"); + + CHECK(risk.maintain_day_anchor(-10000000LL), "first day anchors base"); + CHECK(!risk.maintain_day_anchor(-5000000LL), "same day keeps its base"); + CHECK(risk.day_realized(-5000000LL) == 5000000LL, + "day realized is measured from the anchored base"); + + risk.latch_kill(); + CHECK(risk.killed() && !risk.authorize(0, 1.0, 0.0, 0.0), + "latched kill denies every new order"); +} + +static void test_risk_evaluate_ladder() { + std::printf("risk_evaluate_ladder\n"); + RiskLimits limits{}; + limits.stop_loss_pct = 0.30; + limits.hedge_trigger_pct = 0.10; + limits.max_daily_loss_usd = 1000.0; + limits.max_market_exposure_usd = 100000.0; + limits.max_portfolio_exposure_usd = 100000.0; + RiskManager risk(limits); + risk.maintain_day_anchor(0); + + PositionTracker tracker(100000000ULL, 700000); // 100 YES @ 0.70 + OrderBookL2::Top top{}; + top.bid = {660000, 1000000}; // drop 5.7% — no action + top.ask = {680000, 1000000}; + top.updated_ns = crowdintel::mono_ns(); + RiskDecision decision = risk.evaluate(top, tracker, 300000, 320000); + CHECK(decision.action == RiskAction::NONE, "small dip triggers nothing"); + + top.bid = {620000, 1000000}; // drop 11.4% — hedge triggers first + decision = risk.evaluate(top, tracker, 300000, 320000); + CHECK(decision.action == RiskAction::HEDGE && + decision.shares == 100000000ULL, + "hedge trigger fires before the stop-loss"); + + top.bid = {450000, 1000000}; // drop 35.7% — stop-loss takes priority + decision = risk.evaluate(top, tracker, 300000, 320000); + CHECK(decision.action == RiskAction::CLOSE && + decision.shares == 100000000ULL && + decision.projected_loss == -25000000LL, + "stop-loss closes the whole position and projects the loss"); + + // Daily-loss kill: realized −80 against a 1000 budget does nothing… + PositionTracker burnt(100000000ULL, 900000); + burnt.apply(make_fill(1, 0.10, 100.0, 1, 77)); // realize −80 USD + RiskLimits tight = limits; + tight.max_daily_loss_usd = 50.0; + risk.configure(tight); + decision = risk.evaluate(top, burnt, 0, 0); + CHECK(decision.action == RiskAction::KILL && risk.killed(), + "day loss beyond budget latches the kill switch"); +} + +// ── P2: engine stop-loss integration (acceptance: 0.70 → 0.10 crash) ──────── +static void test_engine_stop_loss_on_crash() { + std::printf("engine_stop_loss_on_crash\n"); + MarketConfig cfg; + EIP712Signer signer; + init_fixture(cfg, signer); + cfg.stop_loss_pct = 0.30; + cfg.max_daily_loss_usd = 500.0; + cfg.max_exposure_usd = 10000.0; + cfg.max_portfolio_exposure_usd = 10000.0; + cfg.initial_position_shares = 100000000ULL; // 100 YES + cfg.initial_position_avg_price = 0.70; + + OrderBookL2 book; + book.set_tick_size(10000); + Level2Entry bids[1] = {{690000, 100000000000ULL}}; + Level2Entry asks[1] = {{710000, 100000000000ULL}}; + book.set_book(bids, 1, asks, 1); + + SPSC_RingBuffer signals; + SPSC_RingBuffer account_q; + SPSC_RingBuffer journal_q; + PositionTracker tracker(cfg.initial_position_shares, 700000); + RiskLimits limits{}; + limits.stop_loss_pct = cfg.stop_loss_pct; + limits.max_daily_loss_usd = cfg.max_daily_loss_usd; + limits.max_market_exposure_usd = cfg.max_exposure_usd; + limits.max_portfolio_exposure_usd = cfg.max_portfolio_exposure_usd; + RiskManager risk(limits); + EngineLayers layers{}; + layers.account_q = &account_q; + layers.tracker = &tracker; + layers.journal_q = &journal_q; + layers.risk = &risk; + + std::atomic trading_enabled{true}; + PresignedOrderPool pool(cfg, signer, cfg.presign_ttl_ms); + MockCLOBClient client(cfg); + ExecutionEngine engine( + cfg, book, signals, signer, pool, client, &trading_enabled, &layers); + + CHECK(engine.run_tick() == TickResult::NO_SIGNAL, + "calm book at entry price triggers no protective action"); + + // Crash 0.70 → 0.10 across fast steps (the 2-second window of the + // acceptance criterion, compressed: marks only move through set_book). + bool stop_seen = false; + const uint64_t marks[] = {610000, 500000, 390000, 240000, 100000}; + for (const uint64_t mark : marks) { + bids[0] = {mark, 100000000000ULL}; + asks[0] = {mark + 10000, 100000000000ULL}; + book.set_book(bids, 1, asks, 1); + const TickResult result = engine.run_tick(); + if (result == TickResult::RISK_STOP_LOSS) { + stop_seen = true; + break; + } + } + CHECK(stop_seen, "stop-loss fires on the way down without human input"); + CHECK(client.submissions() == 1, + "exactly one protective close order was emitted"); + CHECK(tracker.open_sell() == 100000000ULL, + "the close reserves the entire sellable inventory once"); + + // Next ticks do not duplicate the close: reservation exhausts sellable. + const TickResult again = engine.run_tick(); + CHECK(again != TickResult::RISK_STOP_LOSS && client.submissions() == 1, + "protective close is not duplicated while in flight"); + + // The venue fill lands through the user channel; P&L is realized. + account_q.try_push(make_fill(1, 0.10, 100.0, 91, 9101)); + engine.run_tick(); + CHECK(tracker.net_yes() == 0 && + tracker.realized_pnl() == -60000000LL, + "stop-loss exit realizes the loss: P&L recorded (-60 USD)"); + + // Journal proves the trigger and the fill. + JournalEvent last{}; + bool stop_event = false, fill_event = false; + while (journal_q.try_pop(last)) { + if (last.type == JournalEvent::Type::STOP_LOSS_TRIGGERED) { + stop_event = true; + CHECK(last.pnl < 0, "stop-loss journal carries projected loss"); + } + if (last.type == JournalEvent::Type::ACCOUNT_FILL) fill_event = true; + } + CHECK(stop_event && fill_event, "journal contains trigger and exit fill"); +} + +static void test_engine_kill_freezes_orders() { + std::printf("engine_kill_freezes_orders\n"); + MarketConfig cfg; + EIP712Signer signer; + init_fixture(cfg, signer); + cfg.max_daily_loss_usd = 50.0; + cfg.max_exposure_usd = 10000.0; + cfg.max_portfolio_exposure_usd = 10000.0; + + OrderBookL2 book; + book.set_tick_size(10000); + const Level2Entry bids[] = {{470000, 1000000000ULL}}; + const Level2Entry asks[] = {{530000, 1000000000ULL}}; + book.set_book(bids, 1, asks, 1); + + SPSC_RingBuffer signals; + SPSC_RingBuffer account_q; + SPSC_RingBuffer journal_q; + PositionTracker tracker; // starts flat; losses happen during this session + RiskLimits limits{}; + limits.max_daily_loss_usd = cfg.max_daily_loss_usd; + limits.max_market_exposure_usd = cfg.max_exposure_usd; + limits.max_portfolio_exposure_usd = cfg.max_portfolio_exposure_usd; + RiskManager risk(limits); + EngineLayers layers{}; + layers.account_q = &account_q; + layers.tracker = &tracker; + layers.journal_q = &journal_q; + layers.risk = &risk; + + std::atomic trading_enabled{true}; + PresignedOrderPool pool(cfg, signer, cfg.presign_ttl_ms); + MockCLOBClient client(cfg); + ExecutionEngine engine( + cfg, book, signals, signer, pool, client, &trading_enabled, &layers); + + // Tick 1 anchors the day at zero realized P&L (no position yet). + (void)engine.run_tick(); + // Realize an −$80 loss inside the session: buy 100 @0.90, sell 100 @0.10. + account_q.try_push(make_fill(0, 0.90, 100.0, 1, 81)); + account_q.try_push(make_fill(1, 0.10, 100.0, 2, 82)); + const TickResult killed = engine.run_tick(); + CHECK(killed == TickResult::RISK_KILL_SWITCH && !trading_enabled.load(), + "day-loss kill latches and freezes the trading flag"); + CHECK(risk.killed(), "kill state persists in the manager"); + + signals.try_push(make_buy_signal(cfg, 0.90, 9)); + CHECK(engine.run_tick() == TickResult::RISK_REJECTED && + client.submissions() == 0, + "post-kill alpha signals are denied before signing"); +} + +static void test_engine_hedge_on_dip() { + std::printf("engine_hedge_on_dip\n"); + MarketConfig cfg; + EIP712Signer signer; + init_fixture(cfg, signer); + cfg.hedge_trigger_pct = 0.10; + cfg.stop_loss_pct = 0.30; + cfg.max_daily_loss_usd = 500.0; + cfg.max_exposure_usd = 10000.0; + cfg.max_portfolio_exposure_usd = 10000.0; + std::strcpy(cfg.hedge_token_id_dec, "98765432109876543210"); + std::memset(cfg.hedge_token_id_be, 0x07, sizeof(cfg.hedge_token_id_be)); + cfg.initial_position_shares = 100000000ULL; // 100 YES + cfg.initial_position_avg_price = 0.70; + + OrderBookL2 book, hedge_book; + book.set_tick_size(10000); + hedge_book.set_tick_size(10000); + Level2Entry bids[1] = {{600000, 100000000000ULL}}; // drop 14.3% vs 0.70 + Level2Entry asks[1] = {{610000, 100000000000ULL}}; + book.set_book(bids, 1, asks, 1); + const Level2Entry hbids[] = {{340000, 100000000000ULL}}; + const Level2Entry hasks[] = {{350000, 100000000000ULL}}; + hedge_book.set_book(hbids, 1, hasks, 1); + + SPSC_RingBuffer signals; + SPSC_RingBuffer account_q; + SPSC_RingBuffer journal_q; + PositionTracker tracker(cfg.initial_position_shares, 700000); + RiskLimits limits{}; + limits.stop_loss_pct = cfg.stop_loss_pct; + limits.hedge_trigger_pct = cfg.hedge_trigger_pct; + limits.max_daily_loss_usd = cfg.max_daily_loss_usd; + limits.max_market_exposure_usd = cfg.max_exposure_usd; + limits.max_portfolio_exposure_usd = cfg.max_portfolio_exposure_usd; + RiskManager risk(limits); + EngineLayers layers{}; + layers.account_q = &account_q; + layers.tracker = &tracker; + layers.journal_q = &journal_q; + layers.risk = &risk; + layers.hedge_book = &hedge_book; + + std::atomic trading_enabled{true}; + PresignedOrderPool pool(cfg, signer, cfg.presign_ttl_ms); + MockCLOBClient client(cfg); + ExecutionEngine engine( + cfg, book, signals, signer, pool, client, &trading_enabled, &layers); + + const TickResult result = engine.run_tick(); + CHECK(result == TickResult::RISK_HEDGE && client.submissions() == 1, + "dip below hedge trigger emits one hedge buy on the complement"); + CHECK(tracker.net_yes() == 100000000ULL && tracker.net_hedge() == 0, + "YES inventory untouched; hedge not yet filled"); + + // The dip persists but the hedge is already complete: no duplicate hedge + // (shares = net - net_hedge becomes zero after the hedge fill lands). + account_q.try_push(make_fill(0, 0.35, 100.0, 55, 5501, 1)); // hedge fill + const TickResult after = engine.run_tick(); + CHECK(after != TickResult::RISK_HEDGE && client.submissions() == 1, + "no duplicate hedge once the complement leg is covered"); + CHECK(tracker.net_hedge() == 100000000ULL && + tracker.net_yes() == 100000000ULL, + "hedge fill builds complement inventory at venue truth"); + CHECK(tracker.realized_pnl() == 0, + "hedging locks the spread: nothing realized until resolution"); +} + +// ── P3: VolatilityGate ─────────────────────────────────────────────────────── +static void test_vol_gate_units() { + std::printf("vol_gate_units\n"); + { // Regime sampler: NORMAL → ELEVATED (wide spread) → EXTREME → NORMAL. + MarketConfig cfg; // defaults: dev 200bps, wide 1500bps, tick 200Hz + VolatilityGate gate(cfg); + const uint64_t base_ttl = cfg.presign_ttl_ms; // 3000 + const uint64_t t0 = 1000000000ULL; + gate.sample(495000, 505000, t0, base_ttl); // spread 100bps of mid + CHECK(gate.regime() == 0 && gate.effective_ttl_ms() == base_ttl && + gate.size_permille() == 1000 && !gate.paused(), + "normal regime keeps full ladder TTL and size"); + gate.sample(460000, 540000, t0 + 10000000ULL, base_ttl); // 1600bps + CHECK(gate.regime() == 1 && gate.effective_ttl_ms() == 500 && + gate.size_permille() == 500 && !gate.paused(), + "wide spread elevates: TTL shrinks to vol TTL, size halves"); + // Mid churning 450 times in-window while spread stays wide → rate + // >= 2*200 → EXTREME: paused, deepest shrink and TTL. + uint64_t t = t0 + 20000000ULL; + for (int i = 0; i < 450; ++i) { + const uint64_t mid = 500000 + (i & 1 ? 1000 : 0); + gate.sample(mid - 40000, mid + 40000, t, base_ttl); + t += 1000000ULL; + } + gate.sample(460000, 540000, t + 1200000000ULL, base_ttl); + CHECK(gate.regime() == 2 && gate.paused() && + gate.effective_ttl_ms() <= 250 && + gate.size_permille() <= 250, + "extreme churn pauses passive flow with deepest shrink"); + // Cool-off: narrow spread and quiet mid restore the full ladder. + gate.sample(495000, 505000, t + 2600000000ULL, base_ttl); + CHECK(gate.regime() == 0 && !gate.paused() && + gate.effective_ttl_ms() == base_ttl && + gate.size_permille() == 1000, + "calm book restores full ladder parameters"); + } + { // Shock guard: >5% mid jump inside 100 ms arms a 250 ms cooldown. + MarketConfig cfg; + VolatilityGate gate(cfg); // default mid gap 500 bps = 5% + const uint64_t t0 = 1000000000ULL; + CHECK(!gate.observe_mid(500000, t0), "first mid arms nothing"); + CHECK(gate.observe_mid(527000, t0 + 50000000ULL), + "5.4% jump in 50 ms arms the cooldown"); + CHECK(gate.observe_mid(530000, t0 + 60000000ULL), + "cooldown keeps blocking while armed"); + CHECK(!gate.observe_mid(531000, t0 + 400000000ULL), + "cooldown lapses after 250 ms"); + CHECK(gate.shocks() == 1, "exactly one shock was counted"); + // A small move inside the window never arms. + CHECK(!gate.observe_mid(531000, t0 + 500000000ULL), "quiet re-arm"); + CHECK(!gate.observe_mid(535000, t0 + 510000000ULL), + "0.75% move stays under the 5% bar"); + CHECK(gate.shocks() == 1, "no phantom shocks on small moves"); + } + { // Slippage-vs-mid gate honours BOT_POOL_MAX_DEV_BPS (0 = off). + MarketConfig cfg; // default 200 bps + VolatilityGate gate(cfg); + CHECK(gate.slippage_ok(0, 506000, 500000), "buy 120bps from mid ok"); + CHECK(!gate.slippage_ok(0, 511000, 500000), "buy 220bps rejected"); + CHECK(gate.slippage_ok(1, 494000, 500000), "sell 120bps from mid ok"); + CHECK(!gate.slippage_ok(1, 489000, 500000), "sell 220bps rejected"); + MarketConfig off_cfg; + off_cfg.pool_max_dev_bps = 0.0; + VolatilityGate off_gate(off_cfg); + CHECK(off_gate.slippage_ok(0, 900000, 500000), + "dev=0 disables the gate explicitly"); + } +} + +static void test_pool_dynamic_ttl() { + std::printf("pool_dynamic_ttl\n"); + MarketConfig cfg; + EIP712Signer signer; + init_fixture(cfg, signer); + PresignedOrderPool pool(cfg, signer, 60000); // 60 s static TTL + const uint64_t target = + KellyEngine::usd_to_shares_fixed(cfg.max_order_usd, 0.505); + CHECK(pool.rebuild(495000, 505000, target, 10000), + "ladder builds for the TTL experiment"); + WireBody body{}; + uint64_t size = 0, maker = 0, taker = 0; + CHECK(pool.acquire_at_most(0, 505000, 10000, target, body, size, maker, + taker, 0), + "static TTL admits a fresh slot (max_age=0)"); + CHECK(pool.rebuild(495000, 505000, target, 10000), + "second ladder builds"); + std::this_thread::sleep_for(std::chrono::milliseconds(6)); + WireBody body2{}; + CHECK(pool.acquire_at_most(0, 505000, 10000, target, body2, size, maker, + taker, 60000), + "wide dynamic TTL still admits a fresh slot"); + CHECK(pool.rebuild(495000, 505000, target, 10000), + "third ladder builds"); + std::this_thread::sleep_for(std::chrono::milliseconds(6)); + WireBody body3{}; + CHECK(!pool.acquire_at_most(0, 505000, 10000, target, body3, size, maker, + taker, 1), + "1 ms dynamic TTL expires the rebuiltslot after 6 ms"); +} + +// ── P3 engine integration: 5%+ jump in <100 ms must not fire a stale order ── +static void test_engine_shock_acceptance() { + std::printf("engine_shock_acceptance\n"); + MarketConfig cfg; + EIP712Signer signer; + init_fixture(cfg, signer); + cfg.max_exposure_usd = 10000.0; + cfg.max_portfolio_exposure_usd = 10000.0; + cfg.max_daily_loss_usd = 10000.0; // second signal must fit the budget + + OrderBookL2 book; + book.set_tick_size(10000); + Level2Entry bids[1] = {{495000, 100000000000ULL}}; + Level2Entry asks[1] = {{505000, 100000000000ULL}}; + book.set_book(bids, 1, asks, 1); // tight 1 c spread around mid 0.50 + + SPSC_RingBuffer signals; + SPSC_RingBuffer journal_q; + VolatilityGate gate(cfg); + EngineLayers layers{}; + layers.journal_q = &journal_q; + layers.volatility = &gate; + + std::atomic trading_enabled{true}; + PresignedOrderPool pool(cfg, signer, cfg.presign_ttl_ms); + MockCLOBClient client(cfg); + ExecutionEngine engine( + cfg, book, signals, signer, pool, client, &trading_enabled, &layers); + + // 1. Calm tight book: the gate must not alter the baseline behavior. + signals.try_push(make_buy_signal(cfg, 0.90, 301)); + CHECK(engine.run_tick() == TickResult::SUBMITTED && + client.submissions() == 1, + "calm tight book trades through the gate untouched"); + + // 2. Mid jumps +6% (0.50 -> 0.53) between two consecutive ticks + // (microseconds apart in wall clock, far under the 100 ms window). + bids[0] = {525000, 100000000000ULL}; + asks[0] = {535000, 100000000000ULL}; + book.set_book(bids, 1, asks, 1); + signals.try_push(make_buy_signal(cfg, 0.90, 302)); + const TickResult shocked = engine.run_tick(); + CHECK(shocked == TickResult::VOLATILITY_PAUSED && + client.submissions() == 1, + ">5% jump in <100ms: stale order NOT consumed, flow paused"); + CHECK(gate.shocks() == 1 && gate.aborts() == 0, + "shock counted exactly once, no slippage aborts on the way"); + + // The journal records the suppression (acceptance: "logs and adapts"). + JournalEvent ev{}; + bool stale_logged = false; + while (journal_q.try_pop(ev)) { + if (ev.type == JournalEvent::Type::POOL_STALE_DROP) { + stale_logged = true; + CHECK(ev.aux0 == 530000 || ev.aux0 == 500000, + "journal carries the mid at shock time"); + } + } + CHECK(stale_logged, "suppressed stale ladder consumption is journaled"); + + // 3. Cooldown lapses (250 ms real time); the new book becomes the new + // normal and passive flow resumes at the refreshed prices. + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + signals.try_push(make_buy_signal(cfg, 0.90, 303)); + const TickResult resumed = engine.run_tick(); + CHECK(resumed == TickResult::SUBMITTED && client.submissions() == 2, + "after the cooldown the gate adapts: flow resumes at new prices"); +} + +// ── P4: BayesianEngine posterior math ──────────────────────────────────────── +static void test_bayes_math() { + std::printf("bayes_math\n"); + { // Prior from mid × N0 is an exact Beta(N0·p, N0·(1−p)). + BayesianEngine eng; + eng.ensure_prior(0.40, 24.0); + CHECK(eng.has_prior(), "prior anchored at the book mid"); + CHECK(std::fabs(eng.posterior() - 0.40) < 1e-12, + "prior posterior equals the mid that seeded it"); + // Acceptance-style COUNT evidence: 32 trials, 22 YES, weight 0.9. + // α0: 9.6 + 22·0.9 = 29.4; α1: 14.4 + 10·0.9 = 23.4. + eng.update_count(0, 32, 22, 900000); + const double p = eng.posterior(); + CHECK(std::fabs(p - (29.4 / 52.8)) < 1e-12 && p > 0.55 && p < 0.56, + "beta-binomial posterior lands at 0.557 ($0.40 + high-rel)"); + CHECK(eng.count_events() == 1 && eng.events() == 1 && + eng.lr_events() == 0, + "event accounting is exact"); + } + { // LR evidence multiplies the log-odds exactly through the sigmoid. + BayesianEngine eng; + eng.ensure_prior(0.40, 24.0); + eng.update_lr(693147, 1000000); // +ln2 at full weight + const double g = std::log(0.4 / 0.6) + 0.693147; + const double expected = 1.0 / (1.0 + std::exp(-g)); + CHECK(std::fabs(eng.posterior() - expected) < 1e-9, + "lr evidence shifts the posterior via the closed sigmoid"); + CHECK(eng.count_events() == 0 && eng.lr_events() == 1, + "lr accounting kept separate from counts"); + } + { // Dirichlet: complement mass renormalizes proportionally over the + // other slots; totals and shape stay conjugate-exact. + BayesianEngine eng; + eng.ensure_prior(0.40, 24.0); + eng.update_count(2, 12, 9, 1000000); // 9 of 12 hits on outcome 2 + // α0 = 9.6 + 3·(9.6/24) = 10.8; α1 = 14.4 + 3·(14.4/24) = 16.2; + // α2 = 9; total 36 → 0.30 / 0.45 / 0.25. + CHECK(std::fabs(eng.posterior(0) - 0.30) < 1e-12 && + std::fabs(eng.posterior(1) - 0.45) < 1e-12 && + std::fabs(eng.posterior(2) - 0.25) < 1e-12, + "dirichlet-multinomial keeps shape-preserving conjugacy"); + const double sum = eng.posterior(0) + eng.posterior(1) + + eng.posterior(2); + CHECK(std::fabs(sum - 1.0) < 1e-12, + "multi-outcome posteriors stay normalized"); + } + { // Structural guards: no prior, bad slots, zero weight are inert. + BayesianEngine eng; + eng.update_count(0, 10, 5, 1000000); // no prior yet + CHECK(eng.posterior() < 0.0, "no posterior before the prior seeds"); + eng.ensure_prior(0.5, 10.0); + eng.update_count(0, 10, 5, 0); // zero weight + eng.update_count(9, 10, 5, 1000000); // out-of-range slot + eng.update_count(0, 10, 11, 1000000); // k > n + CHECK(std::fabs(eng.posterior() - 0.5) < 1e-12 && + eng.events() == 0, + "invalid evidence is ignored without touching the state"); + // Idempotent seeding: a second call cannot move the anchor. + eng.ensure_prior(0.9, 100.0); + CHECK(std::fabs(eng.posterior() - 0.5) < 1e-12, + "prior seeding is one-shot by design"); + } +} + +// ── P4 engine integration: reliability gating + posterior trigger ──────────── +static EvidenceEvent make_count_evidence(uint32_t source, uint32_t n, + uint32_t k, uint32_t hash) { + EvidenceEvent ev{}; + ev.kind = EvidenceEvent::Kind::COUNT; + ev.outcome = 0; + ev.source_id = source; + ev.count_n = n; + ev.count_k = k; + ev.event_hash = hash; + ev.timestamp_ns = crowdintel::realtime_ns(); + return ev; +} + +static void test_engine_brain_acceptance() { + std::printf("engine_brain_acceptance\n"); + MarketConfig cfg; + EIP712Signer signer; + init_fixture(cfg, signer); + cfg.bayes_prior_strength = 24.0; + cfg.bayes_signal_threshold = 0.03; + cfg.bayes_min_reliability = 0.35; + cfg.bayes_enable = true; + cfg.max_exposure_usd = 10000.0; + cfg.max_portfolio_exposure_usd = 10000.0; + cfg.max_daily_loss_usd = 10000.0; + + OrderBookL2 book; + book.set_tick_size(10000); + Level2Entry bids[1] = {{395000, 100000000000ULL}}; + Level2Entry asks[1] = {{405000, 100000000000ULL}}; + book.set_book(bids, 1, asks, 1); // mid exactly 0.40 + + SPSC_RingBuffer signals; + SPSC_RingBuffer evidence_q; + SPSC_RingBuffer journal_q; + BayesianEngine bayes; + SourceReliability sources; + sources.set_weight(1, 0.90); // reliable polling source + sources.set_weight(2, 0.10); // unreliable source + EngineLayers layers{}; + layers.journal_q = &journal_q; + layers.evidence_q = &evidence_q; + layers.bayes = &bayes; + layers.sources = &sources; + + std::atomic trading_enabled{true}; + PresignedOrderPool pool(cfg, signer, cfg.presign_ttl_ms); + MockCLOBClient client(cfg); + ExecutionEngine engine( + cfg, book, signals, signer, pool, client, &trading_enabled, &layers); + + // Tick 1 seeds the prior from the mid (0.40); nothing fires. + CHECK(engine.run_tick() == TickResult::NO_SIGNAL, + "prior seeding alone never fires"); + CHECK(bayes.has_prior() && + std::fabs(bayes.posterior() - 0.40) < 1e-12, + "posterior anchored at 0.40"); + + // Reliability gate FIRST: untrusted source evidence never moves the + // posterior (acceptance: no order with low reliability). + evidence_q.try_push(make_count_evidence(2, 32, 22, 9901)); + CHECK(engine.run_tick() == TickResult::NO_SIGNAL && + client.submissions() == 0 && + std::fabs(bayes.posterior() - 0.40) < 1e-12, + "low-reliability evidence stalls at the gate"); + + // Trusted evidence: 32 trials / 22 YES at weight 0.9 → posterior 0.557, + // buy divergence = 0.557 − 0.405 ≫ 0.03 → BAYES_SIGNAL order emitted. + evidence_q.try_push(make_count_evidence(1, 32, 22, 9902)); + const TickResult fired = engine.run_tick(); + CHECK(fired == TickResult::BAYES_SIGNAL && client.submissions() == 1, + "posterior 0.557 with ask 0.405 emits exactly one buy order"); + CHECK(bayes.posterior() > 0.42, + "posterior actually moved above the ask"); + + // One shot per batch: no new evidence means no re-fire next tick. + CHECK(engine.run_tick() == TickResult::NO_SIGNAL && + client.submissions() == 1, + "signal fires once per evidence batch, never repeatedly"); + + // Journal captures the whole story: gate rejection, posterior update, + // and the emitted bayesian order. + JournalEvent ev{}; + bool low_rel = false, update_seen = false, signal_seen = false; + double journaled_posterior = 0.0; + while (journal_q.try_pop(ev)) { + if (ev.type == JournalEvent::Type::BAYES_LOW_RELIABILITY) { + low_rel = true; + CHECK(ev.aux0 == 2, "untrusted source id journaled"); + } + if (ev.type == JournalEvent::Type::BAYES_UPDATE) + update_seen = true; + if (ev.type == JournalEvent::Type::BAYES_SIGNAL) { + signal_seen = true; + journaled_posterior = static_cast(ev.aux0) * 1e-6; + } + } + CHECK(low_rel && update_seen && signal_seen, + "journal holds gate, update, and emitted-signal records"); + CHECK(journaled_posterior > 0.42 && journaled_posterior < 0.60, + "journaled signal carries the firing posterior"); +} + +int main() { + std::printf("== CROWDINTEL layer tests ==\n"); + test_tracker_partial_fill(); + test_tracker_vwap_pnl_dedup(); + test_tracker_reservations_snapshot(); + test_user_event_parser(); + test_engine_tracker_integration(); + test_risk_units(); + test_risk_evaluate_ladder(); + test_engine_stop_loss_on_crash(); + test_engine_kill_freezes_orders(); + test_engine_hedge_on_dip(); + test_vol_gate_units(); + test_pool_dynamic_ttl(); + test_engine_shock_acceptance(); + test_bayes_math(); + test_engine_brain_acceptance(); + std::printf("== %s (%d failures) ==\n", g_failures ? "FAILED" : "ALL PASS", + g_failures); + return g_failures ? 1 : 0; +}