forecite 0.1.3

Official Rust SDK for the Forecite API — scored news feed (REST), Verdict scoring, and realtime WebSocket streaming.
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;

// ── Feed ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize)]
pub struct FeedSymbol {
    pub symbol: Option<String>,
    pub exchange: Option<String>,
    pub short_symbol: Option<String>,
    pub logoid: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct FeedScoring {
    pub actionability: Option<bool>,
    #[serde(default)]
    pub actionability_score: Option<f64>,
    pub actionability_comment: Option<String>,
    pub sentiment_score: Option<f64>,
    #[serde(default)]
    pub sentiment_short: Option<f64>,
    #[serde(default)]
    pub sentiment_long: Option<f64>,
    #[serde(default)]
    pub sentiment_conviction: Option<f64>,
    pub sentiment_comment: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct FeedItem {
    pub id: String,
    pub unique_id: Option<String>,
    pub title: Option<String>,
    pub link: Option<String>,
    pub source: String,
    #[serde(default)]
    pub provider_id: Option<String>,
    pub published_at: String,
    pub scraped_at: Option<String>,
    pub scoring: Option<FeedScoring>,
    #[serde(default)]
    pub symbols: Vec<FeedSymbol>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct FeedList {
    pub data: Vec<FeedItem>,
    pub next_cursor: Option<String>,
}

/// Query params for `feeds_list`. Build with `FeedsQuery::default()` then set fields.
///
/// The tag-dimension fields (`corp_activity`, `market`, `market_country`,
/// `economic_category`) filter by tag: values within one dimension are OR'd, and
/// the dimensions are AND'd together. They serialize as a single comma-separated
/// query param (e.g. `?market_country=US,AE`). See `tags_list` for valid values.
#[derive(Debug, Default, Clone, Serialize)]
pub struct FeedsQuery {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub since: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub until: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub aggregator: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub actionability: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sentiment_min: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sentiment_max: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exchange: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub symbol: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub limit: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cursor: Option<String>,
    #[serde(
        skip_serializing_if = "Option::is_none",
        serialize_with = "serialize_csv_opt"
    )]
    pub corp_activity: Option<Vec<String>>,
    #[serde(
        skip_serializing_if = "Option::is_none",
        serialize_with = "serialize_csv_opt"
    )]
    pub market: Option<Vec<String>>,
    /// Any ISO 3166-1 alpha-2 country code (e.g. "US", "AE"), not only the codes
    /// `tags_list` returns.
    #[serde(
        skip_serializing_if = "Option::is_none",
        serialize_with = "serialize_csv_opt"
    )]
    pub market_country: Option<Vec<String>>,
    #[serde(
        skip_serializing_if = "Option::is_none",
        serialize_with = "serialize_csv_opt"
    )]
    pub economic_category: Option<Vec<String>>,
}

/// Serialize `Option<Vec<String>>` as a single comma-separated string, so a tag
/// dimension becomes one `?dim=a,b` query param under `serde_urlencoded`
/// (which cannot serialize sequences directly).
fn serialize_csv_opt<S>(value: &Option<Vec<String>>, serializer: S) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
{
    match value {
        Some(values) => serializer.serialize_str(&values.join(",")),
        None => serializer.serialize_none(),
    }
}

// ── Scoring (Verdict Engine) ─────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize)]
pub struct Artifact {
    #[serde(rename = "type")]
    pub kind: String,
    pub title: String,
    pub body: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tags: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub related_symbols: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub published_at: Option<String>,
}

#[derive(Debug, Default, Clone, Serialize)]
pub struct ScoreOptions {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub include_detailed_analysis: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub score_sentiment: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct Verdict {
    pub actionability: Value,
    #[serde(default)]
    pub sentiment: Option<Value>,
    pub meta: Value,
}

// ── Account / reference ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize)]
pub struct KeyInfo {
    pub key_id: String,
    pub name: String,
    pub tier: String,
    pub limits: Value,
}

#[derive(Debug, Clone, Deserialize)]
pub struct UsageInfo {
    pub tier: String,
    pub limits: Value,
    pub today: Value,
    pub history: Vec<Value>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct Provider {
    pub id: String,
    pub name: String,
    pub logo_id: Option<String>,
    #[serde(default)]
    pub url: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct SymbolInfo {
    pub short_symbol: String,
    pub logoid: Option<String>,
    #[serde(default)]
    pub symbol: Option<String>,
    #[serde(default)]
    pub exchange: Option<String>,
    #[serde(default)]
    pub feed_count: Option<u64>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct SourceInfo {
    pub name: String,
    pub feed_count: u64,
    pub latest_published_at: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct TagValue {
    pub value: String,
    pub label: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct TagDimensionValues {
    pub dimension: String,
    pub values: Vec<TagValue>,
}

// ── Webhooks ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize)]
pub struct Webhook {
    pub id: String,
    pub url: String,
    pub description: Option<String>,
    pub enabled: bool,
    pub last_triggered_at: Option<String>,
    pub last_status: Option<i64>,
    pub created_at: String,
    #[serde(default)]
    pub secret: Option<String>,
}

#[derive(Debug, Default, Clone, Serialize)]
pub struct UpdateWebhook {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub enabled: Option<bool>,
}

// ── Realtime ─────────────────────────────────────────────────────────────────
#[derive(Debug, Default, Clone, Serialize)]
pub struct StreamFilters {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub symbols: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exchanges: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub aggregators: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub providers: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub actionability: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sentiment_min: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sentiment_max: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tags: Option<std::collections::HashMap<String, Vec<String>>>,
}