From c5d1adc883794604ee256e517194ca695140fcbb Mon Sep 17 00:00:00 2001 From: Vedant <> Date: Thu, 3 Sep 2026 08:45:57 +0530 Subject: [PATCH] Add momentum command: BTC 5m/15m Chainlink-market signal trader Polymarket's btc-updown-5m-*/btc-updown-15m-* markets resolve against Chainlink's BTC/USD TWAP stream. `polymarket momentum run` watches Coinbase's free BTC-USD WebSocket feed as a fast proxy for that signal, deterministically tracks the currently open window, and buys the favored side via a market (FOK) order once the deviation clears a configurable threshold and the order book hasn't already priced it in. Defaults to live trading per user request, with --dry-run to test without a funded wallet. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 63 +++++- Cargo.toml | 2 + README.md | 36 +++ src/commands/mod.rs | 1 + src/commands/momentum.rs | 461 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 12 + 6 files changed, 573 insertions(+), 2 deletions(-) create mode 100644 src/commands/momentum.rs diff --git a/Cargo.lock b/Cargo.lock index b60d968..5ddda77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1557,6 +1557,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "der" version = "0.7.10" @@ -2255,7 +2261,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -3008,6 +3014,7 @@ dependencies = [ "chrono", "clap", "dirs", + "futures-util", "polymarket_client_sdk_v2", "predicates", "rust_decimal", @@ -3017,6 +3024,7 @@ dependencies = [ "serde_json", "tabled", "tokio", + "tokio-tungstenite", ] [[package]] @@ -3509,7 +3517,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -4547,6 +4555,22 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4672,6 +4696,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -4745,6 +4789,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4970,6 +5020,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + [[package]] name = "webpki-roots" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index 6e4755f..7c9ae48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,8 @@ anyhow = "1" chrono = "0.4" dirs = "6" rustyline = "15" +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } [dev-dependencies] assert_cmd = "2" diff --git a/README.md b/README.md index 7575d59..6d63867 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,42 @@ polymarket clob notifications polymarket clob delete-notifications "NOTIF1,NOTIF2" ``` +### BTC Momentum (Chainlink signal) + +Polymarket's `btc-updown-5m-*` / `btc-updown-15m-*` markets resolve against Chainlink's +BTC/USD TWAP data stream: "Up" if the price at window-close is greater than or equal to the +price at window-open. `momentum run` watches a fast exchange price feed (Coinbase BTC-USD, +free/no-auth — used as a stand-in for the real Chainlink Data Streams feed, which needs a +paid subscription we don't have) and buys the side it currently favors, before Polymarket's +own order book has fully repriced. + +```bash +# Print signals only, no wallet needed +polymarket momentum run --window 5m --stake 5 --dry-run + +# Live — places real market-buy orders (needs a configured, funded wallet) +polymarket momentum run --window 5m --stake 5 +polymarket momentum run --window 15m --stake 10 --min-deviation-bps 8 --max-windows 20 +``` + +**Flags**: `--window` (`5m` or `15m`, required), `--stake` (USDC per entry, required), +`--min-deviation-bps` (min move from the window-open reference before acting, default `5`), +`--settle-in-secs` (ignore signals for this many seconds after a window opens, default `3`), +`--min-seconds-remaining` (skip entries this close to window close, default `15`), +`--max-entry-price` (skip if the favored side already costs more than this, default `0.85`), +`--max-windows` (auto-stop after N windows, default: runs until interrupted), `--dry-run` +(log signals only, never places orders — also skips wallet auth entirely). + +At most one entry is placed per window, as a fire-or-kill (FOK) market order. On startup the +tool always sits out the current in-progress window — it needs to observe a full window's +transition live to capture a reference price anchored to the true window-open instant, so +trading only begins from the next clean boundary onward. + +> **This is a directional heuristic, not the resolution feed.** Coinbase spot and Chainlink's +> TWAP can and do diverge — there is no guarantee this captures an edge net of fees and +> latency, and you can lose the entire stake on any given window. Start with `--dry-run` and +> a small `--stake` before trusting it with real size. + ### On-Chain Data Public data — no wallet needed. diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9885365..735bfb8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -100,6 +100,7 @@ pub(crate) mod ctf; pub(crate) mod data; pub(crate) mod events; pub(crate) mod markets; +pub(crate) mod momentum; pub(crate) mod profiles; pub(crate) mod series; pub(crate) mod setup; diff --git a/src/commands/momentum.rs b/src/commands/momentum.rs new file mode 100644 index 0000000..85ca126 --- /dev/null +++ b/src/commands/momentum.rs @@ -0,0 +1,461 @@ +use std::str::FromStr; +use std::time::Duration; + +use anyhow::{Context, Result}; +use chrono::Utc; +use clap::{Args, Subcommand, ValueEnum}; +use futures_util::{SinkExt, StreamExt}; +use polymarket_client_sdk_v2::clob::types::request::PriceRequest; +use polymarket_client_sdk_v2::clob::types::{Amount, OrderType, Side}; +use polymarket_client_sdk_v2::gamma::{self, types::request::MarketBySlugRequest}; +use polymarket_client_sdk_v2::types::{Decimal, U256}; +use serde::Deserialize; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; + +use crate::auth; +use crate::output::OutputFormat; + +// Coinbase's public trade feed as a free, no-auth stand-in for Chainlink's BTC/USD Data +// Streams feed (which these markets actually resolve against but which requires a paid +// Chainlink Data Streams subscription we don't have). Coinbase is one of the exchanges +// Chainlink itself aggregates from, so it tracks the same underlying price, just without +// Chainlink's own aggregation/verification hop — sometimes ahead of it, sometimes behind. +const COINBASE_WS_URL: &str = "wss://ws-feed.exchange.coinbase.com"; +const COINBASE_PRODUCT: &str = "BTC-USD"; + +#[derive(Args)] +pub struct MomentumArgs { + #[command(subcommand)] + pub command: MomentumCommand, +} + +#[derive(Subcommand)] +pub enum MomentumCommand { + /// Watch Polymarket's BTC 5m/15m Chainlink-resolved Up/Down markets and buy the side + /// a fast exchange price feed favors, before Polymarket's own order book catches up. + /// + /// This is a directional-noise heuristic, not a resolution guarantee: the market + /// resolves against Chainlink's BTC/USD TWAP, this tool watches Coinbase spot as a + /// proxy, and the two can diverge. Trades live by default — use --dry-run to only + /// print signals. + Run { + /// Market window: 5m or 15m + #[arg(long)] + window: CliWindow, + + /// USDC stake per entry (one entry per window) + #[arg(long)] + stake: String, + + /// Minimum price move from the window-open reference, in basis points, before a + /// signal is considered (filters out bid/ask noise) + #[arg(long, default_value = "5")] + min_deviation_bps: u32, + + /// Seconds to wait after a window opens before acting on a signal + #[arg(long, default_value = "3")] + settle_in_secs: i64, + + /// Don't enter within this many seconds of window close + #[arg(long, default_value = "15")] + min_seconds_remaining: i64, + + /// Skip entry if the favored side already costs more than this (0-1) + #[arg(long, default_value = "0.85")] + max_entry_price: String, + + /// Stop after this many completed windows (default: run until interrupted) + #[arg(long)] + max_windows: Option, + + /// Only print signals — never places real orders + #[arg(long)] + dry_run: bool, + }, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum CliWindow { + #[value(name = "5m")] + FiveMin, + #[value(name = "15m")] + FifteenMin, +} + +impl CliWindow { + const fn seconds(self) -> i64 { + match self { + Self::FiveMin => 300, + Self::FifteenMin => 900, + } + } + + const fn slug_part(self) -> &'static str { + match self { + Self::FiveMin => "5m", + Self::FifteenMin => "15m", + } + } +} + +struct RunConfig { + window: CliWindow, + stake: Decimal, + min_deviation_bps: u32, + settle_in_secs: i64, + min_seconds_remaining: i64, + max_entry_price: Decimal, + max_windows: Option, + dry_run: bool, +} + +struct ActiveMarket { + slug: String, + outcomes: Vec, + token_ids: Vec, +} + +impl ActiveMarket { + fn token_for_outcome(&self, outcome: &str) -> Option { + self.outcomes + .iter() + .position(|o| o.eq_ignore_ascii_case(outcome)) + .and_then(|i| self.token_ids.get(i).copied()) + } +} + +#[derive(Deserialize)] +struct CoinbaseTicker { + #[serde(rename = "type")] + msg_type: String, + price: Option, +} + +pub async fn execute( + args: MomentumArgs, + output: OutputFormat, + private_key: Option<&str>, + signature_type: Option<&str>, +) -> Result<()> { + match args.command { + MomentumCommand::Run { + window, + stake, + min_deviation_bps, + settle_in_secs, + min_seconds_remaining, + max_entry_price, + max_windows, + dry_run, + } => { + let stake = Decimal::from_str(&stake) + .map_err(|_| anyhow::anyhow!("Invalid stake: {stake}"))?; + anyhow::ensure!(stake > Decimal::ZERO, "stake must be greater than 0"); + let max_entry_price = Decimal::from_str(&max_entry_price) + .map_err(|_| anyhow::anyhow!("Invalid max-entry-price: {max_entry_price}"))?; + + let cfg = RunConfig { + window, + stake, + min_deviation_bps, + settle_in_secs, + min_seconds_remaining, + max_entry_price, + max_windows, + dry_run, + }; + run(cfg, output, private_key, signature_type).await + } + } +} + +fn log(output: OutputFormat, message: &str) { + match output { + OutputFormat::Table => println!("[{}] {message}", Utc::now().format("%H:%M:%S")), + OutputFormat::Json => { + println!( + "{}", + serde_json::json!({"ts": Utc::now().to_rfc3339(), "message": message}) + ); + } + } +} + +async fn run( + cfg: RunConfig, + output: OutputFormat, + private_key: Option<&str>, + signature_type: Option<&str>, +) -> Result<()> { + let gamma = gamma::Client::default(); + let unauth = auth::unauthenticated_clob_client()?; + + // Only require a funded/authenticated wallet when we might actually place orders. + let trader = if cfg.dry_run { + None + } else { + let signer = auth::resolve_signer(private_key)?; + let client = auth::authenticate_with_signer(&signer, signature_type).await?; + Some((signer, client)) + }; + + log( + output, + &format!( + "Watching BTC {} Chainlink-resolved markets via Coinbase BTC-USD as the fast \ + proxy feed. {}", + cfg.window.slug_part(), + if cfg.dry_run { + "DRY RUN — no orders will be placed.".to_string() + } else { + format!( + "LIVE — {} USDC per entry, real orders will be placed.", + cfg.stake + ) + } + ), + ); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + tokio::spawn(run_price_feed(tx)); + + let window_secs = cfg.window.seconds(); + let mut current_slot: Option = None; + let mut reference_price: Option = None; + let mut current_market: Option = None; + let mut traded_this_window = false; + let mut warming_up = true; + let mut windows_completed: u32 = 0; + + while let Some(price) = rx.recv().await { + let now = Utc::now().timestamp(); + let slot = now - now.rem_euclid(window_secs); + + if current_slot != Some(slot) { + if current_slot.is_some() { + windows_completed += 1; + if let Some(max) = cfg.max_windows + && windows_completed >= max + { + log(output, "Reached --max-windows, stopping."); + break; + } + } else { + log( + output, + "Started mid-window — skipping it to get a clean reference price at the \ + next window boundary.", + ); + } + + current_slot = Some(slot); + traded_this_window = false; + + if warming_up { + warming_up = false; + reference_price = None; + current_market = None; + } else { + reference_price = Some(price); + current_market = match fetch_active_market(&gamma, cfg.window, slot).await { + Ok(market) => { + log( + output, + &format!("New window {} — reference price ${price}", market.slug), + ); + Some(market) + } + Err(e) => { + eprintln!("[momentum] failed to load market for this window: {e}"); + None + } + }; + } + } + + let (Some(reference), Some(market)) = (reference_price, current_market.as_ref()) else { + continue; + }; + if traded_this_window { + continue; + } + + let seconds_since_open = now - slot; + let seconds_remaining = slot + window_secs - now; + if seconds_since_open < cfg.settle_in_secs || seconds_remaining < cfg.min_seconds_remaining + { + continue; + } + + let deviation_bps = (price - reference) / reference * Decimal::from(10_000); + if deviation_bps.abs() < Decimal::from(cfg.min_deviation_bps) { + continue; + } + + let favored = if deviation_bps > Decimal::ZERO { + "Up" + } else { + "Down" + }; + let Some(token_id) = market.token_for_outcome(favored) else { + eprintln!("[momentum] market has no \"{favored}\" outcome token"); + continue; + }; + + let ask = match best_ask(&unauth, token_id).await { + Ok(a) => a, + Err(e) => { + eprintln!("[momentum] failed to fetch current price: {e}"); + continue; + } + }; + if ask > cfg.max_entry_price { + continue; + } + + log( + output, + &format!( + "Signal: {favored} favored ({deviation_bps:+.1} bps vs ref ${reference}), ask \ + {ask}, {seconds_remaining}s left in window" + ), + ); + + if let Some((signer, client)) = &trader { + match place_market_buy(client, signer, token_id, cfg.stake).await { + Ok((order_id, status)) => { + log( + output, + &format!( + "Bought {} USDC of {favored} — order {order_id} ({status})", + cfg.stake + ), + ); + } + Err(e) => eprintln!("[momentum] order failed: {e}"), + } + } else { + log( + output, + &format!("[dry-run] would buy {} USDC of {favored} @ ~{ask}", cfg.stake), + ); + } + traded_this_window = true; + } + + Ok(()) +} + +async fn fetch_active_market( + gamma: &gamma::Client, + window: CliWindow, + slot: i64, +) -> Result { + let slug = format!("btc-updown-{}-{slot}", window.slug_part()); + let request = MarketBySlugRequest::builder().slug(slug.clone()).build(); + let market = gamma + .market_by_slug(&request) + .await + .with_context(|| format!("Failed to fetch market {slug}"))?; + let outcomes = market + .outcomes + .with_context(|| format!("Market {slug} has no outcomes"))?; + let token_ids = market + .clob_token_ids + .with_context(|| format!("Market {slug} has no CLOB token ids"))?; + anyhow::ensure!( + outcomes.len() == token_ids.len(), + "Market {slug} outcome/token count mismatch" + ); + Ok(ActiveMarket { + slug, + outcomes, + token_ids, + }) +} + +async fn best_ask(client: &polymarket_client_sdk_v2::clob::Client, token_id: U256) -> Result { + let request = PriceRequest::builder().token_id(token_id).side(Side::Buy).build(); + let result = client.price(&request).await?; + Ok(result.price) +} + +async fn place_market_buy( + client: &polymarket_client_sdk_v2::clob::Client< + polymarket_client_sdk_v2::auth::state::Authenticated, + >, + signer: &(impl polymarket_client_sdk_v2::auth::Signer + Sync), + token_id: U256, + stake: Decimal, +) -> Result<(String, String)> { + let amount = Amount::usdc(stake)?; + let order = client + .market_order() + .token_id(token_id) + .side(Side::Buy) + .amount(amount) + .order_type(OrderType::FOK) + .build() + .await?; + let signed_order = client.sign(signer, order).await?; + let mut results = client.post_orders(vec![signed_order]).await?; + let result = results + .pop() + .ok_or_else(|| anyhow::anyhow!("Order submission returned no result"))?; + anyhow::ensure!( + result.success, + "{}", + result + .error_msg + .filter(|m| !m.is_empty()) + .unwrap_or_else(|| "order was not accepted".to_string()) + ); + Ok((result.order_id, result.status.to_string())) +} + +async fn run_price_feed(tx: mpsc::UnboundedSender) { + loop { + if let Err(e) = run_price_feed_once(&tx).await { + eprintln!("[momentum] price feed error: {e}; reconnecting in 3s"); + } + tokio::time::sleep(Duration::from_secs(3)).await; + } +} + +async fn run_price_feed_once(tx: &mpsc::UnboundedSender) -> Result<()> { + let (ws_stream, _) = tokio_tungstenite::connect_async(COINBASE_WS_URL) + .await + .context("Failed to connect to Coinbase price feed")?; + let (mut write, mut read) = ws_stream.split(); + + let subscribe = serde_json::json!({ + "type": "subscribe", + "product_ids": [COINBASE_PRODUCT], + "channels": ["ticker"], + }); + write + .send(Message::Text(subscribe.to_string())) + .await + .context("Failed to subscribe to Coinbase price feed")?; + + while let Some(msg) = read.next().await { + let msg = msg.context("Coinbase price feed connection error")?; + let Ok(text) = msg.to_text() else { continue }; + let Ok(ticker) = serde_json::from_str::(text) else { + continue; + }; + if ticker.msg_type != "ticker" { + continue; + } + let Some(price_str) = ticker.price else { continue }; + let Ok(price) = Decimal::from_str(&price_str) else { + continue; + }; + if tx.send(price).is_err() { + return Ok(()); + } + } + + anyhow::bail!("Coinbase price feed stream ended") +} diff --git a/src/main.rs b/src/main.rs index aaf5840..b8aa42f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,9 @@ enum Commands { Shell, /// Interact with markets Markets(commands::markets::MarketsArgs), + /// Watch and trade the BTC 5m/15m Chainlink-resolved Up/Down markets using a fast + /// exchange price feed as a leading signal + Momentum(commands::momentum::MomentumArgs), /// Interact with events Events(commands::events::EventsArgs), /// Interact with tags @@ -90,6 +93,15 @@ pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> { Commands::Setup => commands::setup::execute(), Commands::Shell => Box::pin(shell::run_shell()).await, Commands::Markets(args) => commands::markets::execute(&gamma, args, cli.output).await, + Commands::Momentum(args) => { + commands::momentum::execute( + args, + cli.output, + cli.private_key.as_deref(), + cli.signature_type.as_deref(), + ) + .await + } Commands::Events(args) => commands::events::execute(&gamma, args, cli.output).await, Commands::Tags(args) => commands::tags::execute(&gamma, args, cli.output).await, Commands::Series(args) => commands::series::execute(&gamma, args, cli.output).await,