tail-fin-daemon 0.6.2

Long-running browser-session daemon for tail-fin (tfd binary). Keeps Chrome tabs warm across invocations via a Unix-socket protocol; registers Site implementations through a runtime Arc<dyn Site> registry.
Documentation
use async_trait::async_trait;
use night_fury_core::BrowserSession;
use night_fury_daemon_core::protocol::Response;
use serde_json::Value;
use tail_fin_sa::{Period, SeekingAlphaApi, SeekingAlphaClient};

use crate::handlers::params::required_str;
use crate::handlers::response::to_response;
use crate::handlers::SiteHandler;

pub struct SaHandler {
    session: BrowserSession,
}

impl SaHandler {
    pub fn new(session: BrowserSession) -> Self {
        Self { session }
    }
}

#[async_trait]
impl SiteHandler for SaHandler {
    async fn handle(&self, id: &str, cmd: &str, params: &Value) -> Response {
        let client = SeekingAlphaClient::new(self.session.clone());
        match cmd {
            "sa.quote" => match required_str(params, "ticker") {
                Ok(ticker) => to_response(id, client.quote(&ticker).await),
                Err(e) => Response::err(id, e),
            },
            "sa.income-statement" => match (required_str(params, "ticker"), parse_period(params)) {
                (Ok(ticker), Ok(period)) => {
                    to_response(id, client.income_statement(&ticker, period).await)
                }
                (Err(e), _) | (_, Err(e)) => Response::err(id, e),
            },
            "sa.balance-sheet" => match (required_str(params, "ticker"), parse_period(params)) {
                (Ok(ticker), Ok(period)) => {
                    to_response(id, client.balance_sheet(&ticker, period).await)
                }
                (Err(e), _) | (_, Err(e)) => Response::err(id, e),
            },
            "sa.cash-flow" => match (required_str(params, "ticker"), parse_period(params)) {
                (Ok(ticker), Ok(period)) => {
                    to_response(id, client.cash_flow(&ticker, period).await)
                }
                (Err(e), _) | (_, Err(e)) => Response::err(id, e),
            },
            "sa.news" => handle_articles(id, &client, params, "news").await,
            "sa.analysis" => handle_articles(id, &client, params, "analysis").await,
            "sa.article" => match required_str(params, "id") {
                Ok(aid) => to_response(id, client.article(&aid).await),
                Err(e) => Response::err(id, e),
            },
            other => Response::err(id, format!("unknown sa cmd: {other}")),
        }
    }
}

async fn handle_articles(
    id: &str,
    client: &SeekingAlphaClient,
    params: &Value,
    kind: &str,
) -> Response {
    let ticker = match required_str(params, "ticker") {
        Ok(t) => t,
        Err(e) => return Response::err(id, e),
    };
    let count = params.get("count").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
    let since = params.get("since").and_then(|v| v.as_i64());
    let until = params.get("until").and_then(|v| v.as_i64());
    let result = if kind == "news" {
        client.news(&ticker, count, since, until).await
    } else {
        client.analysis(&ticker, count, since, until).await
    };
    to_response(id, result)
}

fn parse_period(params: &Value) -> Result<Period, String> {
    let s = params
        .get("period")
        .and_then(|v| v.as_str())
        .unwrap_or("annual");
    match s.to_lowercase().as_str() {
        "annual" | "a" => Ok(Period::Annual),
        "quarterly" | "q" => Ok(Period::Quarterly),
        other => Err(format!("invalid period '{other}' (annual|quarterly)")),
    }
}