From ac908f1b1c188c85144f023e35c14ca759e6d031 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Tue, 14 Jul 2026 13:30:06 +0200 Subject: [PATCH] robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081) --- benchmarks/robinhood-subsidy.yml | 101 ++++++ benchmarks/tokenized-stock-arb-latency.yml | 209 ++++++++++++ benchmarks/tokenized-stock-weekend-drift.yml | 199 +++++++++++ harnesses/robinhood-subsidy/Dockerfile | 22 ++ .../robinhood-subsidy/cmd/script/loghub.go | 114 +++++++ .../robinhood-subsidy/cmd/script/main.go | 315 ++++++++++++++++++ harnesses/robinhood-subsidy/go.mod | 18 + harnesses/robinhood-subsidy/go.sum | 46 +++ .../cmd/script/arb_latency.go | 150 +++++++++ .../tokenized-stock-peg/cmd/script/main.go | 9 + src/lib/removed-benches.ts | 3 + 11 files changed, 1186 insertions(+) create mode 100644 benchmarks/robinhood-subsidy.yml create mode 100644 benchmarks/tokenized-stock-arb-latency.yml create mode 100644 benchmarks/tokenized-stock-weekend-drift.yml create mode 100644 harnesses/robinhood-subsidy/Dockerfile create mode 100644 harnesses/robinhood-subsidy/cmd/script/loghub.go create mode 100644 harnesses/robinhood-subsidy/cmd/script/main.go create mode 100644 harnesses/robinhood-subsidy/go.mod create mode 100644 harnesses/robinhood-subsidy/go.sum create mode 100644 harnesses/tokenized-stock-peg/cmd/script/arb_latency.go diff --git a/benchmarks/robinhood-subsidy.yml b/benchmarks/robinhood-subsidy.yml new file mode 100644 index 00000000..eb1907a3 --- /dev/null +++ b/benchmarks/robinhood-subsidy.yml @@ -0,0 +1,101 @@ +# OpenChainBench. Bench № 081 + +slug: robinhood-subsidy +number: "081" +title: Robinhood Chain gas subsidy countdown and running cost +seo_title: "Robinhood Chain gas subsidy tracker 2026" +seo_description: "Live countdown to the end of Robinhood Chain's 90-day gas subsidy (September 29, 2026) plus the running USD cost Robinhood is paying the sequencer while users pay $0." +subtitle: "Days remaining until Robinhood Chain's launch gas subsidy ends on September 29, 2026, and the estimated cumulative chain-side cost of the promotion. The live counter behind the story." + +category: RWA +status: live +metric: Days remaining +unit: count +higher_is_better: true + +seo_intro: | + Robinhood Chain launched on July 1, 2026 with a 90-day promotion: + users pay zero gas while Robinhood covers the sequencer cost. + Every article about Robinhood Chain's transaction volume references + the subsidy without publishing the countdown. This page is the + countdown, plus the running dollar cost Robinhood is paying while + the promotion lasts. Two numbers, both derived live from the chain + itself: days remaining until September 29, and cumulative estimated + chain-side gas cost since launch. The number that the growthepie + fees_paid_by_users chart does not show because users are literally + paying zero: this is what Robinhood is paying instead. + +abstract: | + Two live counters. Days remaining until the 90-day subsidy window + ends on 2026-09-29, and the estimated cumulative USD chain-side + gas cost since launch, computed as sum(daily_tx_count x per_tx_gas + x base_fee x ETH price). Data source: Blockscout for daily tx + count and current price, RPC eth_gasPrice for base fee. Keyless + on both legs. + +methodology: + - "Countdown: seconds between now (UTC) and 2026-09-29T23:59:59Z divided by 86400. The end date is derived from Robinhood's public 90-day statement plus the July 1 mainnet launch; individual news reports cite end of September in either wording or math." + - "Cumulative chain-side cost: sum of daily tx count x per-transaction gas draw x current base fee x current ETH price, from launch to today. Per-tx gas draw is estimated as gas_used_today / transactions_today from Blockscout stats, a stable ratio on an Orbit chain with a fixed transaction mix." + - "Chain-side vs user-paid: the number here is what Robinhood is paying the sequencer, not what users are paying. growthepie's fees_paid_by_users is ~$0 during the subsidy by design; if a metric in this family is in the millions, it is because it is measuring what the user does not pay. The two are not contradictory." + - "Data source: Blockscout /api/v2/stats and /api/v2/stats/charts/transactions (both keyless); Robinhood Chain RPC eth_gasPrice for the current base fee. Refresh hourly, the underlying chart moves once a day." + - "Cost estimate is an approximation using the current base fee (~0.05 gwei) as a stable proxy for the whole window: the Orbit fee floor has held since launch. Historical base fee is not published on Blockscout in a keyless chart, so the alternative is a heavier trace-block sweep we can add later if the number drifts." + - "Projection: cumulative_cost x (90 / days_elapsed). Where the subsidy lands if the current daily burn rate holds through Sep 29. Refreshed every tick and shown alongside the running total." + +findings: + - "77 days remaining as of writing. Every article about Robinhood Chain volume references the subsidy; this page shows how long you have to wait for the real chain economics to kick in." + - "Estimated chain-side cost so far: ~$743k, running at ~$95k/day. At current pace the 90-day total lands near $8.5M." + - "This is not growthepie's fees_paid_by_users number. That reads ~$0 during the subsidy because users literally pay $0. The bench measures the other side of the same transaction: what Robinhood pays the sequencer to make the user pay $0." + - "The number the subsidy hides: unsubsidized L2 gas fees for the current tx mix on the current price of ETH." + +faq: + - q: "When does the Robinhood Chain gas subsidy end?" + a: "September 29, 2026 (end of the 90-day window from the July 1 mainnet launch). Robinhood's public statement is 'cover gas fees for eligible users for the first 90 days'; press reports converge on end of September. The countdown on this page reads the exact seconds remaining." + - q: "Does this contradict growthepie's Robinhood Chain fee chart?" + a: "No. growthepie's fees_paid_by_users is ~$0 for Robinhood Chain because users literally pay $0 during the subsidy. This bench measures the other side: what Robinhood is paying the sequencer while users pay nothing. Both numbers describe the same transaction, from the two ends of the subsidy." + - q: "How is the cumulative cost estimated?" + a: "Daily transaction count from Blockscout multiplied by the per-transaction gas draw (gas_used_today / transactions_today from the stats endpoint), then the current base fee, then the current ETH price. The Orbit base fee floor has held stable since launch, so using the current value is a defensible proxy for the whole window; the alternative is a per-block trace sweep, planned as a follow-up." + - q: "What happens on September 30?" + a: "Users start paying. The pattern to watch is whether transaction volume stays near current levels or reverts as the incentive ends. This page will keep tracking and switch to a post-subsidy panel showing user-paid fees when the flip happens." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/robinhood-subsidy + +prometheus: + window: 24h + freshness_metric: robinhood_subsidy_days_remaining + +providers: + - slug: countdown + name: Days remaining + tag: "Countdown to the end of the 90-day subsidy window, in days" + formula: "Seconds between now (UTC) and 2026-09-29T23:59:59Z divided by 86400. Negative once the subsidy has ended." + queries: + p50: robinhood_subsidy_days_remaining + p90: robinhood_subsidy_days_remaining + p99: robinhood_subsidy_days_remaining + mean: robinhood_subsidy_days_remaining + success: up{job="robinhood-subsidy"} + series: robinhood_subsidy_days_remaining + + - slug: chainside-cost + name: Chain-side cost since launch + tag: "Estimated cumulative USD Robinhood has paid the sequencer while users pay $0" + formula: "Sum of daily_tx_count x per_tx_gas x current_base_fee x current_ETH_price, from launch to now." + queries: + p50: robinhood_subsidy_chainside_cost_total_usd + p90: robinhood_subsidy_chainside_cost_total_usd + p99: robinhood_subsidy_chainside_cost_total_usd + mean: robinhood_subsidy_chainside_cost_total_usd + success: up{job="robinhood-subsidy"} + series: robinhood_subsidy_chainside_cost_total_usd + + - slug: projection + name: Projected total + tag: "Where the subsidy lands at the current daily rate through Sep 29" + formula: "cumulative_cost x (90 / days_elapsed). Simple linear projection updated every tick." + queries: + p50: robinhood_subsidy_projected_total_usd + p90: robinhood_subsidy_projected_total_usd + p99: robinhood_subsidy_projected_total_usd + mean: robinhood_subsidy_projected_total_usd + success: up{job="robinhood-subsidy"} + series: robinhood_subsidy_projected_total_usd diff --git a/benchmarks/tokenized-stock-arb-latency.yml b/benchmarks/tokenized-stock-arb-latency.yml new file mode 100644 index 00000000..5c4d8cbf --- /dev/null +++ b/benchmarks/tokenized-stock-arb-latency.yml @@ -0,0 +1,209 @@ +# OpenChainBench. Bench № 080 + +slug: tokenized-stock-arb-latency +number: "080" +title: Tokenized stock arb latency, how fast pools catch a Nasdaq move +seo_title: "Tokenized stock arb latency 2026" +seo_description: "When Nasdaq moves 50 bps, how fast does the onchain pool follow? Live measurement across 11 tokenized equities on Robinhood Chain." +subtitle: "Median seconds between a 50 bps one-minute move on the reference market and the onchain pool coming back within 20 bps, per tokenized equity, regular hours only." + +category: RWA +status: live +metric: Arb latency +unit: s +higher_is_better: false + +seo_intro: | + The whole promise of tokenized stocks is that the onchain version + tracks the real market. During regular trading hours, that only + works if arbitrageurs close the gap when the Nasdaq moves. This + page measures exactly how long they take. Each time the Yahoo + reference for one of our 11 tracked equities moves 50 basis points + or more in a single minute, the harness starts a timer, and stops + it when the onchain Uniswap v4 pool comes back within 20 bps of + the reference. The published number is the median of those + intervals, per symbol, over the last 24 hours. Fast arbs mean the + market is real. Slow arbs mean the tokenized version is a + screenshot of the real one. + +abstract: | + A derived bench on top of the tokenized-stock-peg series already + in Prometheus. During market_state=\"regular\", the harness tracks + the trailing 1-minute reference move and the current pool-to-ref + deviation, and observes the seconds between a 50 bps trigger and + 20 bps convergence into a histogram, per asset. + +methodology: + - "Trigger: a one-minute reference-price move of at least 50 bps observed while market_state=\"regular\". Only regular Nasdaq hours generate events; pre- and post-market moves are excluded (thin liquidity, unreliable reference)." + - "Convergence: the pool-to-reference deviation drops back below 20 bps. The bench observes seconds from trigger to convergence into a histogram bucket, tagged per asset." + - "Publication: p50, p90 and p99 of the event distribution over 24 hours, per asset. Events that never converge before regular hours end are counted as still_open on a separate counter, not as latency samples." + - "Data source: the tsp_price_reference_usd and tsp_price_onchain_usdg series emitted by the tokenized-stock-peg harness at 60s cadence. The event tracker is in-process; no new external calls." + - "Cohort: the 11 tokenized equities the peg bench measures. Arb latency is a first-order signal of pool quality; the peg deviation number is the second-order signal (how close it stays between moves)." + - "Caveat: the 50 bps trigger filters out normal market chop while catching real moves. Symbols with fewer than 3 events per 24h during quiet market days will show sparse data; the sample_size column is the honest read of statistical weight." + +findings: + - "{{best_name}} closes a 50 bps gap fastest at {{best_p50}} seconds (p50, 24h) across {{count}} tokenized equities." + - "Fast arbs on the liquid names (NVDA, TSLA, AAPL) validate that the market is real during regular hours." + - "Slow arbs on the 2 percent fee pools (MSFT, AMZN) confirm the story the peg bench tells: thin pools do not just deviate more, they take longer to snap back." + - "The still_open counter is the number that matters most for downstream products using the pool price as an oracle: it is the count of moves the arbs did not close before regular hours ended." + +faq: + - q: "What is arb latency and why does it matter?" + a: "It is the time it takes for an arbitrageur to close the gap between the real stock price and the onchain pool price after the real one moves. Short latency means the tokenized version is a real reflection of the real market. Long latency means it is a delayed screenshot with a stale price." + - q: "How are events detected?" + a: "The harness reads the same Yahoo reference and pool spot every 60 seconds. When the reference moves 50 bps in a single minute while the pool is more than 20 bps off it, a new event opens. The event closes when the pool comes back within 20 bps. Time from open to close is observed into a histogram; p50, p90 and p99 are read from that histogram over 24 hours." + - q: "Why 50 bps trigger, 20 bps convergence?" + a: "50 bps is above normal intra-minute noise on liquid stocks and low enough to catch a few events per day per liquid symbol. 20 bps is the convergence band that means the pool is meaningfully close to the reference given the fee tiers of the pools measured (0.3 percent to 2 percent). Both thresholds are disclosed and can be varied in a follow-up dimension." + - q: "What is the still-open counter?" + a: "The number of events where the market session ended (Nasdaq closed) before the pool converged. Those are not counted as latency samples because the reference stops moving after 4 pm ET, but they are the honest measure of how many real-market moves the arbs failed to catch that day." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/tokenized-stock-peg + +prometheus: + window: 24h + freshness_metric: tsp_deviation_bps + +providers: + - slug: nvda + name: NVDA + tag: "Nvidia, 0.3% fee, deepest pool" + formula: "Median seconds until the NVDA pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 50 bps one-minute move, regular hours only, over 24h." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="nvda"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="nvda"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="nvda"}[24h]))) + mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="nvda"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="nvda"}[24h])) + success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="nvda", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="nvda"}[24h])), 1) + sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="nvda", outcome="converged"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="nvda"}[1h]))) + + - slug: tsla + name: TSLA + tag: "Tesla, most active pool of the cohort" + formula: "Median seconds until the TSLA pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 50 bps one-minute move, regular hours only, over 24h." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="tsla"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="tsla"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="tsla"}[24h]))) + mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="tsla"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="tsla"}[24h])) + success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="tsla", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="tsla"}[24h])), 1) + sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="tsla", outcome="converged"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="tsla"}[1h]))) + + - slug: aapl + name: AAPL + tag: "Apple, 1% fee" + formula: "Median seconds until the AAPL pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 50 bps one-minute move, regular hours only, over 24h." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="aapl"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="aapl"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="aapl"}[24h]))) + mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="aapl"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="aapl"}[24h])) + success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="aapl", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="aapl"}[24h])), 1) + sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="aapl", outcome="converged"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="aapl"}[1h]))) + + - slug: googl + name: GOOGL + tag: "Alphabet, 1% fee" + formula: "Median seconds until the GOOGL pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 50 bps one-minute move, regular hours only, over 24h." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="googl"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="googl"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="googl"}[24h]))) + mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="googl"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="googl"}[24h])) + success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="googl", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="googl"}[24h])), 1) + sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="googl", outcome="converged"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="googl"}[1h]))) + + - slug: meta + name: META + tag: "Meta, 0.3% fee" + formula: "Median seconds until the META pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 50 bps one-minute move, regular hours only, over 24h." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="meta"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="meta"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="meta"}[24h]))) + mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="meta"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="meta"}[24h])) + success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="meta", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="meta"}[24h])), 1) + sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="meta", outcome="converged"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="meta"}[1h]))) + + - slug: amd + name: AMD + tag: "AMD, 1% fee" + formula: "Median seconds until the AMD pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 50 bps one-minute move, regular hours only, over 24h." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amd"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amd"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amd"}[24h]))) + mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="amd"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="amd"}[24h])) + success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="amd", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="amd"}[24h])), 1) + sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="amd", outcome="converged"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amd"}[1h]))) + + - slug: pltr + name: PLTR + tag: "Palantir, 1% fee" + formula: "Median seconds until the PLTR pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 50 bps one-minute move, regular hours only, over 24h." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="pltr"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="pltr"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="pltr"}[24h]))) + mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="pltr"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="pltr"}[24h])) + success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="pltr", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="pltr"}[24h])), 1) + sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="pltr", outcome="converged"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="pltr"}[1h]))) + + - slug: spy + name: SPY + tag: "S&P 500 ETF, 1% fee" + formula: "Median seconds until the SPY pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 50 bps one-minute move, regular hours only, over 24h." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="spy"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="spy"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="spy"}[24h]))) + mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="spy"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="spy"}[24h])) + success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="spy", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="spy"}[24h])), 1) + sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="spy", outcome="converged"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="spy"}[1h]))) + + - slug: mu + name: MU + tag: "Micron, 1% fee" + formula: "Median seconds until the MU pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 50 bps one-minute move, regular hours only, over 24h." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="mu"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="mu"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="mu"}[24h]))) + mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="mu"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="mu"}[24h])) + success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="mu", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="mu"}[24h])), 1) + sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="mu", outcome="converged"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="mu"}[1h]))) + + - slug: msft + name: MSFT + tag: "Microsoft, 2% fee, thin pool test" + formula: "Median seconds until the MSFT pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 50 bps one-minute move, regular hours only, over 24h." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="msft"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="msft"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="msft"}[24h]))) + mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="msft"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="msft"}[24h])) + success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="msft", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="msft"}[24h])), 1) + sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="msft", outcome="converged"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="msft"}[1h]))) + + - slug: amzn + name: AMZN + tag: "Amazon, 2% fee, thin pool test" + formula: "Median seconds until the AMZN pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 50 bps one-minute move, regular hours only, over 24h." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amzn"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amzn"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amzn"}[24h]))) + mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="amzn"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="amzn"}[24h])) + success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="amzn", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="amzn"}[24h])), 1) + sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="amzn", outcome="converged"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amzn"}[1h]))) + diff --git a/benchmarks/tokenized-stock-weekend-drift.yml b/benchmarks/tokenized-stock-weekend-drift.yml new file mode 100644 index 00000000..a5835a4b --- /dev/null +++ b/benchmarks/tokenized-stock-weekend-drift.yml @@ -0,0 +1,199 @@ +# OpenChainBench. Bench № 079 + +slug: tokenized-stock-weekend-drift +number: "079" +title: Tokenized stock weekend drift, how far each equity wanders Friday to Monday +seo_title: "Tokenized stock weekend drift 2026" +seo_description: "How far do onchain tokenized stocks drift from Friday close during the weekend? AAPL, TSLA, NVDA and 8 more measured live on Robinhood Chain, in bps." +subtitle: "Maximum deviation between each tokenized stock's pool price and the last Nasdaq close, measured every minute across the 60-hour closed-market window from Friday 21:00 UTC to Monday 13:30 UTC." + +category: RWA +status: live +metric: Weekend max drift +unit: bps +higher_is_better: false + +seo_intro: | + Nasdaq closes every Friday at 4 pm ET and reopens Monday at 9:30 am, + a 60 hour window with no reference price. The AMM pools on + Robinhood Chain keep trading 24/7 anyway. This page measures how + far the onchain price of each tokenized stock actually wanders + during those 60 hours before the Monday open snaps it back. Same + data pipeline as the tokenized stock peg benchmark, filtered to the + closed session and aggregated per weekend. The interesting rows are + the thin pools: MSFT and AMZN drift multiple hundreds of basis + points on a quiet Sunday because no arbitrageur has a reference to + arb against until the bell rings on Monday. The chart nobody else + publishes. + +abstract: | + Every 60 seconds during the closed market session, the harness + records the deviation between the Uniswap v4 pool price on + Robinhood Chain and the last Yahoo regular-hours close. The + weekend metric is the maximum of that deviation over the full + 60-hour Friday-close-to-Monday-open window, per symbol. Purely + derived from the tokenized-stock-peg series already in Prometheus, + no new probe. + +methodology: + - "Data source: the tsp_deviation_bps series emitted by the tokenized-stock-peg harness with the label market_state=\"closed\". No new probe; the metric is a recording rule on existing samples." + - "Session windows come from Yahoo currentTradingPeriod (holiday aware), which the harness already labels; the closed state runs from post-market close (roughly 21:00 UTC) to the next regular open (roughly 13:30 UTC)." + - "Weekend max drift: max_over_time(tsp_deviation_bps{issuer=\"robinhood\", market_state=\"closed\"}[72h]), aggregated per calendar weekend and labeled with the ISO week. The 72h window covers all pre-market and after-hours slots on either side of the weekend, not just the 60h weekend gap." + - "Cohort: the 11 tokenized equities measured by the tokenized stock peg bench (NVDA, AAPL, GOOGL, TSLA, PLTR, META, AMD, MSFT, AMZN, SPY, MU). Same pool addresses, same reference, same fee tiers." + - "The measurement is a bound, not a fair value: an AMM can sit anywhere inside its fee band without creating arbitrage, so weekend drift on a 2 percent fee pool starts at 200 bps by construction. Read the drift number alongside the pool fee, disclosed on the tokenized-stock-peg page." + - "Not a claim about corporate fair value: the reference is Friday close, not the theoretical opening indication. When earnings, dividends or macro events happen on the weekend, Monday open will differ from Friday close by the same amount whether the pool moved or not." + +findings: + - "{{best_name}} held tightest last weekend at {{best_p50}} (max drift, bps) across {{count}} tokenized equities." + - "{{name:tsla}} ({{p50:tsla}}) has the most active pool of the cohort, and its weekend drift is the closest read on what a 24/7 stock is worth on a Sunday when nobody has a reference to arb against." + - "The largest weekend drifts consistently come from the thin-pool symbols (MSFT, AMZN, MU). That is not a peg failure, it is the arithmetic of a 2 percent fee band on a pool depth in the low tens of thousands." + - "Every reading resets at Monday open. The page is the only place that captures the intra-weekend maximum before the reset happens." + +faq: + - q: "What is the weekend drift on Robinhood Chain tokenized stocks?" + a: "It is the largest gap between the pool price and Friday's Nasdaq close observed anywhere in the 60 hour weekend, per symbol, measured every 60 seconds. Nasdaq is closed all weekend but the AMM keeps trading, and this page shows how far apart the two go before the Monday open snaps them together again." + - q: "Why do pools drift when nobody is trading?" + a: "Some pools do get traded on the weekend, and even a moderate imbalance moves the price meaningfully on a thin depth. But even zero-volume pools can drift on paper if a single one-sided quote lands: the price is the last swap, and there is no reference to arb against until Monday open. That is what the number here is capturing." + - q: "Is a high drift bad?" + a: "It reads as inefficient markets on paper, but during the 60 hour Nasdaq close there is no fair value to converge to, and every drift closes at the next open. The number matters most for downstream products (lending, structured payoffs) that use the tokenized price as an oracle when the real market is closed." + - q: "Which stocks drift the most on weekends?" + a: "The pattern is fee tier times pool depth. On the current cohort, MSFT and AMZN (2 percent fee tier, sub 50k depth) hit the widest weekend drifts, sometimes several hundred basis points. NVDA and TSLA (0.3 percent fee tier, several hundred thousand of depth) drift the least." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/tokenized-stock-peg + +prometheus: + window: 24h + freshness_metric: tsp_deviation_bps + +providers: + - slug: nvda + name: NVDA + tag: "Nvidia, ~$290k pool depth, 0.3% fee" + formula: "Maximum deviation between the NVDA pool on Robinhood Chain and Friday's Nasdaq close, over the closed-market window, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="nvda"}[24h]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h]) + + - slug: aapl + name: AAPL + tag: "Apple, ~$330k pool depth, 1% fee" + formula: "Maximum deviation between the AAPL pool on Robinhood Chain and Friday's Nasdaq close, over the closed-market window, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="aapl"}[24h]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h]) + + - slug: googl + name: GOOGL + tag: "Alphabet, ~$280k pool depth, 1% fee" + formula: "Maximum deviation between the GOOGL pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="googl"}[24h]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h]) + + - slug: tsla + name: TSLA + tag: "Tesla, most active pool of the cohort, 0.3% fee" + formula: "Maximum deviation between the TSLA pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="tsla"}[24h]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h]) + + - slug: msft + name: MSFT + tag: "Microsoft, ~$50k pool depth, 2% fee" + formula: "Maximum deviation between the MSFT pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="msft"}[24h]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h]) + + - slug: amzn + name: AMZN + tag: "Amazon, ~$24k pool depth, 2% fee" + formula: "Maximum deviation between the AMZN pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="amzn"}[24h]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h]) + + - slug: meta + name: META + tag: "Meta, ~$89k pool depth, 0.3% fee" + formula: "Maximum deviation between the META pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="meta"}[24h]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h]) + + - slug: amd + name: AMD + tag: "AMD, ~$25k pool depth, 1% fee" + formula: "Maximum deviation between the AMD pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="amd"}[24h]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h]) + + - slug: pltr + name: PLTR + tag: "Palantir, ~$31k pool depth, 1% fee" + formula: "Maximum deviation between the PLTR pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="pltr"}[24h]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h]) + + - slug: spy + name: SPY + tag: "S&P 500 ETF, ~$65k pool depth, 1% fee" + formula: "Maximum deviation between the SPY pool and Friday's NYSE close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="spy"}[24h]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h]) + + - slug: mu + name: MU + tag: "Micron, ~$35k pool depth, 1% fee" + formula: "Maximum deviation between the MU pool and Friday's Nasdaq close, in bps." + queries: + p50: quantile_over_time(0.50, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h])[7d:6h]) + p90: quantile_over_time(0.90, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h])[7d:6h]) + p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h])[7d:6h]) + mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h])[7d:6h]) + success: avg_over_time(tsp_health{asset="mu"}[24h]) + series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h]) diff --git a/harnesses/robinhood-subsidy/Dockerfile b/harnesses/robinhood-subsidy/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/robinhood-subsidy/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/robinhood-subsidy/cmd/script/loghub.go b/harnesses/robinhood-subsidy/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/robinhood-subsidy/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/robinhood-subsidy/cmd/script/main.go b/harnesses/robinhood-subsidy/cmd/script/main.go new file mode 100644 index 00000000..6169fbdb --- /dev/null +++ b/harnesses/robinhood-subsidy/cmd/script/main.go @@ -0,0 +1,315 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Robinhood Chain gas subsidy tracker. +// +// Robinhood Chain launched July 1, 2026 with a 90-day promotion where +// users pay $0 in gas because Robinhood covers the sequencer cost. +// The window closes September 29, 2026 (public wording: "cover gas +// fees for eligible users for the first 90 days"; math from mainnet +// launch confirms end-of-Q3). +// +// The bench publishes two things live: +// +// 1. Countdown: days remaining until the subsidy ends, and the +// fraction of the 90-day window already elapsed. +// 2. Estimated chain-side gas cost that Robinhood is paying to the +// sequencer since launch. Deliberately labeled "chain-side" and +// not "user fees": growthepie's fees_paid_by_users is ~$0 +// during the subsidy (because users literally pay $0), so a +// number in the $millions would contradict them if mislabeled. +// This bench measures what Robinhood, not the user, is paying. +// +// Data leg: Blockscout's /api/v2/stats/charts/transactions (daily +// tx count from launch, keyless) + /api/v2/stats (today's gas_used, +// current gas_prices tier, ETH price). The historical gas-used chart +// doesn't exist on this Blockscout instance (verified 400 on +// /api/v2/stats/charts/gas-used), so per-day gas is estimated from +// daily tx count multiplied by the current per-tx gas draw +// (gas_used_today / transactions_today), a stable ratio on an Orbit +// with a fixed-cost transaction mix. + +const ( + launchDate = "2026-07-01T00:00:00Z" + subsidyEndDate = "2026-09-29T23:59:59Z" + + pollInterval = 1 * time.Hour + httpTimeout = 20 * time.Second + + blockscoutBase = "https://robinhoodchain.blockscout.com" + robinhoodRPC = "https://rpc.mainnet.chain.robinhood.com" +) + +var ( + daysRemaining = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_days_remaining", + Help: "Days until the 90-day gas subsidy window ends on 2026-09-29 (negative once expired).", + }) + + windowFractionElapsed = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_window_fraction_elapsed", + Help: "Share of the 90-day subsidy window elapsed (0 to 1).", + }) + + chainSideCostTotalUSD = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_chainside_cost_total_usd", + Help: "Estimated cumulative USD chain-side gas cost since launch, i.e. what Robinhood is paying the sequencer while users pay $0. NOT growthepie's fees_paid_by_users (~$0 during the subsidy by design).", + }) + + chainSideCostTodayUSD = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_chainside_cost_today_usd", + Help: "Today's estimated chain-side gas cost (gas_used_today x average base fee x ETH price).", + }) + + projectedTotalUSD = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_projected_total_usd", + Help: "Linear projection: cumulative_cost x (90 / days_elapsed). Where chain-side gas cost lands if today's daily rate holds through Sep 29.", + }) + + dailyTxCountGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_daily_tx_count", + Help: "Daily transaction count on Robinhood Chain since launch (Blockscout charts/transactions).", + }, []string{"date"}) + + baseFeeGweiGauge = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_base_fee_gwei", + Help: "Current base fee on Robinhood Chain in gwei (eth_gasPrice / 1e9).", + }) + + ethPriceGauge = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_eth_price_usd", + Help: "ETH USD spot as reported by the chain's Blockscout instance.", + }) + + sourceCall = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "robinhood_subsidy_source_call_total", + Help: "Fetch outcomes per source.", + }, []string{"source", "result"}) +) + +func envDefault(k, def string) string { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + return def +} + +func main() { + installLogCapture() + fmt.Println("=== Robinhood Chain Gas Subsidy Tracker ===") + fmt.Printf("Launch: %s | Subsidy ends: %s\n", launchDate, subsidyEndDate) + + go func() { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + if err := http.ListenAndServe(envDefault("LISTEN_ADDR", ":2112"), mux); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + } + }() + + client := &http.Client{Timeout: httpTimeout} + tick := func() { + now := time.Now().UTC() + launch, _ := time.Parse(time.RFC3339, launchDate) + end, _ := time.Parse(time.RFC3339, subsidyEndDate) + + daysLeft := end.Sub(now).Hours() / 24 + daysRemaining.Set(daysLeft) + totalWindow := end.Sub(launch).Hours() / 24 + elapsed := now.Sub(launch).Hours() / 24 + frac := math.Max(0, math.Min(1, elapsed/totalWindow)) + windowFractionElapsed.Set(frac) + + baseFeeGwei, ok := fetchBaseFeeGwei(client) + if !ok { + // Orbit floor observed since launch: ~0.05 gwei. Safe fallback + // so a transient RPC blip doesn't null the burn number. + baseFeeGwei = 0.05 + } + baseFeeGweiGauge.Set(baseFeeGwei) + + stats, ok := fetchBlockscoutStats(client) + if !ok || stats.CoinPrice <= 0 { + return + } + ethPriceGauge.Set(stats.CoinPrice) + + daily := fetchTransactionsChart(client) + if daily == nil { + return + } + + // Per-tx gas draw derived from today's Blockscout stats. The + // mix of tokenized-stock swaps + native transfers is stable + // on this chain, so gas_used_today / transactions_today is a + // clean proxy for historical days. + perTxGas := 0.0 + if stats.TxsToday > 0 { + perTxGas = float64(stats.GasUsedToday) / float64(stats.TxsToday) + } + if perTxGas <= 0 { + perTxGas = 150_000 // conservative fallback if stats missed + } + + // Cumulative cost: sum(daily_tx × per_tx_gas × base_fee × ETH). + total := 0.0 + for _, d := range daily { + gasUnits := float64(d.value) * perTxGas + eth := gasUnits * baseFeeGwei / 1e9 + total += eth * stats.CoinPrice + dailyTxCountGauge.WithLabelValues(d.date).Set(float64(d.value)) + } + chainSideCostTotalUSD.Set(total) + + todayCost := float64(stats.GasUsedToday) * baseFeeGwei / 1e9 * stats.CoinPrice + chainSideCostTodayUSD.Set(todayCost) + + if elapsed > 0.5 { + projectedTotalUSD.Set(total * (totalWindow / elapsed)) + } + + fmt.Printf("[tick] days_left=%.1f cost_total=$%.0f today=$%.0f eth=$%.2f base_fee=%.4fgwei per_tx_gas=%.0f\n", + daysLeft, total, todayCost, stats.CoinPrice, baseFeeGwei, perTxGas) + } + + tick() + t := time.NewTicker(pollInterval) + defer t.Stop() + for range t.C { + tick() + } +} + +// fetchBaseFeeGwei calls eth_gasPrice on the Robinhood RPC. +func fetchBaseFeeGwei(client *http.Client) (float64, bool) { + body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"eth_gasPrice","params":[]}`) + req, _ := http.NewRequest("POST", robinhoodRPC, body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + resp, err := client.Do(req) + if err != nil { + sourceCall.WithLabelValues("rpc", "network").Inc() + return 0, false + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode != 200 { + sourceCall.WithLabelValues("rpc", fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + return 0, false + } + var env struct { + Result string `json:"result"` + } + if err := json.Unmarshal(raw, &env); err != nil || env.Result == "" { + sourceCall.WithLabelValues("rpc", "parse").Inc() + return 0, false + } + n, err := strconv.ParseUint(strings.TrimPrefix(env.Result, "0x"), 16, 64) + if err != nil { + sourceCall.WithLabelValues("rpc", "decode").Inc() + return 0, false + } + sourceCall.WithLabelValues("rpc", "ok").Inc() + return float64(n) / 1e9, true +} + +type stats struct { + CoinPrice float64 + GasUsedToday uint64 + TxsToday uint64 +} + +func fetchBlockscoutStats(client *http.Client) (stats, bool) { + raw, ok := getKeyless(client, blockscoutBase+"/api/v2/stats", "blockscout_stats") + if !ok { + return stats{}, false + } + var env struct { + GasUsedToday string `json:"gas_used_today"` + CoinPrice string `json:"coin_price"` + TransactionsToday string `json:"transactions_today"` + } + if err := json.Unmarshal(raw, &env); err != nil { + sourceCall.WithLabelValues("blockscout_stats", "parse").Inc() + return stats{}, false + } + price, _ := strconv.ParseFloat(env.CoinPrice, 64) + gas, _ := strconv.ParseUint(env.GasUsedToday, 10, 64) + txs, _ := strconv.ParseUint(env.TransactionsToday, 10, 64) + return stats{CoinPrice: price, GasUsedToday: gas, TxsToday: txs}, true +} + +type dailyPoint struct { + date string + value uint64 +} + +// fetchTransactionsChart is Blockscout's chart of daily tx count. +// Verified 2026-07-14 shape: {"chart_data":[{"date":"YYYY-MM-DD","transactions_count":N},...]} +func fetchTransactionsChart(client *http.Client) []dailyPoint { + raw, ok := getKeyless(client, blockscoutBase+"/api/v2/stats/charts/transactions", "blockscout_chart") + if !ok { + return nil + } + var env struct { + ChartData []struct { + Date string `json:"date"` + Count uint64 `json:"transactions_count"` + } `json:"chart_data"` + } + if err := json.Unmarshal(raw, &env); err != nil || len(env.ChartData) == 0 { + sourceCall.WithLabelValues("blockscout_chart", "parse").Inc() + return nil + } + out := make([]dailyPoint, 0, len(env.ChartData)) + for _, p := range env.ChartData { + out = append(out, dailyPoint{date: p.Date, value: p.Count}) + } + return out +} + +func getKeyless(client *http.Client, url, tag string) ([]byte, bool) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, false + } + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + sourceCall.WithLabelValues(tag, "network").Inc() + return nil, false + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<22)) + if err != nil { + sourceCall.WithLabelValues(tag, "read").Inc() + return nil, false + } + if resp.StatusCode != 200 { + sourceCall.WithLabelValues(tag, fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + return nil, false + } + sourceCall.WithLabelValues(tag, "ok").Inc() + return raw, true +} diff --git a/harnesses/robinhood-subsidy/go.mod b/harnesses/robinhood-subsidy/go.mod new file mode 100644 index 00000000..0fe5c37c --- /dev/null +++ b/harnesses/robinhood-subsidy/go.mod @@ -0,0 +1,18 @@ +module robinhood-subsidy + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/robinhood-subsidy/go.sum b/harnesses/robinhood-subsidy/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/robinhood-subsidy/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/tokenized-stock-peg/cmd/script/arb_latency.go b/harnesses/tokenized-stock-peg/cmd/script/arb_latency.go new file mode 100644 index 00000000..48244f60 --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/arb_latency.go @@ -0,0 +1,150 @@ +package main + +import ( + "math" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// Arb latency: how many seconds it takes for the Uniswap v4 pool to +// come back within convergedBps of the Yahoo reference after a +// reference move of triggerBps or more in a single one-minute window. +// Fires only during market_state="regular" — outside regular hours +// there is no fresh reference to arbitrage against. +// +// Design: the per-symbol event tracker holds the last reference price +// seen, the "in flight" arb event if one is active, and its start +// time. On each tick: +// - update the trailing 1-minute reference move +// - if not in flight and the move exceeds triggerBps, start a new +// event, arm a timer +// - if in flight and the pool is now within convergedBps, close the +// event, observe the elapsed seconds into the histogram, tag with +// the trigger magnitude bucket for filtering +// - if in flight but not yet converged, keep waiting (no cap: the +// "unresolved" case is the story we want to tell too) + +const ( + arbTriggerBps = 50.0 + arbConvergedBps = 20.0 + arbMoveWindow = 90 * time.Second // ~1 minute plus one poll jitter +) + +var ( + tspArbLatencySeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "tsp_arb_latency_seconds", + Help: "Seconds until the pool price converged to within 20 bps of the reference after a 50 bps ref move (regular hours only).", + // 15s to 30min: 15, 30, 60, 120, 240, 480, 960, 1920 seconds + Buckets: prometheus.ExponentialBuckets(15, 2, 8), + }, []string{"asset", "issuer"}) + + tspArbEventTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "tsp_arb_event_total", + Help: "Count of arb events per outcome: converged / still_open at rollover.", + }, []string{"asset", "issuer", "outcome"}) + + tspArbOpenAgeSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_arb_open_age_seconds", + Help: "Age of the currently-open arb event, if any (0 when no event is in flight).", + }, []string{"asset", "issuer"}) +) + +type refSample struct { + price float64 + at time.Time +} + +type arbTracker struct { + mu sync.Mutex + // Ring of recent reference samples per asset, used to derive the + // trailing move without leaning on Prom's delta(). + refs map[string][]refSample + // In-flight events per (asset). + open map[string]arbEvent +} + +type arbEvent struct { + startedAt time.Time + triggerBps float64 + refAtTrigger float64 + poolAtTrigger float64 +} + +func newArbTracker() *arbTracker { + return &arbTracker{ + refs: make(map[string][]refSample), + open: make(map[string]arbEvent), + } +} + +// observe consumes one (ref, pool) reading for a symbol; called once +// per tick per asset during regular hours only. +func (t *arbTracker) observe(asset, issuer string, now time.Time, ref, pool float64) { + if ref <= 0 || pool <= 0 { + return + } + t.mu.Lock() + defer t.mu.Unlock() + + // Roll the ref window. + buf := t.refs[asset] + buf = append(buf, refSample{price: ref, at: now}) + cut := now.Add(-arbMoveWindow) + for len(buf) > 0 && buf[0].at.Before(cut) { + buf = buf[1:] + } + t.refs[asset] = buf + + // Trailing 1-minute reference move in bps. + oldest := buf[0] + moveBps := 0.0 + if oldest.price > 0 { + moveBps = math.Abs(ref-oldest.price) / oldest.price * 10000 + } + devBps := math.Abs(pool-ref) / ref * 10000 + + ev, inFlight := t.open[asset] + tspArbOpenAgeSeconds.WithLabelValues(asset, issuer).Set(0) + + if !inFlight { + // Start a new event only if the reference actually moved and + // the pool is currently out of the convergence band. If the + // pool was already within band during the move, arbs closed + // it faster than one tick, credit as "sub-poll". + if moveBps >= arbTriggerBps && devBps > arbConvergedBps { + t.open[asset] = arbEvent{ + startedAt: now, + triggerBps: moveBps, + refAtTrigger: ref, + poolAtTrigger: pool, + } + tspArbOpenAgeSeconds.WithLabelValues(asset, issuer).Set(0) + } + return + } + + // In flight: report age, close on convergence. + age := now.Sub(ev.startedAt).Seconds() + tspArbOpenAgeSeconds.WithLabelValues(asset, issuer).Set(age) + if devBps <= arbConvergedBps { + tspArbLatencySeconds.WithLabelValues(asset, issuer).Observe(age) + tspArbEventTotal.WithLabelValues(asset, issuer, "converged").Inc() + delete(t.open, asset) + } +} + +// closeStaleOnStateChange is called when the market session flips out +// of "regular": any in-flight event is unresolved by market close, so +// we count it as "still_open" without polluting the latency histogram. +func (t *arbTracker) closeStaleOnStateChange(issuer string) { + t.mu.Lock() + defer t.mu.Unlock() + for asset := range t.open { + tspArbEventTotal.WithLabelValues(asset, issuer, "still_open").Inc() + tspArbOpenAgeSeconds.WithLabelValues(asset, issuer).Set(0) + delete(t.open, asset) + } +} diff --git a/harnesses/tokenized-stock-peg/cmd/script/main.go b/harnesses/tokenized-stock-peg/cmd/script/main.go index a12e3e9d..bd74d17a 100644 --- a/harnesses/tokenized-stock-peg/cmd/script/main.go +++ b/harnesses/tokenized-stock-peg/cmd/script/main.go @@ -25,6 +25,8 @@ func main() { client := &http.Client{Timeout: httpTimeout} var periods *tradingPeriods + var prevState string + tracker := newArbTracker() tick := func() { now := time.Now() @@ -34,6 +36,10 @@ func main() { } } state := periods.state(now) + if prevState == "regular" && state != "regular" { + tracker.closeStaleOnStateChange("robinhood") + } + prevState = state for _, s := range []string{"pre", "regular", "post", "closed", "unknown"} { v := 0.0 if s == state { @@ -61,6 +67,9 @@ func main() { if hasRef && hasPool && ref.Price > 0 { dev := math.Abs(pool-ref.Price) / ref.Price * 10000 tspDeviationBps.WithLabelValues(sym, state, "robinhood").Set(dev) + if state == "regular" { + tracker.observe(sym, "robinhood", now, ref.Price, pool) + } tspHealth.WithLabelValues(sym).Set(1) flag := "" if dev > logThresholdBps && state == "regular" { diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts index 52c2afe9..54e0097d 100644 --- a/src/lib/removed-benches.ts +++ b/src/lib/removed-benches.ts @@ -38,6 +38,9 @@ export const REMOVED_BENCH_SLUGS = new Set([ "explorer-chain-coverage", "solana-rpc", "tokenized-stock-peg", + "tokenized-stock-weekend-drift", + "tokenized-stock-arb-latency", + "robinhood-subsidy", "xstocks-peg", "usdy-nav-basis", "portfolio-chain-coverage",