|
1 | 1 | use anyhow::{bail, Result}; |
2 | 2 | use colored::Colorize; |
| 3 | +use ethers::signers::Signer; |
3 | 4 | use serde::{Deserialize, Serialize}; |
4 | 5 | use serde_json::json; |
5 | 6 |
|
| 7 | +use crate::{config, polymarket}; |
| 8 | + |
6 | 9 | const POLYMARKET_BASE: &str = "https://gamma-api.polymarket.com"; |
7 | 10 | const KALSHI_BASE: &str = "https://api.elections.kalshi.com/trade-api/v2"; |
8 | 11 |
|
@@ -520,68 +523,355 @@ pub async fn buy( |
520 | 523 | max_price: Option<&str>, |
521 | 524 | json_output: bool, |
522 | 525 | ) -> Result<()> { |
523 | | - let (platform, _) = market |
| 526 | + let (platform, slug) = market |
524 | 527 | .split_once(':') |
525 | 528 | .ok_or_else(|| anyhow::anyhow!("Format: polymarket:<slug> or kalshi:<TICKER>"))?; |
526 | 529 |
|
| 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 | + |
527 | 705 | if json_output { |
528 | 706 | println!( |
529 | 707 | "{}", |
530 | 708 | 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 |
535 | 717 | }) |
536 | 718 | ); |
537 | 719 | } else { |
538 | 720 | 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 | + } |
545 | 738 | } |
546 | 739 | println!(); |
547 | | - print_trading_config_hint(platform); |
548 | 740 | } |
| 741 | + |
549 | 742 | Ok(()) |
550 | 743 | } |
551 | 744 |
|
552 | | -pub async fn sell( |
553 | | - market: &str, |
| 745 | +async fn polymarket_sell( |
| 746 | + slug: &str, |
554 | 747 | side: &str, |
555 | | - amount: &str, |
556 | | - min_price: Option<&str>, |
| 748 | + amount_str: &str, |
| 749 | + min_price_str: Option<&str>, |
557 | 750 | json_output: bool, |
558 | 751 | ) -> 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?; |
562 | 837 |
|
563 | 838 | if json_output { |
564 | 839 | println!( |
565 | 840 | "{}", |
566 | 841 | 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 |
571 | 850 | }) |
572 | 851 | ); |
573 | 852 | } else { |
574 | 853 | 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 | + } |
581 | 871 | } |
582 | 872 | println!(); |
583 | | - print_trading_config_hint(platform); |
584 | 873 | } |
| 874 | + |
585 | 875 | Ok(()) |
586 | 876 | } |
587 | 877 |
|
|
0 commit comments