diff --git a/README.md b/README.md index 06d48f7..eef4ae7 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,11 @@ longbridge warrant issuers # Warrant issuer list (HK mark ```bash longbridge financial-report AAPL.US [--kind IS|BS|CF] # Multi-period financial statements (income / balance sheet / cash flow) +longbridge financial-report AAPL.US --latest # Latest financial report summary +longbridge financial-statement AAPL.US [--kind IS|BS|CF|ALL] [--report af|saf|qf|cumul] # Detailed financial statement (v3 endpoint) longbridge institution-rating AAPL.US # Analyst rating distribution and consensus target price +longbridge institution-rating AAPL.US --history # Rating and target price change history +longbridge institution-rating AAPL.US --industry-rank [--page 1] [--limit 20] # Industry-wide institution rating ranking longbridge institution-rating detail AAPL.US # Monthly rating trend and analyst accuracy history longbridge dividend AAPL.US # Historical dividend records longbridge dividend detail AAPL.US # Dividend allocation plan details @@ -190,11 +194,46 @@ longbridge forecast-eps AAPL.US # Analyst E longbridge consensus AAPL.US # Revenue / profit / EPS multi-period comparison with beat/miss markers longbridge valuation AAPL.US [--indicator pe|pb|ps|dvd_yld] # Current valuation snapshot and peer comparison longbridge valuation AAPL.US --history [--indicator pe] [--range 5] # Historical valuation time series (1 / 3 / 5 / 10 years) +longbridge valuation-rank AAPL.US [--start 20240101] [--end 20241231] # Industry valuation percentile ranking (default: last 30 days) +longbridge analyst-estimates AAPL.US # Analyst consensus EPS estimates longbridge fund-holder AAPL.US [--count 20] # Funds and ETFs holding this stock longbridge shareholder AAPL.US [--range all|inc|dec] [--sort chg] # Institutional shareholders with QoQ change tracking longbridge corp-action 700.HK [--all] # Corporate actions (splits, dividends, rights, etc.) — default 30, --all for full history ``` +### Deposits & Withdrawals + +```bash +longbridge bank-cards # List linked bank cards +longbridge withdrawals [--page 1] [--limit 20] # Withdrawal history +longbridge deposits [--page 1] [--limit 20] [--states 0,1,2] [--currencies HKD,USD] # Deposit history +``` + +### Search + +```bash +longbridge search TSLA [--tab market|news|posts|hashtags|help|share-lists|users|institutions] # Search across multiple content types +longbridge search-hot # Hot search keywords +``` + +### IPO + +```bash +longbridge ipo subscriptions # IPO stocks currently in filing or subscription stage +longbridge ipo wait-listing # IPO stocks in grey-market (wait-listing) stage +longbridge ipo listed [--page 1] [--limit 20] # Recently listed IPO stocks +longbridge ipo calendar # IPO calendar (all upcoming and recent IPOs) +longbridge ipo detail [--market HK|US] # IPO profile, timeline, eligibility, and holdings for a symbol +longbridge ipo orders [--market HK] [--status 0] [--page 1] # IPO orders (active + history) for the current account +longbridge ipo orders detail # Full detail for a single IPO order +longbridge ipo profit-loss [--period all|1m|3m|6m|1y] [--page 1] # IPO P&L summary and item list +longbridge ipo us-subscriptions # US IPO stocks currently in subscription stage +longbridge ipo us-wait-listing # US IPO stocks in wait-listing stage +longbridge ipo us-listed [--page 1] [--limit 20] # Recently listed US IPO stocks +longbridge ipo submit TSLA.US --qty 200 --amount 1000 [--method 2] # Submit IPO subscription (prompts for confirmation) +longbridge ipo withdraw # Withdraw IPO subscription (prompts for confirmation) +``` + ### Market Data ```bash @@ -247,6 +286,8 @@ longbridge order cancel # Cancel a pending or longbridge order replace --qty 200 --price 255.00 # Modify quantity or price of a pending order longbridge assets [--currency USD] # Asset overview: net assets, cash, buy power, margins, and per-currency breakdown longbridge cash-flow [--start 2024-01-01] # Cash flow records (deposits, withdrawals, dividends, settlements) +longbridge portfolio # Portfolio overview: total assets, P/L, holdings, and cash breakdown +longbridge portfolio short-margin # Short-selling margin deposit details longbridge positions # Current stock (equity) positions across all sub-accounts longbridge fund-positions # Current fund (mutual fund) positions across all sub-accounts longbridge margin-ratio TSLA.US # Margin ratio requirements for a symbol diff --git a/scripts/mock-server.ts b/scripts/mock-server.ts deleted file mode 100644 index 2d55576..0000000 --- a/scripts/mock-server.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Longbridge API mock server (Bun) - * - * Accepts all HTTP and WebSocket requests, logs headers, and returns minimal - * valid responses so the SDK doesn't crash on startup. - * - * Usage: - * bun scripts/mock-server.ts - * - * Then point the CLI at it: - * LONGBRIDGE_HTTP_URL=http://localhost:8080 \ - * LONGBRIDGE_QUOTE_WS_URL=ws://localhost:8080/v2 \ - * LONGBRIDGE_TRADE_WS_URL=ws://localhost:8080/v2 \ - * longbridge - */ - -const PORT = parseInt(process.env.PORT ?? "8081"); - -// ANSI colours -const R = "\x1b[0m"; -const BOLD = "\x1b[1m"; -const DIM = "\x1b[2m"; -const GREEN = "\x1b[32m"; -const YELLOW = "\x1b[33m"; -const MAGENTA = "\x1b[35m"; -const CYAN = "\x1b[36m"; - -// Headers worth highlighting -const INTERESTING = new Set([ - "x-cli-cmd", - "x-channel-id", - "user-agent", - "accept-language", - "authorization", - "upgrade", - "connection", -]); - -function printHeaders(headers: Headers) { - for (const [key, value] of headers.entries()) { - const hi = INTERESTING.has(key.toLowerCase()); - const display = key.toLowerCase() === "authorization" - ? value.slice(0, 20) + "…" - : value; - console.log( - ` ${hi ? YELLOW + BOLD : DIM}${key}${R}: ${hi ? BOLD : DIM}${display}${R}` - ); - } -} - -const server = Bun.serve({ - port: PORT, - - fetch(req, server) { - const url = new URL(req.url); - const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket"; - - if (isUpgrade) { - console.log(`\n${MAGENTA}${BOLD}▶ WS UPGRADE${R} ${CYAN}${url.pathname}${R}`); - printHeaders(req.headers); - const ok = server.upgrade(req); - if (!ok) { - return new Response("WebSocket upgrade failed", { status: 400 }); - } - return; // upgraded - } - - console.log(`\n${GREEN}${BOLD}▶ HTTP ${req.method}${R} ${CYAN}${url.pathname}${R}`); - printHeaders(req.headers); - - return new Response(JSON.stringify({ code: 0, message: "", data: {} }), { - headers: { "content-type": "application/json" }, - }); - }, - - websocket: { - open(ws) { - console.log(` ${DIM}↑ WS open${R}`); - }, - message(_ws, _msg) { - // Silently drop protobuf frames — we only care about upgrade headers - }, - close(_ws, code) { - console.log(` ${DIM}↓ WS closed (${code})${R}`); - }, - }, -}); - -console.log(`${BOLD}Longbridge mock server${R} → http://localhost:${PORT}`); -console.log(); -console.log(`${DIM}Run CLI with:${R}`); -console.log( - ` ${YELLOW}LONGBRIDGE_HTTP_URL${R}=http://localhost:${PORT} \\` -); -console.log( - ` ${YELLOW}LONGBRIDGE_QUOTE_WS_URL${R}=ws://localhost:${PORT}/v2 \\` -); -console.log( - ` ${YELLOW}LONGBRIDGE_TRADE_WS_URL${R}=ws://localhost:${PORT}/v2 \\` -); -console.log(` longbridge `); -console.log(); diff --git a/src/cli/asset.rs b/src/cli/asset.rs index 3ca4837..eb51896 100644 --- a/src/cli/asset.rs +++ b/src/cli/asset.rs @@ -2,7 +2,9 @@ use anyhow::Result; use serde_json::Value; use super::api::http_get; -use super::output::{fmt_datetime, parse_datetime_end, parse_datetime_start, print_table}; +use super::output::{ + fmt_datetime, parse_datetime_end, parse_datetime_start, print_json_value, print_table, +}; use super::OutputFormat; fn print_json(value: &Value) { @@ -497,3 +499,104 @@ pub async fn cmd_profit_analysis_by_market( } Ok(()) } + +// ── short margin ───────────────────────────────────────────────────────────── + +pub async fn cmd_short_margin(format: &OutputFormat, verbose: bool) -> Result<()> { + let data = http_get("/v1/asset/cash/short-margin", &[], verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(list) = data["short_list"].as_array() { + if list.is_empty() { + println!("No short margin records."); + return Ok(()); + } + let headers = ["code", "amount", "currency"]; + let rows: Vec> = list + .iter() + .map(|item| { + vec![ + val_str(&item["code"]), + val_str(&item["amount"]), + val_str(&item["currency"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +// ── holding period ──────────────────────────────────────────────────────────── + +pub async fn cmd_holding_period( + symbols: Vec, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let resolved: Vec = if symbols.is_empty() { + let resp = crate::openapi::trade().stock_positions(None).await?; + resp.channels + .into_iter() + .flat_map(|ch| ch.positions.into_iter().map(|p| p.symbol)) + .collect() + } else { + symbols + }; + let cids: Vec = resolved + .iter() + .map(|s| serde_json::Value::String(crate::utils::counter::symbol_to_counter_id(s))) + .collect(); + let body = serde_json::json!({ "counter_ids": cids }); + let data = super::api::http_post("/v1/asset/positions/holding-period", body, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(map) = data["stocks_period"].as_object() { + if map.is_empty() { + println!("No holding period data."); + return Ok(()); + } + let mut rows: Vec> = map + .iter() + .map(|(cid, days)| { + let symbol = crate::utils::counter::counter_id_to_symbol(cid); + let d = match days { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + _ => "-".to_string(), + }; + vec![symbol, d] + }) + .collect(); + rows.sort_by(|a, b| a[0].cmp(&b[0])); + print_table(&["symbol", "days held"], rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +// ── trade info (pre-trade position + cash) ─────────────────────────────────── + +pub async fn cmd_trade_info(symbol: String, format: &OutputFormat, verbose: bool) -> Result<()> { + let cid = crate::utils::counter::symbol_to_counter_id(&symbol); + let data = http_get( + "/v1/asset/positions/trade-info", + &[("counter_id", cid.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_json_value(&data, format), + } + Ok(()) +} diff --git a/src/cli/atm.rs b/src/cli/atm.rs new file mode 100644 index 0000000..2d61191 --- /dev/null +++ b/src/cli/atm.rs @@ -0,0 +1,311 @@ +use anyhow::Result; +use serde_json::{Map, Value}; + +use super::api::http_get; +use super::output::print_table; +use super::OutputFormat; + +fn print_json(value: &Value) { + println!( + "{}", + serde_json::to_string_pretty(value).unwrap_or_default() + ); +} + +fn val_str(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "-".to_string(), + other => other.to_string(), + } +} + +fn fmt_ts(v: &Value) -> String { + let ts = match v { + Value::Number(n) => n.as_i64(), + Value::String(s) => s.parse::().ok(), + _ => None, + }; + ts.map_or_else(|| val_str(v), crate::utils::datetime::format_timestamp) +} + +fn transform_ts_field(item: &Value, ts_fields: &[&str]) -> Value { + let mut obj = Map::new(); + if let Some(map) = item.as_object() { + for (k, v) in map { + if ts_fields.contains(&k.as_str()) { + obj.insert(k.clone(), Value::String(fmt_ts(v))); + } else { + obj.insert(k.clone(), v.clone()); + } + } + } + Value::Object(obj) +} + +const DEPOSIT_SKIP: &[&str] = &[ + "vouchers", + "state_code", + "sub_state_code", + "bank_operation_type_id", + "disable_link", + "can_cancel", +]; + +fn transform_deposit_item(item: &Value) -> Value { + let mut obj = Map::new(); + if let Some(map) = item.as_object() { + for (k, v) in map { + if DEPOSIT_SKIP.contains(&k.as_str()) { + continue; + } + if k == "created_at" { + obj.insert(k.clone(), Value::String(fmt_ts(v))); + } else { + obj.insert(k.clone(), v.clone()); + } + } + } + Value::Object(obj) +} + +fn bank_card_status_label(v: &Value) -> &'static str { + match val_str(v).as_str() { + "0" => "unverified", + "1" => "reviewing", + "2" => "verified", + "3" => "active", + _ => "unknown", + } +} + +const BANK_CARD_KEEP: &[&str] = &[ + "id", + "bank", + "bank_en", + "account", + "account_type", + "name", + "name_en", + "swift_code", + "region", + "region_name", + "country", + "address", + "remark", + "nickname", + "bank_routing_number", +]; + +fn transform_bank_card(card: &Value) -> Value { + let mut obj = Map::new(); + if let Some(map) = card.as_object() { + for (k, v) in map { + if k == "status" { + obj.insert( + k.clone(), + Value::String(bank_card_status_label(v).to_string()), + ); + } else if BANK_CARD_KEEP.contains(&k.as_str()) { + obj.insert(k.clone(), v.clone()); + } + } + } + Value::Object(obj) +} + +// ── bank cards ──────────────────────────────────────────────────────────────── + +/// List withdrawal bank cards for the current user. +pub async fn cmd_withdrawal_cards(format: &OutputFormat, verbose: bool) -> Result<()> { + let data = http_get("/v1/account/bank-cards", &[], verbose).await?; + match format { + OutputFormat::Json => { + if let Some(cards) = data["list"].as_array() { + let transformed: Vec = cards.iter().map(transform_bank_card).collect(); + print_json(&Value::Array(transformed)); + } else { + print_json(&data); + } + } + OutputFormat::Pretty => { + if let Some(cards) = data["list"].as_array() { + if cards.is_empty() { + println!("No bank cards found."); + return Ok(()); + } + let headers = ["bank", "account", "currency", "swift", "region", "status"]; + let rows: Vec> = cards + .iter() + .map(|card| { + vec![ + val_str(&card["bank_en"]), + val_str(&card["account"]), + val_str(&card["account_type"]), + val_str(&card["swift_code"]), + val_str(&card["region_name"]), + bank_card_status_label(&card["status"]).to_string(), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +// ── withdrawals ─────────────────────────────────────────────────────────────── + +/// List withdrawal history for the current account. +pub async fn cmd_withdrawals( + page: u32, + limit: u32, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let account_channel = crate::auth::account_channel_or_default(); + let page_str = page.to_string(); + let size_str = limit.to_string(); + let data = http_get( + "/v1/account/withdrawals", + &[ + ("page", page_str.as_str()), + ("size", size_str.as_str()), + ("account_channel", account_channel.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => { + let mut result = serde_json::Map::new(); + if let Some(obj) = data.as_object() { + for (k, v) in obj { + if k == "list" { + if let Some(list) = v.as_array() { + let transformed: Vec = list + .iter() + .map(|item| transform_ts_field(item, &["created_at"])) + .collect(); + result.insert(k.clone(), Value::Array(transformed)); + } + } else { + result.insert(k.clone(), v.clone()); + } + } + } + print_json(&Value::Object(result)); + } + OutputFormat::Pretty => { + let total = val_str(&data["total"]); + if !total.is_empty() && total != "0" { + println!("Total: {total}\n"); + } + if let Some(list) = data["list"].as_array() { + if list.is_empty() { + println!("No withdrawal records."); + return Ok(()); + } + let headers = ["date", "amount", "currency", "status", "bank"]; + let rows: Vec> = list + .iter() + .map(|item| { + vec![ + fmt_ts(&item["created_at"]), + val_str(&item["amount"]), + val_str(&item["currency"]), + val_str(&item["status"]), + val_str(&item["bank_name"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +// ── deposits ────────────────────────────────────────────────────────────────── + +/// List deposit history for the current account. +pub async fn cmd_deposits( + page: u32, + limit: u32, + states: Option<&str>, + currencies: Option<&str>, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let account_channel = crate::auth::account_channel_or_default(); + let page_str = page.to_string(); + let size_str = limit.to_string(); + let mut params: Vec<(&str, &str)> = vec![ + ("page", page_str.as_str()), + ("size", size_str.as_str()), + ("account_channel", account_channel.as_str()), + ]; + if let Some(s) = states { + params.push(("states", s)); + } + if let Some(c) = currencies { + params.push(("currencies", c)); + } + let data = http_get("/v1/account/deposits", ¶ms, verbose).await?; + match format { + OutputFormat::Json => { + let mut result = serde_json::Map::new(); + if let Some(obj) = data.as_object() { + for (k, v) in obj { + if k == "items" { + if let Some(items) = v.as_array() { + let transformed: Vec = + items.iter().map(transform_deposit_item).collect(); + result.insert(k.clone(), Value::Array(transformed)); + } + } else { + result.insert(k.clone(), v.clone()); + } + } + } + print_json(&Value::Object(result)); + } + OutputFormat::Pretty => { + let total = val_str(&data["total"]); + if !total.is_empty() && total != "0" { + println!("Total: {total}\n"); + } + if let Some(items) = data["items"].as_array() { + if items.is_empty() { + println!("No deposit records."); + return Ok(()); + } + let headers = ["id", "date", "amount", "currency", "type", "state"]; + let rows: Vec> = items + .iter() + .map(|item| { + vec![ + val_str(&item["id"]), + fmt_ts(&item["created_at"]), + val_str(&item["amount"]), + val_str(&item["currency"]), + val_str(&item["bank_operation_type_name"]), + val_str(&item["state"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} diff --git a/src/cli/fundamental.rs b/src/cli/fundamental.rs index a0199db..530e1c5 100644 --- a/src/cli/fundamental.rs +++ b/src/cli/fundamental.rs @@ -1,7 +1,8 @@ use anyhow::Result; use longbridge::httpclient::Json; use reqwest::Method; -use serde_json::Value; +use serde_json::{Map, Value}; +use unicode_width::UnicodeWidthStr; use super::OutputFormat; @@ -486,7 +487,28 @@ pub async fn cmd_dividend( } let data = http_get("/v1/quote/dividends", ¶ms, verbose).await?; match format { - OutputFormat::Json => print_json(&data), + OutputFormat::Json => { + if let Some(list) = data["list"].as_array() { + let transformed: Vec = list + .iter() + .map(|item| { + let mut obj = serde_json::Map::new(); + obj.insert("symbol".to_string(), Value::String(symbol.clone())); + if let Some(map) = item.as_object() { + for (k, v) in map { + if !DIVIDENDS_SKIP.contains(&k.as_str()) { + obj.insert(k.clone(), v.clone()); + } + } + } + Value::Object(obj) + }) + .collect(); + print_json(&serde_json::json!({ "list": transformed })); + } else { + print_json(&data); + } + } OutputFormat::Pretty => print_dividends(&data), } Ok(()) @@ -1972,3 +1994,459 @@ fn print_invest_relation(data: &Value) { .collect(); super::output::print_table(&headers, rows, &OutputFormat::Pretty); } + +// ── financial statement (v3) ───────────────────────────────────────────────── + +fn pad_right(s: &str, width: usize) -> String { + let w = UnicodeWidthStr::width(s); + if w >= width { + s.to_string() + } else { + format!("{s}{}", " ".repeat(width - w)) + } +} + +fn pad_left(s: &str, width: usize) -> String { + let w = UnicodeWidthStr::width(s); + if w >= width { + s.to_string() + } else { + format!("{}{s}", " ".repeat(width - w)) + } +} + +fn trunc_display(s: &str, max_width: usize) -> String { + let mut w = 0usize; + let mut result = String::new(); + for ch in s.chars() { + let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1); + if w + cw > max_width - 1 { + result.push('…'); + return result; + } + result.push(ch); + w += cw; + } + result +} + +fn fmt_fin_number(s: &str) -> String { + let Ok(n) = s.parse::() else { + return s.to_string(); + }; + let abs = n.abs(); + let (div, suffix) = if abs >= 1_000_000_000_000.0 { + (1_000_000_000_000.0, "T") + } else if abs >= 1_000_000_000.0 { + (1_000_000_000.0, "B") + } else if abs >= 1_000_000.0 { + (1_000_000.0, "M") + } else if abs >= 1_000.0 { + (1_000.0, "K") + } else { + return format!("{n:.2}"); + }; + format!("{:.2}{suffix}", n / div) +} + +fn fmt_yoy(s: &str) -> String { + let Ok(v) = s.parse::() else { + return String::new(); + }; + let pct = v * 100.0; + if pct >= 0.0 { + format!("+{pct:.1}%") + } else { + format!("{pct:.1}%") + } +} + +fn period_label(ff_period: &str, ff_year: i64, report: &str) -> String { + if report == "af" { + format!("FY{ff_year}") + } else { + format!("Q{ff_period} {ff_year}") + } +} + +pub async fn cmd_financial_statement( + symbol: String, + kind: &str, + report: &str, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let cid = symbol_to_counter_id(&symbol); + let kind_upper = kind.to_uppercase(); + let report_lower = report.to_lowercase(); + let data = http_get( + "/v1/quote/financials/statements", + &[ + ("counter_id", cid.as_str()), + ("kind", kind_upper.as_str()), + ("report", report_lower.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + let currency = data["currency"].as_str().unwrap_or(""); + let periods = match data["list"].as_array() { + Some(v) if !v.is_empty() => v, + _ => { + println!("No data."); + return Ok(()); + } + }; + // Build period labels (newest first, up to 5) + let cols: Vec = periods + .iter() + .take(5) + .map(|p| { + let yr = p["ff_year"].as_i64().unwrap_or(0); + let per_val = p["ff_period"] + .as_i64() + .map(|n| n.to_string()) + .or_else(|| p["ff_period"].as_str().map(str::to_string)) + .unwrap_or_default(); + period_label(&per_val, yr, report) + }) + .collect(); + let n_cols = cols.len(); + // Width: name col=28, value cols=12 each, yoy=9 + let name_w = 28usize; + let val_w = 12usize; + let yoy_w = 9usize; + // Header: currency note + period labels + if !currency.is_empty() { + println!(" (in {currency})"); + } + // Header row + print!("{}", pad_right("", name_w)); + for col in &cols { + print!("{}", pad_left(col, val_w)); + } + println!("{}", pad_left("YoY", yoy_w)); + // Separator + println!("{}", "─".repeat(name_w + val_w * n_cols + yoy_w)); + // Use first period's field list as template + let template = periods[0]["fields"] + .as_array() + .map_or(&[][..], |v| v.as_slice()); + for field in template { + let level = field["level"].as_i64().unwrap_or(2); + let name = field["name"].as_str().unwrap_or(""); + let vtype = field["value_type"].as_str().unwrap_or(""); + let is_header = level == 1 && field["value"].as_str().unwrap_or("").is_empty(); + let indent = match level { + 1 => "", + 2 => " ", + 3 => " ", + _ => " ", + }; + let raw_name = format!("{indent}{name}"); + let display_name = trunc_display(&raw_name, name_w); + if is_header { + println!(); + print!("{}", pad_right(&display_name, name_w)); + for _ in 0..n_cols { + print!("{}", pad_left("", val_w)); + } + println!(); + } else { + let field_id = field["id"].as_str().unwrap_or(""); + print!("{}", pad_right(&display_name, name_w)); + let mut latest_yoy = String::new(); + for (i, period) in periods.iter().take(n_cols).enumerate() { + let pfield = period["fields"] + .as_array() + .and_then(|fs| fs.iter().find(|f| f["id"].as_str() == Some(field_id))); + let fval = pfield.and_then(|f| f["value"].as_str()).unwrap_or(""); + if i == 0 { + let yoy_raw = pfield.and_then(|f| f["yoy"].as_str()).unwrap_or(""); + if !yoy_raw.is_empty() { + latest_yoy = fmt_yoy(yoy_raw); + } + } + let formatted = if fval.is_empty() { + "-".to_string() + } else if vtype == "bignumber" || vtype.is_empty() { + fmt_fin_number(fval) + } else { + fval.to_string() + }; + print!("{}", pad_left(&formatted, val_w)); + } + println!("{}", pad_left(&latest_yoy, yoy_w)); + } + } + println!(); + } + } + Ok(()) +} + +// ── latest financial report summary ───────────────────────────────────────── + +pub async fn cmd_financial_report_latest( + symbol: String, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let cid = symbol_to_counter_id(&symbol); + let data = http_get( + "/v1/quote/financials/latest-report", + &[("counter_id", cid.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_kv(&data), + } + Ok(()) +} + +// ── valuation rank (industry daily percentile) ─────────────────────────────── + +pub async fn cmd_valuation_rank( + symbol: String, + start: Option<&str>, + end: Option<&str>, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let now = time::OffsetDateTime::now_utc(); + let end_date = end.map_or_else( + || format!("{:04}{:02}{:02}", now.year(), now.month() as u8, now.day()), + str::to_string, + ); + let start_date = start.map_or_else( + || { + let one_month_ago = now - time::Duration::days(30); + format!( + "{:04}{:02}{:02}", + one_month_ago.year(), + one_month_ago.month() as u8, + one_month_ago.day() + ) + }, + str::to_string, + ); + let counter_id = symbol_to_counter_id(&symbol); + let data = http_get( + "/v1/quote/valuation/rank", + &[ + ("counter_id", counter_id.as_str()), + ("start_date", start_date.as_str()), + ("end_date", end_date.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + let kline_type = data["kline_type"].as_str().unwrap_or(""); + if !kline_type.is_empty() { + println!(" ({kline_type})"); + } + let metrics = [("pe", "PE"), ("pb", "PB"), ("ps", "PS"), ("dvd", "Div")]; + // Find the metric with the most data points to use as date backbone + let max_len = metrics + .iter() + .map(|(k, _)| data[k].as_array().map_or(0, Vec::len)) + .max() + .unwrap_or(0); + if max_len == 0 { + println!("No data."); + return Ok(()); + } + // Build header + let date_w = 12usize; + let col_w = 10usize; + print!("{}", pad_right("Date", date_w)); + for (_, label) in &metrics { + print!("{}", pad_left(label, col_w)); + } + println!(); + println!("{}", "─".repeat(date_w + col_w * metrics.len())); + + // Use PE as date source (fall back to first non-empty) + let date_source = metrics + .iter() + .find(|(k, _)| data[*k].as_array().is_some_and(|a| !a.is_empty())) + .map_or("pe", |(k, _)| *k); + let timestamps: Vec = data[date_source] + .as_array() + .map(|a| { + a.iter() + .filter_map(|item| { + item["timestamp"] + .as_str() + .and_then(|s| s.parse::().ok()) + .or_else(|| item["timestamp"].as_i64()) + }) + .collect() + }) + .unwrap_or_default(); + + for (row_idx, &ts) in timestamps.iter().enumerate() { + let row_date = crate::utils::datetime::format_date(ts); + print!("{}", pad_right(&row_date, date_w)); + for (key, _) in &metrics { + let cell = data[*key] + .as_array() + .and_then(|a| a.get(row_idx)) + .map_or_else( + || "-".to_string(), + |item| { + let rank = item["rank"].as_i64().unwrap_or(0); + let total = item["total"].as_i64().unwrap_or(0); + if rank == 0 || total == 0 { + "-".to_string() + } else { + format!("{rank}/{total}") + } + }, + ); + print!("{}", pad_left(&cell, col_w)); + } + println!(); + } + println!(); + } + } + Ok(()) +} + +// ── analyst estimates (multi-dimension) ───────────────────────────────────── + +pub async fn cmd_analyst_estimates( + symbol: String, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let cid = symbol_to_counter_id(&symbol); + let data = http_get( + "/v1/quote/estimates", + &[("counter_id", cid.as_str()), ("item", "EPS")], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_kv(&data), + } + Ok(()) +} + +// ── institution rating history ─────────────────────────────────────────────── + +pub async fn cmd_institution_rating_history( + symbol: String, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let cid = symbol_to_counter_id(&symbol); + let data = http_get( + "/v1/quote/ratings/history", + &[("counter_id", cid.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + let target_empty = data + .get("target_history") + .and_then(|v| v.as_array()) + .is_none_or(Vec::is_empty); + let eval_empty = data + .get("evaluate_history") + .and_then(|v| v.as_array()) + .is_none_or(Vec::is_empty); + if !target_empty { + if let Some(target) = data.get("target_history") { + println!("Target price history:"); + print_kv(target); + } + } + if !eval_empty { + if let Some(eval) = data.get("evaluate_history") { + println!("\nRating history:"); + print_kv(eval); + } + } + if target_empty && eval_empty { + println!("No rating history found."); + } + } + } + Ok(()) +} + +// ── institution rating industry rank ──────────────────────────────────────── + +pub async fn cmd_institution_rating_industry_rank( + symbol: String, + page: u32, + limit: u32, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let cid = symbol_to_counter_id(&symbol); + let page_str = page.to_string(); + let size_str = limit.to_string(); + let data = http_get( + "/v1/quote/institution-ratings/industry-rank", + &[ + ("counter_id", cid.as_str()), + ("page", page_str.as_str()), + ("size", size_str.as_str()), + ], + verbose, + ) + .await?; + let mut result = Map::new(); + if let Some(obj) = data.as_object() { + for (k, v) in obj { + if k == "items" { + if let Some(arr) = v.as_array() { + let transformed: Vec = arr + .iter() + .map(|item| { + let mut o = Map::new(); + if let Some(m) = item.as_object() { + for (ik, iv) in m { + if ik == "counter_id" { + o.insert( + "symbol".to_string(), + Value::String(counter_id_to_symbol( + iv.as_str().unwrap_or(""), + )), + ); + } else { + o.insert(ik.clone(), iv.clone()); + } + } + } + Value::Object(o) + }) + .collect(); + result.insert(k.clone(), Value::Array(transformed)); + } + } else { + result.insert(k.clone(), v.clone()); + } + } + } + let data = Value::Object(result); + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_kv(&data), + } + Ok(()) +} diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs new file mode 100644 index 0000000..a1d4fbf --- /dev/null +++ b/src/cli/ipo.rs @@ -0,0 +1,1253 @@ +use anyhow::Result; +use serde_json::{Map, Value}; + +use super::api::http_get; +use super::output::{print_json_value, print_table}; +use super::OutputFormat; +use crate::utils::counter::{counter_id_to_symbol, symbol_to_counter_id}; + +fn print_json(value: &Value) { + println!( + "{}", + serde_json::to_string_pretty(value).unwrap_or_default() + ); +} + +fn val_str(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "-".to_string(), + other => other.to_string(), + } +} + +fn fmt_ts(v: &Value) -> String { + let ts = match v { + Value::Number(n) => n.as_i64(), + Value::String(s) => s.parse::().ok(), + _ => None, + }; + ts.map_or_else(|| val_str(v), crate::utils::datetime::format_timestamp) +} + +fn fmt_date_opt(v: &Value) -> String { + let ts = match v { + Value::Number(n) => n.as_i64(), + Value::String(s) => s.parse::().ok(), + _ => None, + }; + match ts { + Some(n) if n > 0 => crate::utils::datetime::format_date(n), + Some(_) => "-".to_string(), + None => { + let s = val_str(v); + if s.len() == 8 && s.chars().all(|c| c.is_ascii_digit()) { + format!("{}-{}-{}", &s[..4], &s[4..6], &s[6..]) + } else { + s + } + } + } +} + +fn state_stage_label(v: &Value) -> &'static str { + match val_str(v).as_str() { + "0" => "pending", + "1" => "sub-start", + "2" => "sub-end", + "3" => "allotment", + "4" => "grey-market", + "5" => "listed", + _ => "unknown", + } +} + +fn extract_tag(tags: &[Value], keyword: &str) -> String { + tags.iter() + .find_map(|t| t.as_str().filter(|s| s.contains(keyword))) + .map_or_else(|| "-".to_string(), str::to_string) +} + +// Transform subscription item: counter_id → symbol, sub_deadline → deadline (RFC 3339), +// state_stage → state (label). +fn transform_subscription(item: &Value) -> Value { + let mut obj = Map::new(); + if let Some(map) = item.as_object() { + for (k, v) in map { + match k.as_str() { + "counter_id" => { + obj.insert( + "symbol".to_string(), + Value::String(counter_id_to_symbol(&val_str(v))), + ); + } + "sub_deadline" => { + obj.insert("deadline".to_string(), Value::String(fmt_ts(v))); + } + "state_stage" => { + obj.insert( + "state".to_string(), + Value::String(state_stage_label(v).to_string()), + ); + } + _ => { + obj.insert(k.clone(), v.clone()); + } + } + } + } + Value::Object(obj) +} + +fn state_label(v: &Value) -> &'static str { + match val_str(v).as_str() { + "0" => "normal", + "1" => "delayed", + "2" => "cancelled", + _ => "unknown", + } +} + +fn sub_state_label(v: &Value) -> &'static str { + match val_str(v).as_str() { + "0" => "not-subscribed", + "1" => "not-won", + "2" => "won", + _ => "unknown", + } +} + +// Fields that are unix timestamps (numeric) and should be formatted as RFC 3339. +const TS_FIELDS: &[&str] = &[ + "ipo_date", + "sub_date", + "sub_end_date", + "result_date", + "mart_date", + "mart_begin", + "mart_end", +]; + +// Internal fields not useful to callers. +const SKIP_FIELDS: &[&str] = &[ + "code", + "order_id", + "sort", + "watched", + "remaining_second", + "remaining_day", +]; + +fn transform_ipo_list_item(item: &Value) -> Value { + let mut obj = Map::new(); + if let Some(map) = item.as_object() { + for (k, v) in map { + if SKIP_FIELDS.contains(&k.as_str()) { + continue; + } + if k == "counter_id" { + obj.insert( + "symbol".to_string(), + Value::String(counter_id_to_symbol(&val_str(v))), + ); + } else if k == "state_stage" { + obj.insert( + "state".to_string(), + Value::String(state_stage_label(v).to_string()), + ); + } else if k == "state" { + obj.insert(k.clone(), Value::String(state_label(v).to_string())); + } else if k == "sub_state" { + obj.insert(k.clone(), Value::String(sub_state_label(v).to_string())); + } else if k == "mart_status" { + let label = if val_str(v) == "1" { "open" } else { "closed" }; + obj.insert(k.clone(), Value::String(label.to_string())); + } else if TS_FIELDS.contains(&k.as_str()) && v.is_number() { + obj.insert(k.clone(), Value::String(fmt_ts(v))); + } else if k == "ipo_date" { + let s = val_str(v); + let formatted = if s.len() == 8 { + format!("{}-{}-{}", &s[..4], &s[4..6], &s[6..]) + } else { + s + }; + obj.insert(k.clone(), Value::String(formatted)); + } else { + obj.insert(k.clone(), v.clone()); + } + } + } + Value::Object(obj) +} + +// Transform an IPO order item: counter_id → symbol, format created_at timestamp. +fn transform_order_item(item: &Value) -> Value { + let mut obj = Map::new(); + if let Some(map) = item.as_object() { + for (k, v) in map { + match k.as_str() { + "counter_id" => { + obj.insert( + "symbol".to_string(), + Value::String(counter_id_to_symbol(&val_str(v))), + ); + } + "created_at" => { + obj.insert(k.clone(), Value::String(fmt_ts(v))); + } + _ => { + obj.insert(k.clone(), v.clone()); + } + } + } + } + Value::Object(obj) +} + +async fn member_id() -> Result { + crate::openapi::quote() + .member_id() + .await + .map_err(anyhow::Error::from) +} + +// ── read-only IPO list commands ──────────────────────────────────────────────── + +/// List IPO stocks currently in subscription or pre-filing stage (HK and US). +pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Result<()> { + let mid = member_id().await?; + let mid_str = mid.to_string(); + let hk_params = [("memebr_id", mid_str.as_str())]; + let (hk_data, us_data) = tokio::join!( + http_get("/v1/ipo/subscriptions", &hk_params, verbose), + http_get("/v1/ipo/us/subscriptions", &[], verbose), + ); + let hk_data = hk_data?; + let us_data = us_data?; + match format { + OutputFormat::Json => { + let mut result = serde_json::Map::new(); + if let Some(list) = hk_data["list"].as_array() { + let transformed: Vec = list.iter().map(transform_subscription).collect(); + result.insert("hk".to_string(), Value::Array(transformed)); + } + if let Some(list) = us_data["list"].as_array() { + let transformed: Vec = list.iter().map(transform_subscription).collect(); + result.insert("us".to_string(), Value::Array(transformed)); + } + print_json(&Value::Object(result)); + } + OutputFormat::Pretty => { + let mut printed = false; + if let Some(list) = hk_data["list"].as_array() { + if !list.is_empty() { + println!("── HK ──"); + let headers = [ + "name", + "symbol", + "currency", + "entrance_fee", + "est_sub", + "fin_rate", + "max_lev", + "issue_price", + "deadline", + "state", + ]; + let rows: Vec> = list + .iter() + .map(|item| { + let stage = state_stage_label(&item["state_stage"]).to_string(); + let tags: &[Value] = + item["tags"].as_array().map_or(&[], |a| a.as_slice()); + let fin_rate = extract_tag(tags, "融资利率"); + let max_lev = extract_tag(tags, "杠杆"); + vec![ + val_str(&item["name"]), + counter_id_to_symbol(&val_str(&item["counter_id"])), + val_str(&item["currency"]), + val_str(&item["entrance_fee"]), + val_str(&item["rate_forcast"]), + fin_rate, + max_lev, + val_str(&item["issue_price"]), + fmt_ts(&item["sub_deadline"]), + stage, + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + printed = true; + } + } + if let Some(list) = us_data["list"].as_array() { + if !list.is_empty() { + if printed { + println!(); + } + println!("── US ──"); + let headers = [ + "name", + "symbol", + "currency", + "issue_price", + "deadline", + "state", + ]; + let rows: Vec> = list + .iter() + .map(|item| { + let stage = state_stage_label(&item["state_stage"]).to_string(); + vec![ + val_str(&item["name"]), + counter_id_to_symbol(&val_str(&item["counter_id"])), + val_str(&item["currency"]), + val_str(&item["issue_price"]), + fmt_ts(&item["sub_deadline"]), + stage, + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + printed = true; + } + } + if !printed { + println!("No active IPO subscriptions."); + } + } + } + Ok(()) +} + +/// List IPO stocks in the wait-listing (grey market) stage (HK and US). +pub async fn cmd_ipo_wait_listing(format: &OutputFormat, verbose: bool) -> Result<()> { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let mid = member_id().await?; + let mid_str = mid.to_string(); + let day_str = now.to_string(); + let hk_params = [ + ("day_time", day_str.as_str()), + ("memebr_id", mid_str.as_str()), + ]; + let (hk_data, us_data) = tokio::join!( + http_get("/v1/ipo/wait-listing", &hk_params, verbose), + http_get("/v1/ipo/us/wait-listing", &[], verbose), + ); + let hk_data = hk_data?; + let us_data = us_data?; + let wait_list_row = |item: &Value| -> Vec { + vec![ + val_str(&item["name"]), + counter_id_to_symbol(&val_str(&item["counter_id"])), + val_str(&item["issue_price"]), + fmt_ts(&item["ipo_date"]), + state_stage_label(&item["state_stage"]).to_string(), + ] + }; + match format { + OutputFormat::Json => { + let mut result = serde_json::Map::new(); + if let Some(list) = hk_data["ipos"].as_array() { + let transformed: Vec = list.iter().map(transform_ipo_list_item).collect(); + result.insert("hk".to_string(), Value::Array(transformed)); + } + if let Some(list) = us_data["ipos"].as_array() { + let transformed: Vec = list.iter().map(transform_ipo_list_item).collect(); + result.insert("us".to_string(), Value::Array(transformed)); + } + print_json(&Value::Object(result)); + } + OutputFormat::Pretty => { + let headers = ["name", "symbol", "issue_price", "ipo_date", "state"]; + let mut printed = false; + if let Some(list) = hk_data["ipos"].as_array() { + if !list.is_empty() { + println!("── HK ──"); + let rows: Vec> = list.iter().map(wait_list_row).collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + printed = true; + } + } + if let Some(list) = us_data["ipos"].as_array() { + if !list.is_empty() { + if printed { + println!(); + } + println!("── US ──"); + let rows: Vec> = list.iter().map(wait_list_row).collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + printed = true; + } + } + if !printed { + println!("No IPO stocks in wait-listing."); + } + } + } + Ok(()) +} + +fn hk_listed_row(item: &Value) -> Vec { + let date = fmt_date_opt(&item["ipo_date"]); + let amount = val_str(&item["amount"]).parse::().map_or_else( + |_| val_str(&item["amount"]), + crate::utils::number::format_volume, + ); + vec![ + val_str(&item["name"]), + counter_id_to_symbol(&val_str(&item["counter_id"])), + val_str(&item["issue_price"]), + val_str(&item["last_done"]), + val_str(&item["prev_close"]), + val_str(&item["ipo_change"]), + amount, + date, + ] +} + +fn us_listed_row(item: &Value) -> Vec { + let date = fmt_date_opt(&item["ipo_date"]); + let amount = val_str(&item["amount"]).parse::().map_or_else( + |_| val_str(&item["amount"]), + crate::utils::number::format_volume, + ); + vec![ + val_str(&item["name"]), + counter_id_to_symbol(&val_str(&item["counter_id"])), + val_str(&item["issue_price"]), + val_str(&item["last_done"]), + val_str(&item["prev_close"]), + val_str(&item["ipo_change"]), + amount, + date, + ] +} + +/// List recently listed IPO stocks (HK and US). +pub async fn cmd_ipo_listed( + page: u32, + limit: u32, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let mid = member_id().await?; + let mid_str = mid.to_string(); + let page_str = page.to_string(); + let size_str = limit.to_string(); + let hk_params = [ + ("page", page_str.as_str()), + ("size", size_str.as_str()), + ("memebr_id", mid_str.as_str()), + ]; + let us_params = [("page", page_str.as_str()), ("size", size_str.as_str())]; + let (hk_data, us_data) = tokio::join!( + http_get("/v1/ipo/listed", &hk_params, verbose), + http_get("/v1/ipo/us/listed", &us_params, verbose), + ); + let hk_data = hk_data?; + let us_data = us_data?; + match format { + OutputFormat::Json => { + let mut result = serde_json::Map::new(); + if let Some(list) = hk_data["list"].as_array() { + let transformed: Vec = list.iter().map(transform_ipo_list_item).collect(); + result.insert("hk".to_string(), Value::Array(transformed)); + } + if let Some(list) = us_data["list"].as_array() { + let transformed: Vec = list.iter().map(transform_ipo_list_item).collect(); + result.insert("us".to_string(), Value::Array(transformed)); + } + print_json(&Value::Object(result)); + } + OutputFormat::Pretty => { + let mut printed = false; + if let Some(list) = hk_data["list"].as_array() { + if !list.is_empty() { + println!("── HK ──"); + let headers = [ + "name", + "symbol", + "issue_price", + "last_done", + "prev_close", + "change%", + "amount", + "ipo_date", + ]; + let rows: Vec> = list.iter().map(hk_listed_row).collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + printed = true; + } + } + if let Some(list) = us_data["list"].as_array() { + if !list.is_empty() { + if printed { + println!(); + } + println!("── US ──"); + let headers = [ + "name", + "symbol", + "issue_price", + "last_done", + "prev_close", + "change%", + "amount", + "ipo_date", + ]; + let rows: Vec> = list.iter().map(us_listed_row).collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + printed = true; + } + } + if !printed { + println!("No listed IPO stocks found."); + } + } + } + Ok(()) +} + +/// Show the IPO calendar (all upcoming and recent IPOs). +pub async fn cmd_ipo_calendar(format: &OutputFormat, verbose: bool) -> Result<()> { + let data = http_get("/v1/ipo/calendar", &[], verbose).await?; + match format { + OutputFormat::Json => { + if let Some(list) = data["list"].as_array() { + let transformed: Vec = list.iter().map(transform_ipo_list_item).collect(); + print_json(&Value::Array(transformed)); + } else { + print_json(&data); + } + } + OutputFormat::Pretty => { + if let Some(list) = data["list"].as_array() { + if list.is_empty() { + println!("No IPO calendar entries found."); + return Ok(()); + } + let headers = [ + "name", + "symbol", + "state", + "sub_date", + "sub_end_date", + "ipo_date", + ]; + let mut hk_rows: Vec> = Vec::new(); + let mut us_rows: Vec> = Vec::new(); + let mut other_rows: Vec> = Vec::new(); + for item in list { + let cid = val_str(&item["counter_id"]); + let row = vec![ + val_str(&item["name"]), + counter_id_to_symbol(&cid), + state_stage_label(&item["state_stage"]).to_string(), + fmt_date_opt(&item["sub_date"]), + fmt_date_opt(&item["sub_end_date"]), + fmt_date_opt(&item["ipo_date"]), + ]; + if cid.contains("/HK/") { + hk_rows.push(row); + } else if cid.contains("/US/") { + us_rows.push(row); + } else { + other_rows.push(row); + } + } + let mut printed = false; + if !hk_rows.is_empty() { + println!("── HK ──"); + print_table(&headers, hk_rows, &OutputFormat::Pretty); + printed = true; + } + if !us_rows.is_empty() { + if printed { + println!(); + } + println!("── US ──"); + print_table(&headers, us_rows, &OutputFormat::Pretty); + printed = true; + } + if !other_rows.is_empty() { + if printed { + println!(); + } + print_table(&headers, other_rows, &OutputFormat::Pretty); + } + } else { + print_json(&data); + } + } + } + Ok(()) +} + +/// Show IPO detail: profile (prospectus summary) + timeline for a symbol. +pub async fn cmd_ipo_detail( + symbol: String, + market: &str, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let cid = symbol_to_counter_id(&symbol); + let account_channel = crate::auth::account_channel_or_default(); + let profile_params = [("counter_id", cid.as_str())]; + let timeline_params = [ + ("counter_id", cid.as_str()), + ("market", market), + ("flag", "0"), + ]; + let eligibility_params = [("counter_id", cid.as_str())]; + let holdings_params = [ + ("counter_id", cid.as_str()), + ("need_realtime", "true"), + ("account_channel", account_channel.as_str()), + ]; + let (profile_data, timeline_data, eligibility_data, holdings_data) = tokio::join!( + http_get("/v1/ipo/profile", &profile_params, verbose), + http_get("/v1/ipo/timeline", &timeline_params, verbose), + http_get("/v1/ipo/eligibility", &eligibility_params, verbose), + http_get("/v1/ipo/holdings", &holdings_params, verbose), + ); + let profile_data = profile_data?; + let timeline_data = timeline_data?; + let eligibility_data = eligibility_data?; + let holdings_data = holdings_data?; + match format { + OutputFormat::Json => { + let mut result = serde_json::Map::new(); + result.insert("profile".to_string(), profile_data); + result.insert("timeline".to_string(), timeline_data); + result.insert("eligibility".to_string(), eligibility_data); + result.insert("holdings".to_string(), holdings_data); + print_json(&Value::Object(result)); + } + OutputFormat::Pretty => { + let kv = |label: &str, value: &str| { + println!("{:<24}{value}", format!("{label}:")); + }; + let trunc = |s: String, max: usize| -> String { + if s.chars().count() > max { + format!("{}…", s.chars().take(max).collect::()) + } else { + s + } + }; + let str_list = |arr: Option<&Vec>| -> String { + arr.map(|a| { + a.iter() + .filter_map(|x| { + let s = if x.is_string() { + val_str(x) + } else { + val_str(&x["name"]) + }; + if s == "-" || s.is_empty() { + None + } else { + Some(s) + } + }) + .collect::>() + .join(", ") + }) + .unwrap_or_default() + }; + + let profile = if market == "US" { + &profile_data["us"] + } else { + &profile_data["hk"] + }; + if !profile.is_null() { + let ipo_date = fmt_date_opt(&profile["ipo_date"]); + if ipo_date != "-" { + kv("IPO Date", &ipo_date); + } + + let currency = val_str(&profile["issue_currency"]); + let issue_price = val_str(&profile["issue_price"]); + if issue_price != "-" && !issue_price.is_empty() { + let price_str = if currency != "-" && !currency.is_empty() { + format!("{issue_price} {currency}") + } else { + issue_price + }; + kv("Issue Price", &price_str); + } + if profile["show_mart"].as_bool().unwrap_or(false) { + let mart_begin = fmt_ts(&profile["mart_begin"]); + let mart_end = fmt_ts(&profile["mart_end"]); + if mart_begin != "-" { + kv("Grey Market", &format!("{mart_begin} – {mart_end}")); + } + } + let trade_unit = val_str(&profile["trade_unit"]); + if trade_unit != "-" && !trade_unit.is_empty() && trade_unit != "0" { + kv("Trade Unit (Lot)", &trade_unit); + } + let proceeds = val_str(&profile["proceeds_planned"]); + if proceeds != "-" && !proceeds.is_empty() { + kv("Proceeds Planned", &proceeds); + } + + let industry = val_str(&profile["industry"]); + if industry != "-" && !industry.is_empty() { + kv("Industry", &industry); + } + + for (key, label) in &[ + ("margin_multiple", "Margin Multiple"), + ("margin_sub", "Margin Sub"), + ] { + let v = val_str(&profile[*key]); + if v != "-" && !v.is_empty() { + kv(label, &v); + } + } + let sponsors = str_list(profile["sponsor"].as_array()); + if !sponsors.is_empty() { + kv("Sponsor", &sponsors); + } + + let investors = str_list(profile["investors"].as_array()); + if !investors.is_empty() { + kv("Cornerstone Investors", &investors); + } + + if let Some(uw) = profile["underwriter"].as_array() { + if !uw.is_empty() { + let names: Vec = uw + .iter() + .take(5) + .filter_map(|x| { + let s = if x.is_string() { + val_str(x) + } else { + val_str(&x["name"]) + }; + if s == "-" || s.is_empty() { + None + } else { + Some(s) + } + }) + .collect(); + let mut label = names.join(", "); + if uw.len() > 5 { + use std::fmt::Write as _; + let _ = write!(label, " (+{})", uw.len() - 5); + } + if !label.is_empty() { + kv("Underwriters", &label); + } + } + } + let prospectus = val_str(&profile["prospectus"]); + if prospectus != "-" && !prospectus.is_empty() { + kv("Prospectus", &prospectus); + } + + let recommend_url = val_str(&profile["recommend_url"]); + if recommend_url != "-" && !recommend_url.is_empty() { + kv("Research", &recommend_url); + } + + let profile_text = val_str(&profile["profile"]); + if profile_text != "-" && !profile_text.is_empty() { + kv("Description", &trunc(profile_text, 200)); + } + let rec = val_str(&profile["rec_purposes"]); + if rec != "-" && !rec.is_empty() { + kv("Use of Proceeds", &trunc(rec, 200)); + } + println!(); + } + // Eligibility + timeline meta + let eligible = eligibility_data["can_subscribe"].as_bool(); + let can_sub = timeline_data["can_subscribe"].as_bool().unwrap_or(false); + let pay_end = val_str(&timeline_data["pay_end_date"]); + if eligible.is_some() || can_sub || (pay_end != "-" && !pay_end.is_empty()) { + if let Some(e) = eligible { + kv("Can Subscribe", if e { "Yes" } else { "No" }); + } + if pay_end != "-" && !pay_end.is_empty() { + kv("Payment Deadline", &pay_end); + } + println!(); + } + if let Some(timeline) = timeline_data["timeline"].as_array() { + if !timeline.is_empty() { + let headers = ["time", "event"]; + let rows: Vec> = timeline + .iter() + .map(|item| { + vec![ + val_str(&item["time"]).replace('\n', " "), + val_str(&item["name"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } + } else { + print_json(&timeline_data); + } + // Holdings section + if !holdings_data.is_null() && holdings_data.as_object().is_some() { + let max_purchase = val_str(&holdings_data["ipo_max_purchase"]); + let total = val_str(&holdings_data["total_amount"]); + let current = val_str(&holdings_data["current_amount"]); + let fee_rate = val_str(&holdings_data["finance_fee_rate"]); + let has_data = [&max_purchase, &total, ¤t, &fee_rate] + .iter() + .any(|v| *v != "-" && !v.is_empty() && *v != "0" && *v != "0.00"); + if has_data { + println!(); + if max_purchase != "-" && !max_purchase.is_empty() && max_purchase != "0" { + kv("Max Purchase", &max_purchase); + } + if total != "-" && !total.is_empty() && total != "0.00" { + kv("Total Amount", &total); + } + if current != "-" && !current.is_empty() && current != "0.00" { + kv("Current Amount", ¤t); + } + if fee_rate != "-" && !fee_rate.is_empty() { + kv("Finance Fee Rate", &fee_rate); + } + } + } + } + } + Ok(()) +} + +/// List IPO orders (active + history) for the current account. +pub async fn cmd_ipo_orders( + symbol: Option, + market: Option, + status: Option, + page: u32, + count: u32, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let account_channel = crate::auth::account_channel_or_default(); + let mut active_params: Vec<(&str, &str)> = vec![("account_channel", account_channel.as_str())]; + let cid; + if let Some(ref sym) = symbol { + cid = symbol_to_counter_id(sym); + active_params.push(("counter_id", cid.as_str())); + } + let page_str = page.to_string(); + let count_str = count.to_string(); + let mut hist_params: Vec<(&str, &str)> = + vec![("page", page_str.as_str()), ("limit", count_str.as_str())]; + if let Some(ref m) = market { + hist_params.push(("market", m.as_str())); + } + if let Some(ref s) = status { + hist_params.push(("status", s.as_str())); + } + let (active_data, hist_data) = tokio::join!( + http_get("/v1/ipo/orders", &active_params, verbose), + http_get("/v1/ipo/orders/history", &hist_params, verbose), + ); + let active_data = active_data?; + let hist_data = hist_data?; + match format { + OutputFormat::Json => { + let mut result = serde_json::Map::new(); + if let Some(arr) = active_data["orders"].as_array() { + let transformed: Vec = arr.iter().map(transform_order_item).collect(); + result.insert("orders".to_string(), Value::Array(transformed)); + } + if let Some(arr) = hist_data["orders"].as_array() { + let transformed: Vec = arr.iter().map(transform_order_item).collect(); + result.insert("history".to_string(), Value::Array(transformed)); + } + print_json(&Value::Object(result)); + } + OutputFormat::Pretty => { + let mut printed = false; + if let Some(orders) = active_data["orders"].as_array() { + if !orders.is_empty() { + println!("── Active ──"); + let headers = ["id", "symbol", "name", "qty", "status", "date"]; + let rows: Vec> = orders + .iter() + .map(|o| { + vec![ + val_str(&o["id"]), + counter_id_to_symbol(&val_str(&o["counter_id"])), + val_str(&o["name"]), + val_str(&o["sub_qty"]), + val_str(&o["status"]), + fmt_ts(&o["created_at"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + printed = true; + } + } + if let Some(arr) = hist_data["orders"].as_array() { + if !arr.is_empty() { + if printed { + println!(); + } + println!("── History ──"); + let headers = ["id", "symbol", "name", "qty", "won", "status", "date"]; + let rows: Vec> = arr + .iter() + .map(|o| { + vec![ + val_str(&o["id"]), + counter_id_to_symbol(&val_str(&o["counter_id"])), + val_str(&o["name"]), + val_str(&o["sub_qty"]), + val_str(&o["lot_win_qty"]), + val_str(&o["status"]), + fmt_ts(&o["created_at"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + printed = true; + } + } + if !printed { + println!("No IPO orders found."); + } + } + } + Ok(()) +} + +/// Show IPO order detail by order ID. +pub async fn cmd_ipo_order_detail( + order_id: String, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let account_channel = crate::auth::account_channel_or_default(); + let path = format!("/v1/ipo/orders/{order_id}"); + let data = http_get( + &path, + &[("account_channel", account_channel.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + let kv = |label: &str, value: &str| { + println!("{:<24}{value}", format!("{label}:")); + }; + kv( + "Symbol", + &counter_id_to_symbol(&val_str(&data["counter_id"])), + ); + kv("Name", &val_str(&data["name"])); + kv("Market", &val_str(&data["market"])); + let ipo_date = fmt_date_opt(&data["ipo_date"]); + if ipo_date != "-" { + kv("IPO Date", &ipo_date); + } + let ipo_price = val_str(&data["ipo_price"]); + if ipo_price != "-" && !ipo_price.is_empty() { + kv( + "IPO Price", + &format!("{} {}", ipo_price, val_str(&data["currency"])), + ); + } + kv("Status", &val_str(&data["status"])); + kv("Sub Qty", &val_str(&data["sub_qty"])); + let won = val_str(&data["lot_win_qty"]); + if won != "-" && won != "0" { + kv("Won Qty", &won); + } + let sub_amount = val_str(&data["sub_amount"]); + if sub_amount != "-" && sub_amount != "0.00" { + kv( + "Sub Amount", + &format!("{} {}", sub_amount, val_str(&data["currency"])), + ); + } + let sub_fee = val_str(&data["sub_fee"]); + if sub_fee != "-" && sub_fee != "0.00" { + kv( + "Sub Fee", + &format!("{} {}", sub_fee, val_str(&data["currency"])), + ); + } + let total = val_str(&data["total_amount"]); + if total != "-" && total != "0.00" { + kv( + "Total Amount", + &format!("{} {}", total, val_str(&data["currency"])), + ); + } + let need_to_pay = val_str(&data["need_to_pay"]); + if need_to_pay != "-" && need_to_pay != "0.00" { + kv( + "Need to Pay", + &format!("{} {}", need_to_pay, val_str(&data["currency"])), + ); + } + let refund = val_str(&data["refund_amount"]); + if refund != "-" && refund != "0.00" { + kv( + "Refund", + &format!("{} {}", refund, val_str(&data["currency"])), + ); + } + let mart_begin = fmt_ts(&data["mart_begin"]); + let mart_end = fmt_ts(&data["mart_end"]); + if mart_begin != "-" { + kv("Grey Market", &format!("{mart_begin} – {mart_end}")); + } + if let Some(timeline) = data["timeline"].as_array() { + if !timeline.is_empty() { + println!(); + let headers = ["time", "event"]; + let rows: Vec> = timeline + .iter() + .map(|item| { + vec![ + val_str(&item["time"]).replace('\n', " "), + val_str(&item["desc"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } + } + } + } + Ok(()) +} + +/// Show IPO profit/loss summary + items for the given period. +pub async fn cmd_ipo_profit_loss( + period: &str, + page: u32, + limit: u32, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let account_channel = crate::auth::account_channel_or_default(); + let page_str = page.to_string(); + let size_str = limit.to_string(); + let summary_params = [ + ("period", period), + ("account_channel", account_channel.as_str()), + ]; + let items_params = [ + ("period", period), + ("page", page_str.as_str()), + ("size", size_str.as_str()), + ("account_channel", account_channel.as_str()), + ]; + let (summary_data, items_data) = tokio::join!( + http_get("/v1/ipo/profit-loss", &summary_params, verbose), + http_get("/v1/ipo/profit-loss/items", &items_params, verbose), + ); + let summary_data = summary_data?; + let items_data = items_data?; + let mut summary = serde_json::Map::new(); + if let Some(obj) = summary_data.as_object() { + for (k, v) in obj { + if k == "updated_at" { + summary.insert(k.clone(), Value::String(fmt_ts(v))); + } else { + summary.insert(k.clone(), v.clone()); + } + } + } + let summary_val = Value::Object(summary); + match format { + OutputFormat::Json => { + let mut result = serde_json::Map::new(); + result.insert("summary".to_string(), summary_val); + result.insert("items".to_string(), items_data); + print_json(&Value::Object(result)); + } + OutputFormat::Pretty => { + print_json_value(&summary_val, format); + if let Some(items) = items_data["items"].as_array() { + if !items.is_empty() { + println!(); + let headers = ["symbol", "name", "qty", "cost", "profit", "rate"]; + let rows: Vec> = items + .iter() + .map(|item| { + vec![ + counter_id_to_symbol(&val_str(&item["counter_id"])), + val_str(&item["name"]), + val_str(&item["qty"]), + val_str(&item["cost_price"]), + val_str(&item["profit_loss"]), + val_str(&item["profit_loss_rate"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } + } else if !items_data.is_null() { + print_json_value(&items_data, format); + } + } + } + Ok(()) +} + +// ── US IPO commands ─────────────────────────────────────────────────────────── + +/// List US IPO stocks currently in subscription stage. +pub async fn cmd_ipo_us_subscriptions(format: &OutputFormat, verbose: bool) -> Result<()> { + let data = http_get("/v1/ipo/us/subscriptions", &[], verbose).await?; + match format { + OutputFormat::Json => { + if let Some(list) = data["list"].as_array() { + let transformed: Vec = list.iter().map(transform_subscription).collect(); + print_json(&Value::Array(transformed)); + } else { + print_json(&data); + } + } + OutputFormat::Pretty => { + if let Some(list) = data["list"].as_array() { + if list.is_empty() { + println!("No active US IPO subscriptions."); + return Ok(()); + } + let headers = [ + "name", + "symbol", + "currency", + "issue_price", + "deadline", + "state", + ]; + let rows: Vec> = list + .iter() + .map(|item| { + let stage = state_stage_label(&item["state_stage"]).to_string(); + vec![ + val_str(&item["name"]), + counter_id_to_symbol(&val_str(&item["counter_id"])), + val_str(&item["currency"]), + val_str(&item["issue_price"]), + fmt_ts(&item["sub_deadline"]), + stage, + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +/// List US IPO stocks in wait-listing stage. +pub async fn cmd_ipo_us_wait_listing(format: &OutputFormat, verbose: bool) -> Result<()> { + let data = http_get("/v1/ipo/us/wait-listing", &[], verbose).await?; + match format { + OutputFormat::Json => { + if let Some(list) = data["ipos"].as_array() { + let transformed: Vec = list.iter().map(transform_ipo_list_item).collect(); + print_json(&Value::Array(transformed)); + } else { + print_json(&data); + } + } + OutputFormat::Pretty => { + if let Some(list) = data["ipos"].as_array() { + if list.is_empty() { + println!("No US IPO stocks in wait-listing."); + return Ok(()); + } + let headers = ["name", "symbol", "issue_price", "ipo_date", "state"]; + let rows: Vec> = list + .iter() + .map(|item| { + vec![ + val_str(&item["name"]), + counter_id_to_symbol(&val_str(&item["counter_id"])), + val_str(&item["issue_price"]), + fmt_ts(&item["ipo_date"]), + state_stage_label(&item["state_stage"]).to_string(), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +/// List recently listed US IPO stocks. +pub async fn cmd_ipo_us_listed( + page: u32, + limit: u32, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let page_str = page.to_string(); + let size_str = limit.to_string(); + let data = http_get( + "/v1/ipo/us/listed", + &[("page", page_str.as_str()), ("size", size_str.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => { + if let Some(list) = data["list"].as_array() { + let transformed: Vec = list.iter().map(transform_ipo_list_item).collect(); + print_json(&Value::Array(transformed)); + } else { + print_json(&data); + } + } + OutputFormat::Pretty => { + if let Some(list) = data["list"].as_array() { + if list.is_empty() { + println!("No listed US IPO stocks found."); + return Ok(()); + } + let headers = [ + "name", + "symbol", + "issue_price", + "last_done", + "prev_close", + "change%", + "amount", + "ipo_date", + ]; + let rows: Vec> = list.iter().map(us_listed_row).collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 73e711d..c51b2ac 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,6 +3,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; pub mod api; pub mod asset; +pub mod atm; pub mod auth; pub mod check; pub mod completion; @@ -11,12 +12,14 @@ pub mod fundamental; pub mod init; pub mod insider_trades; pub mod investors; +pub mod ipo; pub mod my_quote; pub mod news; pub mod output; pub mod quant_render; pub mod quote; pub mod run_script; +pub mod search; pub mod sharelist; pub mod statement; pub mod topic; @@ -386,6 +389,9 @@ pub enum Commands { /// Report period: af (annual), saf (semi-annual), q1 (Q1), 3q (3 quarters), qf (quarterly) #[arg(long)] report: Option, + /// Fetch the latest financial report summary instead of the full statement + #[arg(long)] + latest: bool, }, /// Institution rating overview and target price summary @@ -401,6 +407,18 @@ pub enum Commands { symbol: Option, #[command(subcommand)] cmd: Option, + /// Show rating history (target price and rating changes over time) + #[arg(long)] + history: bool, + /// Show industry-wide rating ranking instead of per-symbol summary + #[arg(long)] + industry_rank: bool, + /// Page number for --industry-rank results (default: 1) + #[arg(long, default_value = "1")] + page: u32, + /// Page size for --industry-rank results (default: 20) + #[arg(long, default_value = "20")] + limit: u32, }, /// Dividend history and distribution details for a symbol @@ -477,11 +495,12 @@ pub enum Commands { /// Latest news articles for a symbol, or fetch full article content /// /// Without subcommand: lists news articles for a symbol. - /// Subcommands: detail + /// Subcommands: detail search /// Returns: id, title, `published_at`, likes, comments. /// Example: longbridge news TSLA.US /// Example: longbridge news TSLA.US --count 5 /// Example: longbridge news detail 12345678 + /// Example: longbridge news search "AI stocks" News { /// Symbol in . format (e.g. TSLA.US 700.HK). Omit when using a subcommand. symbol: Option, @@ -512,11 +531,12 @@ pub enum Commands { /// Community discussion topics /// /// Without subcommand: lists topics for a symbol. - /// Subcommands: list detail mine create replies create-reply + /// Subcommands: list detail mine create replies create-reply search /// Example: longbridge topic TSLA.US /// Example: longbridge topic list TSLA.US /// Example: longbridge topic detail 6993508780031016960 /// Example: longbridge topic create --body "Bullish on TSLA today" + /// Example: longbridge topic search TSLA Topic { /// Symbol in . format (e.g. TSLA.US 700.HK). Omit when using a subcommand. symbol: Option, @@ -625,9 +645,14 @@ pub enum Commands { /// Returns: overview (`total_asset`, `market_cap`, `total_cash`, `total_pl`, `total_today_pl`, /// `margin_call`, `risk_level`, `credit_limit`, currency), holdings table, and cash balances. /// + /// Without subcommand: shows full portfolio overview. + /// Subcommands: short-margin /// Example: longbridge portfolio - /// Example: longbridge portfolio --format json - Portfolio, + /// Example: longbridge portfolio short-margin + Portfolio { + #[command(subcommand)] + cmd: Option, + }, /// Current stock (equity) positions across all sub-accounts /// @@ -1017,6 +1042,95 @@ pub enum Commands { #[command(subcommand)] cmd: QuantCmd, }, + + // ── Fundamental (new) ──────────────────────────────────────────────────── + /// Financial statement (income / balance sheet / cash flow) for a symbol + /// + /// Example: longbridge financial-statement TSLA.US --kind IS --report af + /// Example: longbridge financial-statement 700.HK --kind BS --format json + FinancialStatement { + /// Symbol in . format + symbol: String, + /// Statement type: IS (income), BS (balance sheet), CF (cash flow), ALL + #[arg(long, value_name = "TYPE", default_value = "IS")] + kind: String, + /// Report period: af (annual), saf (semi-annual), qf (quarterly), cumul (cumulative) + #[arg(long, default_value = "af")] + report: String, + }, + + /// Valuation rank within the stock's industry for a date range + /// + /// Example: longbridge valuation-rank TSLA.US --start 20240101 --end 20241231 + ValuationRank { + /// Symbol in . format + symbol: String, + /// Start date YYYYMMDD (default: 1 year ago) + #[arg(long)] + start: Option, + /// End date YYYYMMDD (default: today) + #[arg(long)] + end: Option, + }, + + /// Analyst consensus estimates (EPS) for a symbol + /// + /// Example: longbridge analyst-estimates TSLA.US + AnalystEstimates { + /// Symbol in . format + symbol: String, + }, + + // ── Asset (new) ────────────────────────────────────────────────────────── + // ── ATM (new) ──────────────────────────────────────────────────────────── + /// List bank cards for the current account + /// + /// Example: longbridge bank-cards + #[command(name = "bank-cards")] + BankCards, + + /// List withdrawal history for the current account + /// + /// Example: longbridge withdrawals + /// Example: longbridge withdrawals --page 2 --limit 50 + Withdrawals { + /// Page number (default: 1) + #[arg(long, default_value = "1")] + page: u32, + /// Records per page (default: 20) + #[arg(long, alias = "limit", default_value = "20")] + count: u32, + }, + + /// List deposit history for the current account + /// + /// Example: longbridge deposits + /// Example: longbridge deposits --states 1 --currencies HKD,USD + Deposits { + /// Page number (default: 1) + #[arg(long, default_value = "1")] + page: u32, + /// Records per page (default: 20) + #[arg(long, alias = "limit", default_value = "20")] + count: u32, + /// Filter by state: 0=pending, 1=credited, 2=failed (comma-separated) + #[arg(long)] + states: Option, + /// Filter by currency codes (comma-separated, e.g. HKD,USD) + #[arg(long)] + currencies: Option, + }, + + // ── Search (new) ───────────────────────────────────────────────────────── + // ── IPO (new) ──────────────────────────────────────────────────────────── + /// IPO (new listings) commands — subscriptions, calendar, orders, profit/loss + /// + /// Example: longbridge ipo subscriptions + /// Example: longbridge ipo calendar + Ipo { + #[command(subcommand)] + cmd: IpoCmd, + }, } #[derive(Subcommand)] @@ -1476,6 +1590,97 @@ pub enum InstitutionRatingCmd { }, } +#[derive(Subcommand)] +pub enum PortfolioCmd { + /// Short-selling margin deposit details for the current account + /// + /// Example: longbridge portfolio short-margin + #[command(name = "short-margin")] + ShortMargin, +} + +#[derive(Subcommand)] +pub enum IpoCmd { + /// List IPO stocks currently in filing or subscription stage + Subscriptions, + /// List IPO stocks in wait-listing (grey market) stage + WaitListing, + /// List recently listed IPO stocks + Listed { + #[arg(long, default_value = "1")] + page: u32, + #[arg(long, alias = "limit", default_value = "20")] + count: u32, + }, + /// Show the IPO calendar (all upcoming and recent IPOs) + Calendar, + /// Show IPO detail: profile and timeline for a symbol + /// + /// Example: longbridge ipo detail 6810.HK + /// Example: longbridge ipo detail AAPL.US --market US + Detail { + symbol: String, + /// Market: HK (default) or US + #[arg(long, default_value = "HK")] + market: String, + }, + /// IPO orders (active + history) for the current account + /// + /// Without a subcommand, lists active and historical orders. + /// Example: longbridge ipo orders + /// Example: longbridge ipo orders --status 4 + /// Example: longbridge ipo orders detail 2452504 + Orders { + #[arg(long)] + market: Option, + /// Status filter for history: 0=all, 1=subscribed, 2=debit-failed, 3=not-won, 4=won, 5=cancelled + #[arg(long)] + status: Option, + #[arg(long, default_value = "1")] + page: u32, + #[arg(long, alias = "limit", default_value = "20")] + count: u32, + #[command(subcommand)] + cmd: Option, + }, + /// Show IPO profit/loss summary and items for a period + #[command(name = "profit-loss")] + ProfitLoss { + /// Period: 1m | 3m | 6m | 1y | all + #[arg(long, default_value = "all")] + period: String, + #[arg(long, default_value = "1")] + page: u32, + #[arg(long, alias = "limit", default_value = "20")] + count: u32, + }, + /// List US IPO stocks currently in subscription stage + #[command(name = "us-subscriptions")] + UsSubscriptions, + /// List US IPO stocks in wait-listing stage + #[command(name = "us-wait-listing")] + UsWaitListing, + /// List recently listed US IPO stocks + #[command(name = "us-listed")] + UsListed { + #[arg(long, default_value = "1")] + page: u32, + #[arg(long, alias = "limit", default_value = "20")] + count: u32, + }, +} + +#[derive(Subcommand)] +pub enum IpoOrderCmd { + /// Full detail for a single IPO order + /// + /// Example: longbridge ipo orders detail 2452504 + Detail { + /// IPO order ID + order_id: String, + }, +} + #[derive(Subcommand)] pub enum WatchlistCmd { /// Show securities in a specific watchlist group (by ID or name) @@ -1980,6 +2185,18 @@ pub enum NewsCmd { /// News article ID (from `longbridge news `) id: String, }, + + /// Search news by keyword + /// + /// Example: longbridge news search "AI stocks" + /// Example: longbridge news search TSLA --count 10 + Search { + /// Search keyword + keyword: String, + /// Maximum results to display (default: 20) + #[arg(long, alias = "limit", default_value = "20")] + count: usize, + }, } #[derive(Subcommand)] @@ -2091,6 +2308,18 @@ pub enum TopicCmd { #[arg(long = "reply-to")] reply_to_id: Option, }, + + /// Search community topics by keyword + /// + /// Example: longbridge topic search TSLA + /// Example: longbridge topic search "AI stocks" --count 10 + Search { + /// Search keyword + keyword: String, + /// Maximum results to display (default: 20) + #[arg(long, alias = "limit", default_value = "20")] + count: usize, + }, } #[derive(Subcommand)] @@ -2367,20 +2596,55 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re symbol, kind, report, - } => fundamental::cmd_financial_report(symbol, kind, report, format, verbose).await, - Commands::InstitutionRating { symbol, cmd } => match cmd { - Some(InstitutionRatingCmd::Detail { symbol: s }) => { - fundamental::cmd_institution_rating_detail(s, format, verbose).await + latest, + } => { + if latest { + fundamental::cmd_financial_report_latest(symbol, format, verbose).await + } else { + fundamental::cmd_financial_report(symbol, kind, report, format, verbose).await } - None => { + } + Commands::InstitutionRating { + symbol, + cmd, + history, + industry_rank, + page, + limit, + } => { + if industry_rank { let sym = symbol.ok_or_else(|| { anyhow::anyhow!( - "Symbol required. Usage: longbridge institution-rating " + "Symbol required. Usage: longbridge institution-rating --industry-rank" ) })?; - fundamental::cmd_institution_rating(sym, format, verbose).await + fundamental::cmd_institution_rating_industry_rank( + sym, page, limit, format, verbose, + ) + .await + } else if history { + let sym = symbol.ok_or_else(|| { + anyhow::anyhow!( + "Symbol required. Usage: longbridge institution-rating --history" + ) + })?; + fundamental::cmd_institution_rating_history(sym, format, verbose).await + } else { + match cmd { + Some(InstitutionRatingCmd::Detail { symbol: s }) => { + fundamental::cmd_institution_rating_detail(s, format, verbose).await + } + None => { + let sym = symbol.ok_or_else(|| { + anyhow::anyhow!( + "Symbol required. Usage: longbridge institution-rating " + ) + })?; + fundamental::cmd_institution_rating(sym, format, verbose).await + } + } } - }, + } Commands::Dividend { symbol, page, year, cmd } => match cmd { Some(DividendCmd::Detail { symbol: s }) => { fundamental::cmd_dividend_detail(s, format, verbose).await @@ -2434,6 +2698,9 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re } Commands::News { symbol, count, cmd } => match cmd { Some(NewsCmd::Detail { id }) => news::cmd_news_detail(id).await, + Some(NewsCmd::Search { keyword, count }) => { + search::cmd_search(keyword, "news", count, format, verbose).await + } None => { let sym = symbol.ok_or_else(|| { anyhow::anyhow!("Symbol required. Usage: longbridge news ") @@ -2478,6 +2745,9 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re body, reply_to_id, }) => topic::cmd_create_reply(topic_id, body, reply_to_id, format).await, + Some(TopicCmd::Search { keyword, count }) => { + search::cmd_search(keyword, "topics", count, format, verbose).await + } None => { let sym = symbol.ok_or_else(|| { anyhow::anyhow!("Symbol required. Usage: longbridge topic ") @@ -2600,7 +2870,11 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re }, Commands::Assets { currency } => trade::cmd_assets(currency, format).await, Commands::CashFlow { start, end } => trade::cmd_cash_flow(start, end, format).await, - Commands::Portfolio => trade::cmd_portfolio(format).await, + Commands::Portfolio { cmd } => match cmd { + None => trade::cmd_portfolio(format).await, + Some(PortfolioCmd::ShortMargin) => asset::cmd_short_margin(format, verbose).await, + + }, Commands::Positions => trade::cmd_positions(format).await, Commands::FundPositions => trade::cmd_fund_positions(format).await, Commands::MarginRatio { symbol } => trade::cmd_margin_ratio(symbol, format).await, @@ -2820,6 +3094,72 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re } => run_script::cmd_run_script(symbol, &period, &start, &end, script, input, format, verbose).await, }, + Commands::FinancialStatement { symbol, kind, report } => { + fundamental::cmd_financial_statement(symbol, &kind, &report, format, verbose).await + } + Commands::ValuationRank { symbol, start, end } => { + fundamental::cmd_valuation_rank(symbol, start.as_deref(), end.as_deref(), format, verbose).await + } + Commands::AnalystEstimates { symbol } => { + fundamental::cmd_analyst_estimates(symbol, format, verbose).await + } + + + Commands::BankCards => atm::cmd_withdrawal_cards(format, verbose).await, + Commands::Withdrawals { page, count } => { + atm::cmd_withdrawals(page, count, format, verbose).await + } + Commands::Deposits { + page, + count, + states, + currencies, + } => { + atm::cmd_deposits( + page, + count, + states.as_deref(), + currencies.as_deref(), + format, + verbose, + ) + .await + } + + Commands::Ipo { cmd } => match cmd { + IpoCmd::Subscriptions => ipo::cmd_ipo_subscriptions(format, verbose).await, + IpoCmd::WaitListing => ipo::cmd_ipo_wait_listing(format, verbose).await, + IpoCmd::Listed { page, count } => { + ipo::cmd_ipo_listed(page, count, format, verbose).await + } + IpoCmd::Calendar => ipo::cmd_ipo_calendar(format, verbose).await, + IpoCmd::Detail { symbol, market } => { + ipo::cmd_ipo_detail(symbol, &market, format, verbose).await + } + IpoCmd::Orders { + market, + status, + page, + count, + cmd, + } => match cmd { + Some(IpoOrderCmd::Detail { order_id }) => { + ipo::cmd_ipo_order_detail(order_id, format, verbose).await + } + None => { + ipo::cmd_ipo_orders(None, market, status, page, count, format, verbose).await + } + }, +IpoCmd::ProfitLoss { period, page, count } => { + ipo::cmd_ipo_profit_loss(&period, page, count, format, verbose).await + } + IpoCmd::UsSubscriptions => ipo::cmd_ipo_us_subscriptions(format, verbose).await, + IpoCmd::UsWaitListing => ipo::cmd_ipo_us_wait_listing(format, verbose).await, + IpoCmd::UsListed { page, count } => { + ipo::cmd_ipo_us_listed(page, count, format, verbose).await + } + }, + Commands::Auth { .. } | Commands::Tui | Commands::Check diff --git a/src/cli/news.rs b/src/cli/news.rs index 2ac55b0..502074c 100644 --- a/src/cli/news.rs +++ b/src/cli/news.rs @@ -38,7 +38,7 @@ pub async fn cmd_news(symbol: String, count: usize, format: &OutputFormat) -> Re "id": item.id, "title": title, "url": item.url, - "published_at": item.published_at.unix_timestamp(), + "published_at": format_datetime(item.published_at), "likes_count": item.likes_count, "comments_count": item.comments_count, }) @@ -94,7 +94,7 @@ pub async fn cmd_filings(symbol: String, count: usize, format: &OutputFormat) -> "title": item.title, "description": item.description, "file_name": item.file_name, - "publish_at": item.published_at.unix_timestamp(), + "publish_at": format_datetime(item.published_at), "file_count": item.file_urls.len(), "file_urls": item.file_urls, }) @@ -259,9 +259,9 @@ pub async fn cmd_topics(symbol: String, count: usize, format: &OutputFormat) -> serde_json::json!({ "id": item.id, "title": item.title, - "description": crate::cli::topic::format_topic_contents(&item.description), + "excerpt": crate::cli::topic::format_topic_contents(&item.description), "url": item.url, - "published_at": item.published_at.unix_timestamp(), + "published_at": format_datetime(item.published_at), "likes_count": item.likes_count, "comments_count": item.comments_count, "shares_count": item.shares_count, diff --git a/src/cli/quant_render.rs b/src/cli/quant_render.rs index cb74076..4456abe 100644 --- a/src/cli/quant_render.rs +++ b/src/cli/quant_render.rs @@ -23,9 +23,7 @@ const SERIES_COLORS: &[Color] = &[ // ── Terminal helpers ────────────────────────────────────────────────────────── fn term_width() -> usize { - crossterm::terminal::size() - .map(|(w, _)| w as usize) - .unwrap_or(120) + crossterm::terminal::size().map_or(120, |(w, _)| w as usize) } type Out<'a> = std::io::StdoutLock<'a>; diff --git a/src/cli/search.rs b/src/cli/search.rs new file mode 100644 index 0000000..1d146c0 --- /dev/null +++ b/src/cli/search.rs @@ -0,0 +1,187 @@ +use anyhow::Result; +use serde_json::{Map, Value}; + +use super::api::http_get; +use super::output::print_table; +use super::OutputFormat; +use crate::utils::text::strip_html; + +fn fmt_ts(v: &serde_json::Value) -> String { + let ts = match v { + serde_json::Value::Number(n) => n.as_i64(), + serde_json::Value::String(s) => s.parse::().ok(), + _ => None, + }; + ts.map_or_else(|| val_str(v), crate::utils::datetime::format_timestamp) +} + +fn print_json(value: &Value) { + println!( + "{}", + serde_json::to_string_pretty(value).unwrap_or_default() + ); +} + +fn val_str(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "-".to_string(), + other => other.to_string(), + } +} + +fn transform_news_item(item: &Value) -> Value { + let keep: &[&str] = &["id", "title", "source_name"]; + let mut obj = Map::new(); + if let Some(map) = item.as_object() { + for (k, v) in map { + if k == "publish_at_timestamp" { + obj.insert("time".to_string(), Value::String(fmt_ts(v))); + } else if k == "description" { + let excerpt: String = strip_html(&val_str(v)).chars().take(80).collect(); + obj.insert("excerpt".to_string(), Value::String(excerpt)); + } else if keep.contains(&k.as_str()) { + if k == "title" { + obj.insert(k.clone(), Value::String(strip_html(&val_str(v)))); + } else { + obj.insert(k.clone(), v.clone()); + } + } + } + } + if let Some(id) = obj.get("id").and_then(Value::as_str) { + let url = format!("https://longbridge.com/news/{id}.md"); + obj.insert("url".to_string(), Value::String(url)); + } + Value::Object(obj) +} + +fn transform_topic_item(item: &Value) -> Value { + let keep: &[&str] = &[ + "id", + "title", + "comments_count", + "likes_count", + "creator_name", + "creator_id", + ]; + let mut obj = Map::new(); + if let Some(map) = item.as_object() { + for (k, v) in map { + if k == "created_at_timestamp" { + obj.insert("time".to_string(), Value::String(fmt_ts(v))); + } else if k == "description" { + let excerpt: String = strip_html(&val_str(v)).chars().take(80).collect(); + obj.insert("excerpt".to_string(), Value::String(excerpt)); + } else if keep.contains(&k.as_str()) { + if k == "title" { + obj.insert(k.clone(), Value::String(strip_html(&val_str(v)))); + } else { + obj.insert(k.clone(), v.clone()); + } + } + } + } + if let Some(id) = obj.get("id").and_then(Value::as_str) { + let url = format!("https://longbridge.com/topics/{id}.md"); + obj.insert("url".to_string(), Value::String(url)); + } + Value::Object(obj) +} + +// ── search ──────────────────────────────────────────────────────────────────── + +/// Search news or community topics. +pub async fn cmd_search( + keyword: String, + tab: &str, + limit: usize, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + match tab { + "news" => { + let data = http_get("/v1/search/news", &[("k", keyword.as_str())], verbose).await?; + match format { + OutputFormat::Json => { + if let Some(list) = data["news_list"].as_array() { + let items: Vec = + list.iter().take(limit).map(transform_news_item).collect(); + print_json(&Value::Array(items)); + } else { + print_json(&data); + } + } + OutputFormat::Pretty => { + if let Some(list) = data["news_list"].as_array() { + let items: Vec<&Value> = list.iter().take(limit).collect(); + if items.is_empty() { + println!("No news results."); + return Ok(()); + } + let headers = ["id", "title", "time"]; + let rows: Vec> = items + .iter() + .map(|item| { + vec![ + val_str(&item["id"]), + strip_html(&val_str(&item["title"])), + fmt_ts(&item["publish_at_timestamp"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + } + "topics" => { + let data = http_get("/v1/search/topics", &[("k", keyword.as_str())], verbose).await?; + match format { + OutputFormat::Json => { + if let Some(list) = data["topic_list"].as_array() { + let items: Vec = + list.iter().take(limit).map(transform_topic_item).collect(); + print_json(&Value::Array(items)); + } else { + print_json(&data); + } + } + OutputFormat::Pretty => { + if let Some(list) = data["topic_list"].as_array() { + let items: Vec<&Value> = list.iter().take(limit).collect(); + if items.is_empty() { + println!("No topic results."); + return Ok(()); + } + let headers = ["id", "author", "time", "excerpt"]; + let rows: Vec> = items + .iter() + .map(|item| { + let excerpt: String = strip_html(&val_str(&item["description"])) + .chars() + .take(60) + .collect(); + vec![ + val_str(&item["id"]), + val_str(&item["creator_name"]), + fmt_ts(&item["created_at_timestamp"]), + excerpt, + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + } + _ => unreachable!(), + } + Ok(()) +}