#![warn(missing_debug_implementations)]
#![warn(rust_2018_idioms)]
mod client;
pub mod api;
pub mod error;
pub mod types;
use std::sync::Arc;
use crate::api::{
alpha_wallets::AlphaWallets, copytrade::CopyTrade, deployer_hunter::DeployerHunter, kol::Kol,
price_alerts::PriceAlerts, stream::Stream, tokens::Tokens, trades::Trades,
wallet::Wallet,
};
use crate::client::HttpCore;
use crate::error::{Result, RobinhoodChainError};
pub use crate::error::RobinhoodChainError as Error;
#[derive(Debug, Clone)]
pub struct RobinhoodChain {
pub kol: Kol,
pub trades: Trades,
pub tokens: Tokens,
pub deployer_hunter: DeployerHunter,
pub alpha_wallets: AlphaWallets,
pub wallet: Wallet,
pub copytrade: CopyTrade,
pub price_alerts: PriceAlerts,
pub stream: Stream,
}
impl RobinhoodChain {
pub fn new(api_key: impl Into<String>) -> Result<Self> {
let api_key = api_key.into();
if !api_key.starts_with("msk_") {
eprintln!(
"\n[robinhood-chain] Missing or invalid API key.\n\
→ Get a free key at https://madeonsol.com/pricing (RHC bundled into every tier)\n\
→ Then: robinhood_chain::RobinhoodChain::new(std::env::var(\"MADEONSOL_API_KEY\")?)?\n"
);
return Err(RobinhoodChainError::MissingApiKey);
}
let core = Arc::new(HttpCore::new(api_key));
Ok(Self {
kol: Kol { core: Arc::clone(&core) },
trades: Trades { core: Arc::clone(&core) },
tokens: Tokens { core: Arc::clone(&core) },
deployer_hunter: DeployerHunter { core: Arc::clone(&core) },
alpha_wallets: AlphaWallets { core: Arc::clone(&core) },
wallet: Wallet { core: Arc::clone(&core) },
copytrade: CopyTrade { core: Arc::clone(&core) },
price_alerts: PriceAlerts { core: Arc::clone(&core) },
stream: Stream { core },
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_missing_api_key() {
let err = RobinhoodChain::new("").unwrap_err();
assert!(matches!(err, RobinhoodChainError::MissingApiKey));
}
#[test]
fn rejects_wrong_prefix() {
let err = RobinhoodChain::new("sk_live_abc").unwrap_err();
assert!(matches!(err, RobinhoodChainError::MissingApiKey));
}
#[test]
fn accepts_valid_prefix() {
let client = RobinhoodChain::new("msk_test_abcdef").unwrap();
let _cloned = client.clone();
}
#[test]
fn patch_omits_untouched_fields() {
let body = serde_json::to_string(&types::CopyTradeUpdateParams::default()).unwrap();
assert_eq!(body, "{}");
}
#[test]
fn patch_distinguishes_omit_from_explicit_null() {
let clear = serde_json::to_value(&types::PriceAlertUpdateParams {
name: Some(None),
is_active: Some(false),
..Default::default()
})
.unwrap();
assert_eq!(clear["name"], serde_json::Value::Null);
assert!(clear.get("name").is_some(), "explicit null must be sent");
assert!(clear.get("webhook_url").is_none(), "omitted key must not be sent");
let set = serde_json::to_value(&types::PriceAlertUpdateParams {
name: Some(Some("renamed".into())),
..Default::default()
})
.unwrap();
assert_eq!(set["name"], "renamed");
}
#[test]
fn rule_engine_enums_match_wire_literals() {
use types::*;
assert_eq!(
serde_json::to_string(&DeliveryMode::Websocket).unwrap(),
"\"websocket\""
);
assert_eq!(
serde_json::to_string(&CopyTradeSizingMode::PercentSource).unwrap(),
"\"percent_source\""
);
assert_eq!(
serde_json::to_string(&FirstTouchStrategy::DayTrader).unwrap(),
"\"day_trader\""
);
assert_eq!(DeliveryMode::Both.as_str(), "both");
assert_eq!(PriceAlertStatus::Watching.as_str(), "watching");
assert_eq!(PriceAlertEventType::Recovery.as_str(), "recovery");
assert_eq!(CopyTradeOnlyAction::Sell.as_str(), "sell");
}
#[test]
fn fire_history_parses_without_count() {
let signals: types::CopyTradeSignalsResponse =
serde_json::from_str(r#"{"chain":"robinhood","signals":[]}"#).unwrap();
assert_eq!(signals.count, 0);
let events: types::PriceAlertEventsResponse =
serde_json::from_str(r#"{"chain":"robinhood","events":[]}"#).unwrap();
assert_eq!(events.count, 0);
}
#[test]
fn first_touch_filters_round_trip() {
let sub: types::RhcFirstTouchSubscription = serde_json::from_str(
r#"{"id":"11111111-1111-1111-1111-111111111111","name":null,"filters":{},
"delivery_mode":"websocket","webhook_url":null,"is_active":true,
"created_at":"2026-08-01T00:00:00Z","updated_at":"2026-08-01T00:00:00Z"}"#,
)
.unwrap();
assert!(sub.filters.kol.is_none());
assert_eq!(sub.delivery_mode, types::DeliveryMode::Websocket);
assert_eq!(
serde_json::to_string(&types::FirstTouchFilters::default()).unwrap(),
"{}"
);
}
}