Skip to content

Commit d2eabce

Browse files
Michael YuanMichael Yuan
authored andcommitted
Wire up Polymarket CLOB trading
- Add src/polymarket.rs with full CLOB API implementation: * L1 auth: EIP-712 signature for API credential derivation * L2 auth: HMAC-SHA256 request signing * Order signing: EIP-712 for CTF Exchange orders * Market data: fetch token IDs and tick size * Order submission: post_order with full CLOB integration * Credential caching: ~/.fintool/polymarket_creds.json - Update src/commands/predict.rs: * Replace stub buy/sell with real Polymarket trading * Implement polymarket_buy() and polymarket_sell() * Load wallet from config, derive API creds * Build orders with proper token amounts/pricing * Support YES/NO side selection * Handle tick size rounding - Update src/main.rs: add polymarket module Kalshi trading remains stubbed for now. Zero clippy warnings. Compiles successfully.
1 parent a4671b4 commit d2eabce

3 files changed

Lines changed: 854 additions & 30 deletions

File tree

src/commands/predict.rs

Lines changed: 320 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
use anyhow::{bail, Result};
22
use colored::Colorize;
3+
use ethers::signers::Signer;
34
use serde::{Deserialize, Serialize};
45
use serde_json::json;
56

7+
use crate::{config, polymarket};
8+
69
const POLYMARKET_BASE: &str = "https://gamma-api.polymarket.com";
710
const KALSHI_BASE: &str = "https://api.elections.kalshi.com/trade-api/v2";
811

@@ -520,68 +523,355 @@ pub async fn buy(
520523
max_price: Option<&str>,
521524
json_output: bool,
522525
) -> Result<()> {
523-
let (platform, _) = market
526+
let (platform, slug) = market
524527
.split_once(':')
525528
.ok_or_else(|| anyhow::anyhow!("Format: polymarket:<slug> or kalshi:<TICKER>"))?;
526529

530+
match platform {
531+
"polymarket" => polymarket_buy(slug, side, amount, max_price, json_output).await,
532+
"kalshi" => {
533+
// Kalshi trading still stubbed
534+
if json_output {
535+
println!(
536+
"{}",
537+
json!({
538+
"action": "predict_buy", "market": market, "side": side,
539+
"amount": amount, "maxPrice": max_price,
540+
"status": "not_implemented",
541+
"note": "Kalshi trading requires additional configuration."
542+
})
543+
);
544+
} else {
545+
println!();
546+
println!(" 🔮 Prediction Buy (Preview)");
547+
println!(" Market: {}", market.cyan());
548+
println!(" Side: {}", side);
549+
println!(" Amount: {}", amount);
550+
if let Some(mp) = max_price {
551+
println!(" Max Price: {}¢", mp);
552+
}
553+
println!();
554+
print_trading_config_hint(platform);
555+
}
556+
Ok(())
557+
}
558+
_ => bail!("Unknown platform '{}'", platform),
559+
}
560+
}
561+
562+
pub async fn sell(
563+
market: &str,
564+
side: &str,
565+
amount: &str,
566+
min_price: Option<&str>,
567+
json_output: bool,
568+
) -> Result<()> {
569+
let (platform, slug) = market
570+
.split_once(':')
571+
.ok_or_else(|| anyhow::anyhow!("Format: polymarket:<slug> or kalshi:<TICKER>"))?;
572+
573+
match platform {
574+
"polymarket" => polymarket_sell(slug, side, amount, min_price, json_output).await,
575+
"kalshi" => {
576+
// Kalshi trading still stubbed
577+
if json_output {
578+
println!(
579+
"{}",
580+
json!({
581+
"action": "predict_sell", "market": market, "side": side,
582+
"amount": amount, "minPrice": min_price,
583+
"status": "not_implemented",
584+
"note": "Kalshi trading requires additional configuration."
585+
})
586+
);
587+
} else {
588+
println!();
589+
println!(" 🔮 Prediction Sell (Preview)");
590+
println!(" Market: {}", market.cyan());
591+
println!(" Side: {}", side);
592+
println!(" Amount: {}", amount);
593+
if let Some(mp) = min_price {
594+
println!(" Min Price: {}¢", mp);
595+
}
596+
println!();
597+
print_trading_config_hint(platform);
598+
}
599+
Ok(())
600+
}
601+
_ => bail!("Unknown platform '{}'", platform),
602+
}
603+
}
604+
605+
// --- Polymarket Trading Helpers ---
606+
607+
async fn polymarket_buy(
608+
slug: &str,
609+
side: &str,
610+
amount_str: &str,
611+
max_price_str: Option<&str>,
612+
json_output: bool,
613+
) -> Result<()> {
614+
// Load wallet
615+
let cfg = config::load_config_file()?;
616+
let private_key = cfg
617+
.wallet
618+
.private_key
619+
.as_ref()
620+
.ok_or_else(|| {
621+
anyhow::anyhow!(
622+
"No private_key configured. Set in ~/.fintool/config.toml under [wallet]"
623+
)
624+
})?
625+
.strip_prefix("0x")
626+
.unwrap_or(cfg.wallet.private_key.as_ref().unwrap())
627+
.to_string();
628+
629+
let wallet: ethers::signers::LocalWallet = private_key.parse()?;
630+
let address = format!("{:?}", wallet.address());
631+
632+
let client = reqwest::Client::new();
633+
634+
// Derive API credentials
635+
let (api_key, secret, passphrase) =
636+
polymarket::derive_api_credentials(&client, &private_key).await?;
637+
638+
// Fetch market info
639+
let (token_ids, neg_risk) = polymarket::get_market_info(&client, slug).await?;
640+
641+
// Determine which token to buy: YES = index 0, NO = index 1
642+
let token_idx = match side.to_lowercase().as_str() {
643+
"yes" => 0,
644+
"no" => 1,
645+
_ => bail!("Side must be 'yes' or 'no'"),
646+
};
647+
let token_id = token_ids
648+
.get(token_idx)
649+
.ok_or_else(|| anyhow::anyhow!("Token ID not found for side {}", side))?;
650+
651+
// Get tick size
652+
let tick_size = polymarket::get_tick_size(&client, token_id).await?;
653+
654+
// Parse amount (USDC to spend) and max_price (0-1 range, like 0.50)
655+
let amount_usdc: f64 = amount_str.parse()?;
656+
let max_price: f64 = max_price_str.map(|s| s.parse()).transpose()?.unwrap_or(1.0);
657+
658+
// Round price to tick
659+
let limit_price = polymarket::round_to_tick(max_price, tick_size);
660+
661+
// Calculate sizes in token decimals (6 decimals for both USDC and conditional tokens)
662+
// For a BUY order:
663+
// - makerAmount = USDC to spend (in 6 decimals)
664+
// - takerAmount = outcome tokens to receive (in 6 decimals)
665+
// - takerAmount = makerAmount / price
666+
let maker_amount_raw = (amount_usdc * 1_000_000.0).round() as u64;
667+
let taker_amount_raw = if limit_price > 0.0 {
668+
(amount_usdc / limit_price * 1_000_000.0).round() as u64
669+
} else {
670+
bail!("Price must be > 0");
671+
};
672+
673+
// Build order
674+
let salt = uuid::Uuid::new_v4().as_u128().to_string();
675+
let order = polymarket::OrderData {
676+
salt,
677+
maker: address.clone(),
678+
signer: address.clone(),
679+
taker: "0x0000000000000000000000000000000000000000".to_string(),
680+
token_id: token_id.clone(),
681+
maker_amount: maker_amount_raw.to_string(),
682+
taker_amount: taker_amount_raw.to_string(),
683+
expiration: "0".to_string(),
684+
nonce: "0".to_string(),
685+
fee_rate_bps: "0".to_string(),
686+
side: 0, // BUY
687+
signature_type: 0,
688+
};
689+
690+
// Sign order
691+
let signature = polymarket::sign_order(&private_key, &order, neg_risk).await?;
692+
693+
// Submit order
694+
let result = polymarket::post_order(
695+
&client,
696+
&api_key,
697+
&secret,
698+
&passphrase,
699+
&address,
700+
&order,
701+
&signature,
702+
)
703+
.await?;
704+
527705
if json_output {
528706
println!(
529707
"{}",
530708
json!({
531-
"action": "predict_buy", "market": market, "side": side,
532-
"amount": amount, "maxPrice": max_price,
533-
"status": "not_implemented",
534-
"note": format!("Trading on {} requires additional configuration.", platform)
709+
"action": "predict_buy",
710+
"market": format!("polymarket:{}", slug),
711+
"side": side,
712+
"amount": amount_str,
713+
"maxPrice": limit_price,
714+
"orderId": result.order_id,
715+
"success": result.success,
716+
"error": result.error
535717
})
536718
);
537719
} else {
538720
println!();
539-
println!(" 🔮 Prediction Buy (Preview)");
540-
println!(" Market: {}", market.cyan());
541-
println!(" Side: {}", side);
542-
println!(" Amount: {}", amount);
543-
if let Some(mp) = max_price {
544-
println!(" Max Price: {}¢", mp);
721+
println!(" 🔮 Polymarket Buy Order");
722+
println!(" Market: polymarket:{}", slug.cyan());
723+
println!(" Side: {}", side);
724+
println!(" Amount: ${}", amount_str);
725+
println!(" Limit: {:.4}", limit_price);
726+
println!(" Token ID: {}", token_id.dimmed());
727+
println!();
728+
if let Some(true) = result.success {
729+
println!(" {} Order submitted!", "✓".green().bold());
730+
if let Some(ref oid) = result.order_id {
731+
println!(" Order ID: {}", oid);
732+
}
733+
} else {
734+
println!(" {} Order failed", "✗".red().bold());
735+
if let Some(ref err) = result.error {
736+
println!(" Error: {}", err);
737+
}
545738
}
546739
println!();
547-
print_trading_config_hint(platform);
548740
}
741+
549742
Ok(())
550743
}
551744

552-
pub async fn sell(
553-
market: &str,
745+
async fn polymarket_sell(
746+
slug: &str,
554747
side: &str,
555-
amount: &str,
556-
min_price: Option<&str>,
748+
amount_str: &str,
749+
min_price_str: Option<&str>,
557750
json_output: bool,
558751
) -> Result<()> {
559-
let (platform, _) = market
560-
.split_once(':')
561-
.ok_or_else(|| anyhow::anyhow!("Format: polymarket:<slug> or kalshi:<TICKER>"))?;
752+
// Load wallet
753+
let cfg = config::load_config_file()?;
754+
let private_key = cfg
755+
.wallet
756+
.private_key
757+
.as_ref()
758+
.ok_or_else(|| {
759+
anyhow::anyhow!(
760+
"No private_key configured. Set in ~/.fintool/config.toml under [wallet]"
761+
)
762+
})?
763+
.strip_prefix("0x")
764+
.unwrap_or(cfg.wallet.private_key.as_ref().unwrap())
765+
.to_string();
766+
767+
let wallet: ethers::signers::LocalWallet = private_key.parse()?;
768+
let address = format!("{:?}", wallet.address());
769+
770+
let client = reqwest::Client::new();
771+
772+
// Derive API credentials
773+
let (api_key, secret, passphrase) =
774+
polymarket::derive_api_credentials(&client, &private_key).await?;
775+
776+
// Fetch market info
777+
let (token_ids, neg_risk) = polymarket::get_market_info(&client, slug).await?;
778+
779+
// Determine which token to sell
780+
let token_idx = match side.to_lowercase().as_str() {
781+
"yes" => 0,
782+
"no" => 1,
783+
_ => bail!("Side must be 'yes' or 'no'"),
784+
};
785+
let token_id = token_ids
786+
.get(token_idx)
787+
.ok_or_else(|| anyhow::anyhow!("Token ID not found for side {}", side))?;
788+
789+
// Get tick size
790+
let tick_size = polymarket::get_tick_size(&client, token_id).await?;
791+
792+
// Parse amount (outcome tokens to sell) and min_price
793+
let amount_tokens: f64 = amount_str.parse()?;
794+
let min_price: f64 = min_price_str.map(|s| s.parse()).transpose()?.unwrap_or(0.0);
795+
796+
// Round price to tick
797+
let limit_price = polymarket::round_to_tick(min_price, tick_size);
798+
799+
// For a SELL order:
800+
// - makerAmount = outcome tokens to sell (in 6 decimals)
801+
// - takerAmount = USDC to receive (in 6 decimals)
802+
// - takerAmount = makerAmount * price
803+
let maker_amount_raw = (amount_tokens * 1_000_000.0).round() as u64;
804+
let taker_amount_raw = (amount_tokens * limit_price * 1_000_000.0).round() as u64;
805+
806+
// Build order
807+
let salt = uuid::Uuid::new_v4().as_u128().to_string();
808+
let order = polymarket::OrderData {
809+
salt,
810+
maker: address.clone(),
811+
signer: address.clone(),
812+
taker: "0x0000000000000000000000000000000000000000".to_string(),
813+
token_id: token_id.clone(),
814+
maker_amount: maker_amount_raw.to_string(),
815+
taker_amount: taker_amount_raw.to_string(),
816+
expiration: "0".to_string(),
817+
nonce: "0".to_string(),
818+
fee_rate_bps: "0".to_string(),
819+
side: 1, // SELL
820+
signature_type: 0,
821+
};
822+
823+
// Sign order
824+
let signature = polymarket::sign_order(&private_key, &order, neg_risk).await?;
825+
826+
// Submit order
827+
let result = polymarket::post_order(
828+
&client,
829+
&api_key,
830+
&secret,
831+
&passphrase,
832+
&address,
833+
&order,
834+
&signature,
835+
)
836+
.await?;
562837

563838
if json_output {
564839
println!(
565840
"{}",
566841
json!({
567-
"action": "predict_sell", "market": market, "side": side,
568-
"amount": amount, "minPrice": min_price,
569-
"status": "not_implemented",
570-
"note": format!("Trading on {} requires additional configuration.", platform)
842+
"action": "predict_sell",
843+
"market": format!("polymarket:{}", slug),
844+
"side": side,
845+
"amount": amount_str,
846+
"minPrice": limit_price,
847+
"orderId": result.order_id,
848+
"success": result.success,
849+
"error": result.error
571850
})
572851
);
573852
} else {
574853
println!();
575-
println!(" 🔮 Prediction Sell (Preview)");
576-
println!(" Market: {}", market.cyan());
577-
println!(" Side: {}", side);
578-
println!(" Amount: {}", amount);
579-
if let Some(mp) = min_price {
580-
println!(" Min Price: {}¢", mp);
854+
println!(" 🔮 Polymarket Sell Order");
855+
println!(" Market: polymarket:{}", slug.cyan());
856+
println!(" Side: {}", side);
857+
println!(" Amount: {} tokens", amount_str);
858+
println!(" Limit: {:.4}", limit_price);
859+
println!(" Token ID: {}", token_id.dimmed());
860+
println!();
861+
if let Some(true) = result.success {
862+
println!(" {} Order submitted!", "✓".green().bold());
863+
if let Some(ref oid) = result.order_id {
864+
println!(" Order ID: {}", oid);
865+
}
866+
} else {
867+
println!(" {} Order failed", "✗".red().bold());
868+
if let Some(ref err) = result.error {
869+
println!(" Error: {}", err);
870+
}
581871
}
582872
println!();
583-
print_trading_config_hint(platform);
584873
}
874+
585875
Ok(())
586876
}
587877

0 commit comments

Comments
 (0)