use clap::Subcommand;
use tail_fin_common::TailFinError;
use tail_fin_sa::{Period, SeekingAlphaApi, SeekingAlphaClient};
use crate::session::{default_cookies_path, print_json, print_list, resolve_cookies_path, Ctx};
#[derive(Subcommand)]
pub enum SeekingAlphaAction {
IncomeStatement {
ticker: String,
#[arg(long, default_value = "annual", value_parser = parse_period)]
period: Period,
},
BalanceSheet {
ticker: String,
#[arg(long, default_value = "annual", value_parser = parse_period)]
period: Period,
},
CashFlow {
ticker: String,
#[arg(long, default_value = "annual", value_parser = parse_period)]
period: Period,
},
Quote {
ticker: String,
},
News {
ticker: String,
#[arg(long, default_value_t = 20)]
count: usize,
#[arg(long)]
since: Option<String>,
#[arg(long)]
until: Option<String>,
},
Analysis {
ticker: String,
#[arg(long, default_value_t = 20)]
count: usize,
#[arg(long)]
since: Option<String>,
#[arg(long)]
until: Option<String>,
},
Article {
id: String,
},
ExportCookies {
#[arg(long, value_name = "PATH")]
output: Option<String>,
},
}
fn parse_period(s: &str) -> Result<Period, String> {
match s.to_lowercase().as_str() {
"annual" | "a" => Ok(Period::Annual),
"quarterly" | "q" => Ok(Period::Quarterly),
_ => Err(format!("unknown period '{}': use annual or quarterly", s)),
}
}
fn parse_date_to_timestamp(s: &str) -> Result<i64, String> {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 3 {
return Err(format!("invalid date '{}': expected YYYY-MM-DD", s));
}
let y: i64 = parts[0]
.parse()
.map_err(|_| format!("invalid year in '{}'", s))?;
let m: i64 = parts[1]
.parse()
.map_err(|_| format!("invalid month in '{}'", s))?;
let d: i64 = parts[2]
.parse()
.map_err(|_| format!("invalid day in '{}'", s))?;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return Err(format!("invalid date '{}'", s));
}
let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400;
let doy = (153 * m + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe - 719468;
Ok(days * 86400)
}
pub async fn run(action: SeekingAlphaAction, ctx: &Ctx) -> Result<(), TailFinError> {
if let SeekingAlphaAction::ExportCookies { output } = action {
let chrome_host = ctx.connect.as_deref().unwrap_or("127.0.0.1:9222");
let out_path = output
.map(std::path::PathBuf::from)
.unwrap_or_else(|| default_cookies_path("sa"));
let count = tail_fin_sa::http::export_cookies(chrome_host, &out_path).await?;
eprintln!("Saved {} cookies to {}", count, out_path.display());
return Ok(());
}
if let Some(ref cookies_flag) = ctx.cookies {
let path = resolve_cookies_path(cookies_flag, "sa");
let client = tail_fin_sa::http::SeekingAlphaHttpClient::from_cookie_file(&path)?;
return dispatch(action, &client).await;
}
let chrome_host = ctx.connect.as_deref().unwrap_or_else(|| {
eprintln!("Error: SeekingAlpha commands require --connect or --cookies.");
eprintln!(" Browser mode: tail-fin --connect 127.0.0.1:9222 sa income-statement ONDS");
eprintln!(" Cookie mode: tail-fin --cookies sa income-statement ONDS");
eprintln!(" Export first: tail-fin --connect 127.0.0.1:9222 sa export-cookies");
std::process::exit(1);
});
let session = crate::session::browser_session(chrome_host, ctx.headed).await?;
let client = SeekingAlphaClient::new(session);
dispatch(action, &client).await
}
async fn dispatch(
action: SeekingAlphaAction,
client: &impl SeekingAlphaApi,
) -> Result<(), TailFinError> {
match action {
SeekingAlphaAction::IncomeStatement { ticker, period } => {
let stmt = client.income_statement(&ticker, period).await?;
print_json(&stmt)?;
}
SeekingAlphaAction::BalanceSheet { ticker, period } => {
let stmt = client.balance_sheet(&ticker, period).await?;
print_json(&stmt)?;
}
SeekingAlphaAction::CashFlow { ticker, period } => {
let stmt = client.cash_flow(&ticker, period).await?;
print_json(&stmt)?;
}
SeekingAlphaAction::Quote { ticker } => {
let quote = client.quote(&ticker).await?;
print_json("e)?;
}
SeekingAlphaAction::News {
ticker,
count,
since,
until,
} => {
let since_ts = since
.map(|s| parse_date_to_timestamp(&s))
.transpose()
.map_err(|e| TailFinError::Parse(e))?;
let until_ts = until
.map(|s| parse_date_to_timestamp(&s))
.transpose()
.map_err(|e| TailFinError::Parse(e))?;
let articles = client.news(&ticker, count, since_ts, until_ts).await?;
print_list("articles", &articles, articles.len())?;
}
SeekingAlphaAction::Analysis {
ticker,
count,
since,
until,
} => {
let since_ts = since
.map(|s| parse_date_to_timestamp(&s))
.transpose()
.map_err(|e| TailFinError::Parse(e))?;
let until_ts = until
.map(|s| parse_date_to_timestamp(&s))
.transpose()
.map_err(|e| TailFinError::Parse(e))?;
let articles = client.analysis(&ticker, count, since_ts, until_ts).await?;
print_list("articles", &articles, articles.len())?;
}
SeekingAlphaAction::Article { id } => {
let content = client.article(&id).await?;
print_json(&content)?;
}
SeekingAlphaAction::ExportCookies { .. } => unreachable!(),
}
Ok(())
}
pub struct Adapter;
impl crate::adapter::CliAdapter for Adapter {
fn name(&self) -> &'static str {
"sa"
}
fn about(&self) -> &'static str {
"SeekingAlpha financial data"
}
fn command(&self) -> clap::Command {
<SeekingAlphaAction as clap::Subcommand>::augment_subcommands(
clap::Command::new("sa").about("SeekingAlpha financial data"),
)
}
fn dispatch<'a>(
&'a self,
matches: &'a clap::ArgMatches,
ctx: &'a crate::session::Ctx,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<(), tail_fin_common::TailFinError>> + Send + 'a,
>,
> {
Box::pin(async move {
let action = <SeekingAlphaAction as clap::FromArgMatches>::from_arg_matches(matches)
.map_err(|e| tail_fin_common::TailFinError::Api(e.to_string()))?;
run(action, ctx).await
})
}
}