use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::models::filings::{CongressionalTrade, FailToDeliver, InsiderTrade};
use crate::adapters::fmp::build_client;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct InsiderTradeDTO {
pub symbol: Option<String>,
#[serde(rename = "filingDate")]
pub filing_date: Option<String>,
#[serde(rename = "transactionDate")]
pub transaction_date: Option<String>,
#[serde(rename = "reportingCik")]
pub reporting_cik: Option<String>,
#[serde(rename = "reportingName")]
pub reporting_name: Option<String>,
#[serde(rename = "transactionType")]
pub transaction_type: Option<String>,
#[serde(rename = "securitiesTransacted")]
pub securities_transacted: Option<f64>,
pub price: Option<f64>,
#[serde(rename = "securitiesOwned")]
pub securities_owned: Option<f64>,
#[serde(rename = "typeOfOwner")]
pub type_of_owner: Option<String>,
pub link: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FailToDeliverDTO {
pub symbol: Option<String>,
pub date: Option<String>,
pub quantity: Option<f64>,
pub price: Option<f64>,
pub name: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CongressionalTradeDTO {
pub symbol: Option<String>,
#[serde(rename = "transactionDate")]
pub transaction_date: Option<String>,
#[serde(rename = "disclosureDate")]
pub disclosure_date: Option<String>,
#[serde(rename = "firstName")]
pub first_name: Option<String>,
#[serde(rename = "lastName")]
pub last_name: Option<String>,
pub office: Option<String>,
pub district: Option<String>,
#[serde(rename = "type")]
pub trade_type: Option<String>,
pub amount: Option<String>,
#[serde(rename = "assetDescription")]
pub asset_description: Option<String>,
pub link: Option<String>,
}
pub async fn insider_trading(symbol: &str, limit: u32) -> Result<Vec<InsiderTradeDTO>> {
let client = build_client()?;
let limit_str = limit.to_string();
client
.get(
"/stable/insider-trading/search",
&[("symbol", symbol), ("limit", &limit_str)],
)
.await
}
pub async fn fail_to_deliver(symbol: &str) -> Result<Vec<FailToDeliverDTO>> {
let client = build_client()?;
client
.get("/api/v4/fail_to_deliver", &[("symbol", symbol)])
.await
}
pub async fn congressional_trading(symbol: &str) -> Result<Vec<CongressionalTradeDTO>> {
let client = build_client()?;
client
.get("/stable/senate-trades", &[("symbol", symbol)])
.await
}
fn to_insider_trade(dto: InsiderTradeDTO) -> InsiderTrade {
InsiderTrade {
symbol: dto.symbol,
insider_name: dto.reporting_name,
insider_cik: dto.reporting_cik,
officer_title: dto.type_of_owner,
transaction_code: dto
.transaction_type
.as_deref()
.and_then(|t| t.split('-').next())
.map(str::to_string),
url: dto.link,
transaction_date: dto.transaction_date,
shares: dto.securities_transacted,
price_per_share: dto.price,
shares_owned_after: dto.securities_owned,
..Default::default()
}
}
pub async fn fetch_insider_trades_response(symbol: &str, limit: u32) -> Result<Vec<InsiderTrade>> {
Ok(insider_trading(symbol, limit)
.await?
.into_iter()
.map(to_insider_trade)
.collect())
}
fn to_congressional_trade(dto: CongressionalTradeDTO) -> CongressionalTrade {
CongressionalTrade {
symbol: dto.symbol,
first_name: dto.first_name,
last_name: dto.last_name,
office: dto.office,
district: dto.district,
trade_type: dto.trade_type,
amount: dto.amount,
asset_description: dto.asset_description,
transaction_date: dto.transaction_date,
disclosure_date: dto.disclosure_date,
link: dto.link,
}
}
pub async fn fetch_congressional_trades_response(symbol: &str) -> Result<Vec<CongressionalTrade>> {
Ok(congressional_trading(symbol)
.await?
.into_iter()
.map(to_congressional_trade)
.collect())
}
fn to_fail_to_deliver(dto: FailToDeliverDTO) -> FailToDeliver {
FailToDeliver {
symbol: dto.symbol,
date: dto.date,
quantity: dto.quantity,
price: dto.price,
name: dto.name,
description: dto.description,
}
}
pub async fn fetch_fails_to_deliver_response(symbol: &str) -> Result<Vec<FailToDeliver>> {
Ok(fail_to_deliver(symbol)
.await?
.into_iter()
.map(to_fail_to_deliver)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_insider_trade_fields() {
let dto: InsiderTradeDTO = serde_json::from_value(serde_json::json!({
"symbol": "AAPL",
"transactionDate": "2024-01-12",
"reportingCik": "0001234567",
"reportingName": "Cook Timothy D",
"transactionType": "S-Sale",
"securitiesTransacted": 50000.0,
"price": 185.50,
"securitiesOwned": 3200000.0,
"link": "https://sec.gov/example",
"typeOfOwner": "officer"
}))
.unwrap();
let out = to_insider_trade(dto);
assert_eq!(out.symbol.as_deref(), Some("AAPL"));
assert_eq!(out.insider_name.as_deref(), Some("Cook Timothy D"));
assert_eq!(out.transaction_code.as_deref(), Some("S"));
assert_eq!(out.shares, Some(50000.0));
assert_eq!(out.shares_owned_after, Some(3200000.0));
assert_eq!(out.officer_title.as_deref(), Some("officer"));
assert_eq!(out.form_type, None);
assert!(!out.is_director);
}
#[test]
fn maps_congressional_trade_fields() {
let dto: CongressionalTradeDTO = serde_json::from_value(serde_json::json!({
"symbol": "AAPL",
"transactionDate": "2024-01-10",
"disclosureDate": "2024-01-20",
"firstName": "John",
"lastName": "Doe",
"office": "Senate",
"type": "Purchase",
"amount": "$1,001 - $15,000"
}))
.unwrap();
let out = to_congressional_trade(dto);
assert_eq!(out.last_name.as_deref(), Some("Doe"));
assert_eq!(out.trade_type.as_deref(), Some("Purchase"));
assert_eq!(out.amount.as_deref(), Some("$1,001 - $15,000"));
}
#[test]
fn maps_fail_to_deliver_fields() {
let dto: FailToDeliverDTO = serde_json::from_value(serde_json::json!({
"symbol": "AAPL",
"date": "2024-01-15",
"quantity": 1200.0,
"price": 185.50
}))
.unwrap();
let out = to_fail_to_deliver(dto);
assert_eq!(out.symbol.as_deref(), Some("AAPL"));
assert_eq!(out.quantity, Some(1200.0));
}
#[tokio::test]
async fn test_insider_trading_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/stable/insider-trading/search")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("apikey".into(), "test-key".into()),
mockito::Matcher::UrlEncoded("symbol".into(), "AAPL".into()),
mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
]))
.with_status(200)
.with_body(
serde_json::json!([
{
"symbol": "AAPL",
"filingDate": "2024-01-15",
"transactionDate": "2024-01-12",
"reportingCik": "0001234567",
"reportingName": "Cook Timothy D",
"transactionType": "S-Sale",
"securitiesTransacted": 50000.0,
"price": 185.50,
"securitiesOwned": 3200000.0,
"typeOfOwner": "officer"
}
])
.to_string(),
)
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let resp: Vec<InsiderTradeDTO> = client
.get(
"/stable/insider-trading/search",
&[("symbol", "AAPL"), ("limit", "10")],
)
.await
.unwrap();
assert_eq!(resp.len(), 1);
assert_eq!(resp[0].reporting_name.as_deref(), Some("Cook Timothy D"));
assert!((resp[0].price.unwrap() - 185.50).abs() < 0.01);
}
#[tokio::test]
async fn test_congressional_trading_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/stable/senate-trades")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("apikey".into(), "test-key".into()),
mockito::Matcher::UrlEncoded("symbol".into(), "AAPL".into()),
]))
.with_status(200)
.with_body(
serde_json::json!([
{
"symbol": "AAPL",
"transactionDate": "2024-01-10",
"disclosureDate": "2024-01-20",
"firstName": "John",
"lastName": "Doe",
"office": "Senate",
"type": "Purchase",
"amount": "$1,001 - $15,000"
}
])
.to_string(),
)
.create_async()
.await;
let client = crate::adapters::fmp::build_test_client(&server.url()).unwrap();
let resp: Vec<CongressionalTradeDTO> = client
.get("/stable/senate-trades", &[("symbol", "AAPL")])
.await
.unwrap();
assert_eq!(resp.len(), 1);
assert_eq!(resp[0].last_name.as_deref(), Some("Doe"));
}
}