From 5a1351563bb90107cdcf80b24d816c86e9743d19 Mon Sep 17 00:00:00 2001 From: Endless Agent Date: Thu, 7 May 2026 03:38:10 +0000 Subject: [PATCH 01/44] feat(dev): task #14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目标:基于 `docs/api-reference.md` 中的接口,为 `longbridge-terminal` CLI 开发对应的子命令, commit 和 pr 都使用英文 --- ## 🤖 Generated with Endless Co-authored-by: 老袁 Yuan Zhanghong --- src/auth.rs | 7 + src/cli/asset.rs | 113 ++++++++ src/cli/atm.rs | 179 ++++++++++++ src/cli/fundamental.rs | 155 ++++++++++ src/cli/ipo.rs | 587 ++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 430 +++++++++++++++++++++++++++- src/cli/quant_render.rs | 1 - src/cli/search.rs | 315 ++++++++++++++++++++ src/tui/systems/orders.rs | 71 +++-- 9 files changed, 1829 insertions(+), 29 deletions(-) create mode 100644 src/cli/atm.rs create mode 100644 src/cli/ipo.rs create mode 100644 src/cli/search.rs diff --git a/src/auth.rs b/src/auth.rs index aa545ab..730da6a 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -364,6 +364,13 @@ pub async fn auth_code_login() -> Result<()> { } } +/// Return the account channel for the current session, defaulting to "lb". +/// +/// PSPL Singapore users can set `LONGBRIDGE_ACCOUNT_CHANNEL=pspl_sg` to override. +pub fn account_channel_or_default() -> String { + std::env::var("LONGBRIDGE_ACCOUNT_CHANNEL").unwrap_or_else(|_| "lb".to_string()) +} + /// Clear the stored OAuth token (logout). Deletes the token file used by the longbridge SDK. pub fn clear_token() -> Result<()> { let path = token_file_path()?; diff --git a/src/cli/asset.rs b/src/cli/asset.rs index 3ca4837..9816c13 100644 --- a/src/cli/asset.rs +++ b/src/cli/asset.rs @@ -497,3 +497,116 @@ 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/detail", &[], 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(()) +} + +// ── pnl calendar ───────────────────────────────────────────────────────────── + +pub async fn cmd_pnl_calendar(format: &OutputFormat, verbose: bool) -> Result<()> { + let data = http_get("/portfolio/pnl/calendar", &[], verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(list) = data["calendar_list"].as_array() { + if list.is_empty() { + println!("No P&L calendar data."); + return Ok(()); + } + let headers = ["date", "pnl", "currency"]; + let rows: Vec> = list + .iter() + .map(|item| { + vec![ + val_str(&item["date"]), + val_str(&item["pnl"]), + val_str(&item["currency"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +// ── holding period ──────────────────────────────────────────────────────────── + +pub async fn cmd_holding_period( + symbol: String, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let cid = crate::utils::counter::symbol_to_counter_id(&symbol); + let data = http_get( + "/portfolio/asset/stock_holding_period", + &[("counter_id", cid.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => 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/portfolio/asset/trade/detail", + &[("counter_id", cid.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_json(&data), + } + Ok(()) +} + +// ── order stats (today's account trade summary) ─────────────────────────────── + +pub async fn cmd_order_stats(format: &OutputFormat, verbose: bool) -> Result<()> { + let data = http_get("/v1/orders/trade_analysis", &[], verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_json(&data), + } + Ok(()) +} diff --git a/src/cli/atm.rs b/src/cli/atm.rs new file mode 100644 index 0000000..9dfd7ac --- /dev/null +++ b/src/cli/atm.rs @@ -0,0 +1,179 @@ +use anyhow::Result; +use serde_json::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 => String::new(), + other => other.to_string(), + } +} + +// ── withdrawal cards ────────────────────────────────────────────────────────── + +/// List withdrawal bank cards for the current user. +pub async fn cmd_withdrawal_cards(format: &OutputFormat, verbose: bool) -> Result<()> { + let data = http_get("/v3/portfolio/withdraw/cards", &[], verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(cards) = data["cards"].as_array() { + if cards.is_empty() { + println!("No withdrawal cards found."); + return Ok(()); + } + let headers = ["bank_name", "account_number", "currency", "status"]; + let rows: Vec> = cards + .iter() + .map(|card| { + vec![ + val_str(&card["bank_name"]), + val_str(&card["account_number"]), + val_str(&card["currency"]), + val_str(&card["status"]), + ] + }) + .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( + "/v6/portfolio/withdraw/record", + &[ + ("page", page_str.as_str()), + ("size", size_str.as_str()), + ("account_channel", account_channel.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + 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![ + val_str(&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("/v5/portfolio/deposit/notify", ¶ms, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + 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 = ["date", "amount", "currency", "state", "source"]; + let rows: Vec> = items + .iter() + .map(|item| { + let state = match val_str(&item["state"]).as_str() { + "0" => "Pending".to_string(), + "1" => "Credited".to_string(), + "2" => "Failed".to_string(), + s => s.to_string(), + }; + vec![ + val_str(&item["created_at"]), + val_str(&item["amount"]), + val_str(&item["currency"]), + state, + val_str(&item["fund_source"]), + ] + }) + .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 e0d479f..aa8ad83 100644 --- a/src/cli/fundamental.rs +++ b/src/cli/fundamental.rs @@ -1777,3 +1777,158 @@ fn print_invest_relation(data: &Value) { .collect(); super::output::print_table(&headers, rows, &OutputFormat::Pretty); } + +// ── financial statement (v3) ───────────────────────────────────────────────── + +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 data = http_get( + "/v3/stock-info/statement", + &[ + ("counter_id", cid.as_str()), + ("kind", kind), + ("report", report), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_kv(&data), + } + 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( + "/v4/stock-info/latest-financial-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: &str, + end: &str, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let cid = symbol_to_counter_id(&symbol); + let data = http_get( + "/stock-info/valuation-rank", + &[ + ("counter_id", cid.as_str()), + ("start_date", start), + ("end_date", end), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_kv(&data), + } + 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( + "/stock-info/estimate", + &[("counter_id", cid.as_str())], + 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( + "/stock-info/recommendation/history", + &[("counter_id", cid.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(target) = data.get("target_history") { + println!("Target price history:"); + print_kv(target); + } + if let Some(eval) = data.get("evaluate_history") { + println!("\nRating history:"); + print_kv(eval); + } + } + } + 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( + "/v2/fa/institution-rating-industry-rank", + &[ + ("counter_id", cid.as_str()), + ("page", page_str.as_str()), + ("size", size_str.as_str()), + ], + verbose, + ) + .await?; + 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..3fe3c47 --- /dev/null +++ b/src/cli/ipo.rs @@ -0,0 +1,587 @@ +use anyhow::Result; +use serde_json::Value; +use std::io::Write; + +use super::api::{http_get, http_post}; +use super::output::print_table; +use super::OutputFormat; +use crate::utils::counter::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 => String::new(), + other => other.to_string(), + } +} + +fn confirm_action(action: &str) -> Result { + print!("Are you sure you want to {action}? [y/N] "); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + Ok(input.trim().eq_ignore_ascii_case("y")) +} + +async fn member_id() -> Result { + crate::openapi::quote().member_id().await +} + +// ── read-only IPO list commands ──────────────────────────────────────────────── + +/// List IPO stocks currently in subscription or pre-filing stage. +pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Result<()> { + let mid = member_id().await?; + let mid_str = mid.to_string(); + let data = http_get( + "/newmarket/hk/ipo/subscribing", + &[("memebr_id", mid_str.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(list) = data["list"].as_array() { + if list.is_empty() { + println!("No active IPO subscriptions."); + return Ok(()); + } + let headers = ["name", "counter_id", "issue_price", "deadline", "state"]; + let rows: Vec> = list + .iter() + .map(|item| { + let stage = match val_str(&item["state_stage"]).as_str() { + "1" => "sub-start", + "2" => "sub-end", + "3" => "allotment", + "4" => "grey-market", + "5" => "listed", + s => s, + } + .to_string(); + vec![ + val_str(&item["name"]), + val_str(&item["counter_id"]), + val_str(&item["issue_price"]), + val_str(&item["sub_deadline"]), + stage, + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +/// List IPO stocks in the wait-listing (grey market) stage. +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 data = http_get( + "/newmarket/hk/ipo/wait_listing", + &[ + ("day_time", day_str.as_str()), + ("memebr_id", mid_str.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(list) = data["list"].as_array() { + if list.is_empty() { + println!("No IPO stocks in wait-listing."); + return Ok(()); + } + let headers = ["name", "counter_id", "issue_price", "listing_date"]; + let rows: Vec> = list + .iter() + .map(|item| { + vec![ + val_str(&item["name"]), + val_str(&item["counter_id"]), + val_str(&item["issue_price"]), + val_str(&item["listing_date"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +/// List recently listed IPO stocks. +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 data = http_get( + "/newmarket/hk/ipo/ipo_listing", + &[ + ("page", page_str.as_str()), + ("size", size_str.as_str()), + ("memebr_id", mid_str.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(list) = data["list"].as_array() { + if list.is_empty() { + println!("No listed IPO stocks found."); + return Ok(()); + } + let headers = ["name", "counter_id", "issue_price", "listing_date"]; + let rows: Vec> = list + .iter() + .map(|item| { + vec![ + val_str(&item["name"]), + val_str(&item["counter_id"]), + val_str(&item["issue_price"]), + val_str(&item["listing_date"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + 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("/newmarket/ipo/calendar", &[], verbose).await?; + match format { + OutputFormat::Json => 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 = ["date", "name", "counter_id", "type"]; + let rows: Vec> = list + .iter() + .map(|item| { + vec![ + val_str(&item["date"]), + val_str(&item["name"]), + val_str(&item["counter_id"]), + val_str(&item["type"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +/// Show IPO subscription page information for a symbol. +pub async fn cmd_ipo_info(symbol: String, format: &OutputFormat, verbose: bool) -> Result<()> { + let account_channel = crate::auth::account_channel_or_default(); + let cid = symbol_to_counter_id(&symbol); + let data = http_get( + "/v3/ipo/info", + &[ + ("counter_id", cid.as_str()), + ("account_channel", account_channel.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_json(&data), + } + Ok(()) +} + +/// Show IPO profile (prospectus summary) for a symbol. +pub async fn cmd_ipo_profile(symbol: String, format: &OutputFormat, verbose: bool) -> Result<()> { + let cid = symbol_to_counter_id(&symbol); + let data = http_get( + "/v1/stock-info/ipo-profile", + &[("counter_id", cid.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_json(&data), + } + Ok(()) +} + +/// Show the IPO timeline for a symbol. +pub async fn cmd_ipo_timeline( + symbol: String, + market: &str, + flag: u8, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let cid = symbol_to_counter_id(&symbol); + let flag_str = flag.to_string(); + let data = http_get( + "/stock-info/ipo-timeline", + &[ + ("counter_id", cid.as_str()), + ("market", market), + ("flag", flag_str.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(timeline) = data["timeline"].as_array() { + let headers = ["date", "event", "status"]; + let rows: Vec> = timeline + .iter() + .map(|item| { + vec![ + val_str(&item["date"]), + val_str(&item["event"]), + val_str(&item["status"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +/// Show the current active IPO order status for a symbol. +pub async fn cmd_ipo_order(symbol: String, format: &OutputFormat, verbose: bool) -> Result<()> { + let cid = symbol_to_counter_id(&symbol); + let data = http_get( + "/ipo/active_order", + &[("counter_id", cid.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_json(&data), + } + Ok(()) +} + +/// List active IPO holding orders for the current account. +pub async fn cmd_ipo_orders( + symbol: Option, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let account_channel = crate::auth::account_channel_or_default(); + let mut 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); + params.push(("counter_id", cid.as_str())); + } + let data = http_get("/ipo/holding", ¶ms, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(orders) = data["orders"].as_array() { + if orders.is_empty() { + println!("No active IPO orders."); + return Ok(()); + } + let headers = ["id", "name", "code", "qty", "status"]; + let rows: Vec> = orders + .iter() + .map(|o| { + vec![ + val_str(&o["id"]), + val_str(&o["name"]), + val_str(&o["code"]), + val_str(&o["sub_qty"]), + val_str(&o["status"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + 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 data = http_get( + "/v1/ipo/detail", + &[ + ("order_id", order_id.as_str()), + ("account_channel", account_channel.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_json(&data), + } + Ok(()) +} + +/// List IPO subscription history. +pub async fn cmd_ipo_history( + market: Option, + status: Option, + page: u32, + limit: u32, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let page_str = page.to_string(); + let limit_str = limit.to_string(); + let mut params: Vec<(&str, &str)> = + vec![("page", page_str.as_str()), ("limit", limit_str.as_str())]; + if let Some(ref m) = market { + params.push(("market", m.as_str())); + } + if let Some(ref s) = status { + params.push(("status", s.as_str())); + } + let data = http_get("/ipo/history", ¶ms, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(arr) = data.as_array() { + if arr.is_empty() { + println!("No IPO history found."); + return Ok(()); + } + let headers = ["id", "name", "code", "qty", "won", "status", "date"]; + let rows: Vec> = arr + .iter() + .map(|o| { + vec![ + val_str(&o["id"]), + val_str(&o["name"]), + val_str(&o["code"]), + val_str(&o["sub_qty"]), + val_str(&o["lot_win_qty"]), + val_str(&o["status"]), + val_str(&o["created_at"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + Ok(()) +} + +/// Check if the current user is eligible to subscribe to an IPO. +pub async fn cmd_ipo_eligibility( + symbol: String, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let cid = symbol_to_counter_id(&symbol); + let data = http_get( + "/v1/ipo/check_user_can_subscribe", + &[("counter_id", cid.as_str())], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_json(&data), + } + Ok(()) +} + +/// Show IPO profit/loss summary for the given period. +pub async fn cmd_ipo_profit_loss(period: &str, format: &OutputFormat, verbose: bool) -> Result<()> { + let account_channel = crate::auth::account_channel_or_default(); + let data = http_get( + "/portfolio/asset/ipo_analysis", + &[ + ("period", period), + ("account_channel", account_channel.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_json(&data), + } + Ok(()) +} + +/// List IPO profit/loss items for the given period. +pub async fn cmd_ipo_profit_loss_items( + 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 data = http_get( + "/portfolio/asset/ipo_analysis_sublist", + &[ + ("period", period), + ("page", page_str.as_str()), + ("size", size_str.as_str()), + ("account_channel", account_channel.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_json(&data), + } + Ok(()) +} + +/// Show IPO holding portfolio detail for a symbol. +pub async fn cmd_ipo_holdings(symbol: String, format: &OutputFormat, verbose: bool) -> Result<()> { + let account_channel = crate::auth::account_channel_or_default(); + let cid = symbol_to_counter_id(&symbol); + let data = http_get( + "/portfolio/ipo/detail", + &[ + ("counter_id", cid.as_str()), + ("need_realtime", "true"), + ("account_channel", account_channel.as_str()), + ], + verbose, + ) + .await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => print_json(&data), + } + Ok(()) +} + +// ── write IPO commands (require confirmation) ────────────────────────────────── + +/// Submit an IPO subscription order. +pub async fn cmd_ipo_submit( + symbol: String, + qty: String, + amount: String, + financing_amount: String, + method: u8, + financing_interest: String, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let account_channel = crate::auth::account_channel_or_default(); + let cid = symbol_to_counter_id(&symbol); + if !confirm_action(&format!("submit IPO subscription for {symbol}"))? { + println!("Cancelled."); + return Ok(()); + } + let body = serde_json::json!({ + "counter_id": cid, + "sub_qty": qty, + "sub_amount": amount, + "financing_amount": financing_amount, + "method": method, + "financing_interest": financing_interest, + "account_channel": account_channel, + }); + let data = http_post("/ipo/submit", body, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + println!("IPO subscription submitted."); + print_json(&data); + } + } + Ok(()) +} + +/// Withdraw an IPO subscription order by order ID. +pub async fn cmd_ipo_withdraw( + order_id: String, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + let account_channel = crate::auth::account_channel_or_default(); + if !confirm_action(&format!("withdraw IPO order {order_id}"))? { + println!("Cancelled."); + return Ok(()); + } + let body = serde_json::json!({ + "order_id": order_id, + "account_channel": account_channel, + }); + let data = http_post("/ipo/withdraw", body, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + println!("IPO order withdrawn."); + print_json(&data); + } + } + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9297830..6de31ee 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,6 +3,7 @@ use clap::{Parser, Subcommand, ValueEnum}; pub mod api; pub mod asset; +pub mod atm; pub mod auth; pub mod check; pub mod completion; @@ -11,11 +12,13 @@ pub mod fundamental; pub mod init; pub mod insider_trades; pub mod investors; +pub mod ipo; 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; @@ -382,6 +385,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 @@ -397,6 +403,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 @@ -1036,6 +1054,155 @@ 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 = "ALL")] + 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 + #[arg(long)] + start: String, + /// End date YYYYMMDD + #[arg(long)] + end: String, + }, + + /// Analyst consensus estimates (EPS, revenue, ratings) for a symbol + /// + /// Example: longbridge analyst-estimates TSLA.US + /// Example: longbridge analyst-estimates 700.HK --format json + AnalystEstimates { + /// Symbol in . format + symbol: String, + }, + + // ── Asset (new) ────────────────────────────────────────────────────────── + /// Short-selling margin deposit details for the current account + /// + /// Example: longbridge short-margin + ShortMargin, + + /// Daily profit/loss calendar for the current account + /// + /// Example: longbridge pnl-calendar + PnlCalendar, + + /// Stock holding period breakdown for a symbol + /// + /// Example: longbridge holding-period TSLA.US + HoldingPeriod { + /// Symbol in . format + symbol: String, + }, + + /// Trade-order detail and cash snapshot for a symbol (order entry page) + /// + /// Example: longbridge trade-info TSLA.US + TradeInfo { + /// Symbol in . format + symbol: String, + }, + + /// Account-level order trade analysis / statistics + /// + /// Example: longbridge order-stats + #[command(name = "order-stats")] + OrderStats, + + // ── ATM (new) ──────────────────────────────────────────────────────────── + /// List withdrawal bank cards for the current account + /// + /// Example: longbridge withdrawal-cards + WithdrawalCards, + + /// 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, default_value = "20")] + limit: 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, default_value = "20")] + limit: 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) ───────────────────────────────────────────────────────── + /// Search across market, news, posts, hashtags, help, share-lists, users, and institutions + /// + /// Example: longbridge search TSLA + /// Example: longbridge search "AI stocks" --tab news + /// Example: longbridge search TSLA --tab market --market US + Search { + /// Search keyword + keyword: String, + /// Search tab: market | news | posts | hashtags | help | share-lists | users | institutions + #[arg(long, default_value = "market")] + tab: String, + /// Market filter for --tab market: US | HK | SG + #[arg(long)] + market: Option, + /// Product filter for --tab market (comma-separated: BK,ETF,ST,…) + #[arg(long)] + product: Option, + /// Maximum results to display (default: 20) + #[arg(long, default_value = "20")] + limit: usize, + }, + + /// Show hot search keywords + /// + /// Example: longbridge search-hot + SearchHot, + + // ── IPO (new) ──────────────────────────────────────────────────────────── + /// IPO (new listings) commands — subscriptions, calendar, orders, profit/loss + /// + /// Example: longbridge ipo subscriptions + /// Example: longbridge ipo calendar + /// Example: longbridge ipo submit TSLA.US --qty 200 --amount 1000 --financing-amount 0 --method 2 --financing-interest 0 + Ipo { + #[command(subcommand)] + cmd: IpoCmd, + }, } #[derive(Subcommand)] @@ -1418,6 +1585,99 @@ pub enum InstitutionRatingCmd { }, } +#[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, default_value = "20")] + limit: u32, + }, + /// Show the IPO calendar (all upcoming and recent IPOs) + Calendar, + /// Show IPO subscription page information for a symbol + Info { symbol: String }, + /// Show IPO profile (prospectus summary) for a symbol + Profile { symbol: String }, + /// Show IPO timeline for a symbol + Timeline { + symbol: String, + /// Market, e.g. HK + #[arg(long, default_value = "HK")] + market: String, + /// Flag: 0=normal, 2=international placement + #[arg(long, default_value = "0")] + flag: u8, + }, + /// Show the current active IPO order status for a symbol + Order { symbol: String }, + /// List active IPO holding orders for the current account + Orders { symbol: Option }, + /// Show IPO order detail by order ID + #[command(name = "order-detail")] + OrderDetail { order_id: String }, + /// List IPO subscription history + History { + #[arg(long)] + market: Option, + /// Status: 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, default_value = "10")] + limit: u32, + }, + /// Check if the current user is eligible to subscribe to an IPO + Eligibility { symbol: String }, + /// Show IPO profit/loss summary for a period + #[command(name = "profit-loss")] + ProfitLoss { + /// Period: 1m | 3m | 6m | 1y | all + #[arg(long, default_value = "all")] + period: String, + }, + /// List IPO profit/loss items for a period + #[command(name = "profit-loss-items")] + ProfitLossItems { + /// Period: 1m | 3m | 6m | 1y | all + #[arg(long, default_value = "all")] + period: String, + #[arg(long, default_value = "1")] + page: u32, + #[arg(long, default_value = "20")] + limit: u32, + }, + /// Show IPO holding portfolio detail for a symbol + Holdings { symbol: String }, + /// Submit an IPO subscription order (requires confirmation) + Submit { + symbol: String, + /// Number of shares to subscribe + #[arg(long)] + qty: String, + /// Subscription amount + #[arg(long)] + amount: String, + /// Financing amount (0 for cash subscription) + #[arg(long, default_value = "0")] + financing_amount: String, + /// Subscription method: 1=financing, 2=cash + #[arg(long, default_value = "2")] + method: u8, + /// Financing daily interest (0 for cash subscription) + #[arg(long, default_value = "0")] + financing_interest: String, + }, + /// Withdraw an IPO subscription order (requires confirmation) + Withdraw { order_id: String }, +} + #[derive(Subcommand)] pub enum WatchlistCmd { /// Show securities in a specific watchlist group (by ID or name) @@ -2304,20 +2564,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 @@ -2751,6 +3046,125 @@ 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, &end, format, verbose).await + } + Commands::AnalystEstimates { symbol } => { + fundamental::cmd_analyst_estimates(symbol, format, verbose).await + } + + Commands::ShortMargin => asset::cmd_short_margin(format, verbose).await, + Commands::PnlCalendar => asset::cmd_pnl_calendar(format, verbose).await, + Commands::HoldingPeriod { symbol } => { + asset::cmd_holding_period(symbol, format, verbose).await + } + Commands::TradeInfo { symbol } => asset::cmd_trade_info(symbol, format, verbose).await, + Commands::OrderStats => asset::cmd_order_stats(format, verbose).await, + + Commands::WithdrawalCards => atm::cmd_withdrawal_cards(format, verbose).await, + Commands::Withdrawals { page, limit } => { + atm::cmd_withdrawals(page, limit, format, verbose).await + } + Commands::Deposits { + page, + limit, + states, + currencies, + } => { + atm::cmd_deposits( + page, + limit, + states.as_deref(), + currencies.as_deref(), + format, + verbose, + ) + .await + } + + Commands::Search { + keyword, + tab, + market, + product, + limit, + } => { + search::cmd_search( + keyword, + &tab, + market.as_deref(), + product.as_deref(), + limit, + format, + verbose, + ) + .await + } + Commands::SearchHot => search::cmd_search_hot(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, limit } => { + ipo::cmd_ipo_listed(page, limit, format, verbose).await + } + IpoCmd::Calendar => ipo::cmd_ipo_calendar(format, verbose).await, + IpoCmd::Info { symbol } => ipo::cmd_ipo_info(symbol, format, verbose).await, + IpoCmd::Profile { symbol } => ipo::cmd_ipo_profile(symbol, format, verbose).await, + IpoCmd::Timeline { + symbol, + market, + flag, + } => ipo::cmd_ipo_timeline(symbol, &market, flag, format, verbose).await, + IpoCmd::Order { symbol } => ipo::cmd_ipo_order(symbol, format, verbose).await, + IpoCmd::Orders { symbol } => ipo::cmd_ipo_orders(symbol, format, verbose).await, + IpoCmd::OrderDetail { order_id } => { + ipo::cmd_ipo_order_detail(order_id, format, verbose).await + } + IpoCmd::History { + market, + status, + page, + limit, + } => ipo::cmd_ipo_history(market, status, page, limit, format, verbose).await, + IpoCmd::Eligibility { symbol } => { + ipo::cmd_ipo_eligibility(symbol, format, verbose).await + } + IpoCmd::ProfitLoss { period } => { + ipo::cmd_ipo_profit_loss(&period, format, verbose).await + } + IpoCmd::ProfitLossItems { period, page, limit } => { + ipo::cmd_ipo_profit_loss_items(&period, page, limit, format, verbose).await + } + IpoCmd::Holdings { symbol } => ipo::cmd_ipo_holdings(symbol, format, verbose).await, + IpoCmd::Submit { + symbol, + qty, + amount, + financing_amount, + method, + financing_interest, + } => { + ipo::cmd_ipo_submit( + symbol, + qty, + amount, + financing_amount, + method, + financing_interest, + format, + verbose, + ) + .await + } + IpoCmd::Withdraw { order_id } => { + ipo::cmd_ipo_withdraw(order_id, format, verbose).await + } + }, + Commands::Auth { .. } | Commands::Tui | Commands::Check diff --git a/src/cli/quant_render.rs b/src/cli/quant_render.rs index c330bac..9c45250 100644 --- a/src/cli/quant_render.rs +++ b/src/cli/quant_render.rs @@ -20,7 +20,6 @@ const SERIES_COLORS: &[Color] = &[ Color::DarkGrey, ]; - // ── Terminal helpers ────────────────────────────────────────────────────────── fn term_width() -> usize { diff --git a/src/cli/search.rs b/src/cli/search.rs new file mode 100644 index 0000000..a9c6ad1 --- /dev/null +++ b/src/cli/search.rs @@ -0,0 +1,315 @@ +use anyhow::Result; +use serde_json::{json, Value}; + +use super::api::{http_get, http_post}; +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 => String::new(), + other => other.to_string(), + } +} + +// ── search ──────────────────────────────────────────────────────────────────── + +/// Search across securities, news, community, help, and more. +pub async fn cmd_search( + keyword: String, + tab: &str, + market: Option<&str>, + product: Option<&str>, + limit: usize, + format: &OutputFormat, + verbose: bool, +) -> Result<()> { + match tab { + "market" => { + let mut params: Vec<(&str, &str)> = vec![("k", keyword.as_str())]; + if let Some(m) = market { + params.push(("market", m)); + } + if let Some(p) = product { + params.push(("product", p)); + } + let data = http_get("/v4/search", ¶ms, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(list) = data["product_list"].as_array() { + let items: Vec<&Value> = list.iter().take(limit).collect(); + if items.is_empty() { + println!("No results."); + return Ok(()); + } + let headers = ["symbol", "name", "market", "type"]; + let rows: Vec> = items + .iter() + .map(|item| { + vec![ + val_str(&item["code"]), + val_str(&item["name"]), + val_str(&item["market"]), + val_str(&item["type"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + } + "news" => { + let data = http_get("/v1/news_search", &[("k", keyword.as_str())], verbose).await?; + match format { + OutputFormat::Json => 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"]), + val_str(&item["title"]), + val_str(&item["publish_at"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + } + "posts" => { + let body = json!({ "k": keyword }); + let data = http_post("/v1/search/social_topics", body, verbose).await?; + match format { + OutputFormat::Json => 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 post results."); + return Ok(()); + } + let headers = ["id", "author", "excerpt"]; + let rows: Vec> = items + .iter() + .map(|item| { + let excerpt: String = + val_str(&item["content"]).chars().take(60).collect(); + vec![val_str(&item["id"]), val_str(&item["author_name"]), excerpt] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + } + "hashtags" => { + let body = json!({ "k": keyword }); + let data = http_post("/v2/search/hashtag", body, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(list) = data["hashtag_list"].as_array() { + let items: Vec<&Value> = list.iter().take(limit).collect(); + if items.is_empty() { + println!("No hashtag results."); + return Ok(()); + } + let headers = ["id", "name", "topic_count"]; + let rows: Vec> = items + .iter() + .map(|item| { + vec![ + val_str(&item["id"]), + val_str(&item["name"]), + val_str(&item["topic_count"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + } + "help" => { + let limit_str = limit.to_string(); + let body = json!({ "k": keyword, "limit": limit_str }); + let data = http_post("/v1/helpcenter/search_main", body, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(list) = data["help_topic_list"].as_array() { + if list.is_empty() { + println!("No help results."); + return Ok(()); + } + let headers = ["id", "title"]; + let rows: Vec> = list + .iter() + .take(limit) + .map(|item| vec![val_str(&item["id"]), val_str(&item["title"])]) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + } + "share-lists" => { + let body = json!({ "k": keyword, "id": "" }); + let data = http_post("/v1/search/share_lists", body, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(list) = data["share_list"].as_array() { + let items: Vec<&Value> = list.iter().take(limit).collect(); + if items.is_empty() { + println!("No share-list results."); + return Ok(()); + } + let headers = ["id", "name", "author", "stock_count"]; + let rows: Vec> = items + .iter() + .map(|item| { + vec![ + val_str(&item["id"]), + val_str(&item["name"]), + val_str(&item["author_name"]), + val_str(&item["stock_count"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + } + "users" => { + let body = json!({ "k": keyword, "id": "" }); + let data = http_post("/v1/search/social_users", body, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(list) = data["user_list"].as_array() { + let items: Vec<&Value> = list.iter().take(limit).collect(); + if items.is_empty() { + println!("No user results."); + return Ok(()); + } + let headers = ["id", "name", "followers"]; + let rows: Vec> = items + .iter() + .map(|item| { + vec![ + val_str(&item["id"]), + val_str(&item["name"]), + val_str(&item["followers_count"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + } + "institutions" => { + let body = json!({ "k": keyword }); + let data = http_post("/v1/search/social_institutions", body, verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + if let Some(list) = data["institution_list"].as_array() { + let items: Vec<&Value> = list.iter().take(limit).collect(); + if items.is_empty() { + println!("No institution results."); + return Ok(()); + } + let headers = ["id", "name", "followers"]; + let rows: Vec> = items + .iter() + .map(|item| { + vec![ + val_str(&item["id"]), + val_str(&item["name"]), + val_str(&item["followers_count"]), + ] + }) + .collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + } else { + print_json(&data); + } + } + } + } + _ => unreachable!(), + } + Ok(()) +} + +// ── search hot words ────────────────────────────────────────────────────────── + +/// Fetch hot search words. +pub async fn cmd_search_hot(format: &OutputFormat, verbose: bool) -> Result<()> { + let data = http_get("/search/gethotwords", &[], verbose).await?; + match format { + OutputFormat::Json => print_json(&data), + OutputFormat::Pretty => { + // Try common response shapes for hot words + let list = data["hot_words"] + .as_array() + .or_else(|| data["list"].as_array()) + .or_else(|| data["words"].as_array()); + if let Some(words) = list { + if words.is_empty() { + println!("No hot words."); + return Ok(()); + } + for (i, w) in words.iter().enumerate() { + let word = if w.is_string() { + val_str(w) + } else { + val_str(&w["word"]) + }; + println!("{}. {word}", i + 1); + } + } else { + print_json(&data); + } + } + } + Ok(()) +} diff --git a/src/tui/systems/orders.rs b/src/tui/systems/orders.rs index ac3666c..7909e29 100644 --- a/src/tui/systems/orders.rs +++ b/src/tui/systems/orders.rs @@ -11,7 +11,10 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table}, + widgets::{ + Block, Borders, Cell, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, + ScrollbarState, Table, + }, Frame, }; use rust_decimal::Decimal; @@ -250,7 +253,10 @@ pub fn open_date_filter() { pub fn apply_date_filter() { let (start, end) = { let s = DATE_FILTER_STATE.read().expect("poison"); - (s.start_input.value().to_string(), s.end_input.value().to_string()) + ( + s.start_input.value().to_string(), + s.end_input.value().to_string(), + ) }; { let mut range = HISTORY_DATE_RANGE.write().expect("poison"); @@ -806,11 +812,23 @@ pub fn render_orders( if is_history { let mut table = HISTORY_ORDERS_TABLE.lock().expect("poison"); let cur = table.selected(); - table.select(Some(cur.map_or(0, |i| if i + 1 < orders_len { i + 1 } else { i }))); + table.select(Some(cur.map_or(0, |i| { + if i + 1 < orders_len { + i + 1 + } else { + i + } + }))); } else { let mut table = ORDERS_TABLE.lock().expect("poison"); let cur = table.selected(); - table.select(Some(cur.map_or(0, |i| if i + 1 < orders_len { i + 1 } else { i }))); + table.select(Some(cur.map_or(0, |i| { + if i + 1 < orders_len { + i + 1 + } else { + i + } + }))); } } } @@ -1018,8 +1036,7 @@ fn render_orders_list(frame: &mut Frame, rect: Rect) { preferred.clamp(6, 8) // min 3 data rows, max 5 data rows }; let [today_rect, history_rect] = - Layout::vertical([Constraint::Length(today_height), Constraint::Min(4)]) - .areas(rect); + Layout::vertical([Constraint::Length(today_height), Constraint::Min(4)]).areas(rect); // Today table title let today_title = if today_orders.is_empty() { @@ -1051,19 +1068,17 @@ fn render_orders_list(frame: &mut Frame, rect: Rect) { let bottom_hints = Line::from(vec![ Span::styled(format!(" {} ", t!("Orders.Refresh")), styles::dark_gray()), Span::styled(format!(" {} ", t!("Orders.CancelKey")), styles::dark_gray()), - Span::styled(format!(" {} ", t!("Orders.ReplaceKey")), styles::dark_gray()), + Span::styled( + format!(" {} ", t!("Orders.ReplaceKey")), + styles::dark_gray(), + ), Span::styled(format!(" {} ", t!("Orders.FilterKey")), styles::dark_gray()), Span::styled(format!(" {} ", t!("Orders.TabSwitch")), styles::dark_gray()), ]) .right_aligned(); - let (today_table, today_has_rows) = make_orders_table( - today_orders, - false, - !is_history_active, - today_title, - None, - ); + let (today_table, today_has_rows) = + make_orders_table(today_orders, false, !is_history_active, today_title, None); let (history_table, history_has_rows) = make_orders_table( history_orders, true, @@ -1081,10 +1096,18 @@ fn render_orders_list(frame: &mut Frame, rect: Rect) { } else { frame.render_stateful_widget(today_table, today_rect, &mut *today_state); } - let inner = today_rect.inner(Margin { horizontal: 1, vertical: 1 }); - let scrollbar_area = Rect { x: inner.x + inner.width, y: inner.y, width: 1, height: inner.height }; - let mut sb = ScrollbarState::new(today_orders.len()) - .position(today_state.selected().unwrap_or(0)); + let inner = today_rect.inner(Margin { + horizontal: 1, + vertical: 1, + }); + let scrollbar_area = Rect { + x: inner.x + inner.width, + y: inner.y, + width: 1, + height: inner.height, + }; + let mut sb = + ScrollbarState::new(today_orders.len()).position(today_state.selected().unwrap_or(0)); frame.render_stateful_widget( Scrollbar::new(ScrollbarOrientation::VerticalRight) .begin_symbol(None) @@ -1104,8 +1127,16 @@ fn render_orders_list(frame: &mut Frame, rect: Rect) { } else { frame.render_stateful_widget(history_table, history_rect, &mut TableState::default()); } - let inner = history_rect.inner(Margin { horizontal: 1, vertical: 1 }); - let scrollbar_area = Rect { x: inner.x + inner.width, y: inner.y, width: 1, height: inner.height }; + let inner = history_rect.inner(Margin { + horizontal: 1, + vertical: 1, + }); + let scrollbar_area = Rect { + x: inner.x + inner.width, + y: inner.y, + width: 1, + height: inner.height, + }; let mut sb = ScrollbarState::new(history_orders.len()) .position(history_state.selected().unwrap_or(0)); frame.render_stateful_widget( From 7f2828008da7563e41d16274dc7608274b73ed9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Thu, 7 May 2026 15:49:24 +0800 Subject: [PATCH 02/44] fix(ipo): resolve anyhow::Error type mismatch in member_id helper `crate::openapi::quote().member_id()` returns `Result`, but the helper declared `Result` (i.e. `anyhow::Result`). Add `.map_err(anyhow::Error::from)` to convert the error type explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/ipo.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index 3fe3c47..ae78ce1 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -33,7 +33,10 @@ fn confirm_action(action: &str) -> Result { } async fn member_id() -> Result { - crate::openapi::quote().member_id().await + crate::openapi::quote() + .member_id() + .await + .map_err(anyhow::Error::from) } // ── read-only IPO list commands ──────────────────────────────────────────────── From d224c359f472b8384af434d556c4cbf6f531cbc7 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 15:55:01 +0800 Subject: [PATCH 03/44] Update orders.rs --- src/tui/systems/orders.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tui/systems/orders.rs b/src/tui/systems/orders.rs index 27286d4..854395b 100644 --- a/src/tui/systems/orders.rs +++ b/src/tui/systems/orders.rs @@ -1044,6 +1044,11 @@ fn render_orders_list(frame: &mut Frame, rect: Rect) { let [today_rect, history_rect] = Layout::vertical([Constraint::Length(today_height), Constraint::Min(4)]).areas(rect); + *crate::tui::mouse::ORDERS_TABLE_RECT.lock().expect("poison") = today_rect; + *crate::tui::mouse::HISTORY_ORDERS_TABLE_RECT + .lock() + .expect("poison") = history_rect; + // Today table title let today_title = if today_orders.is_empty() { format!(" {} ", t!("Orders.TodayTab")) From b1cd052cfa9e24b3b5fd8686118901d3b1e77257 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 15:55:26 +0800 Subject: [PATCH 04/44] Update auth.rs --- src/auth.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 963b3a0..ea61173 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -438,12 +438,6 @@ pub async fn auth_code_login() -> Result<()> { } } -/// Return the account channel for the current session, defaulting to "lb". -/// -/// PSPL Singapore users can set `LONGBRIDGE_ACCOUNT_CHANNEL=pspl_sg` to override. -pub fn account_channel_or_default() -> String { - std::env::var("LONGBRIDGE_ACCOUNT_CHANNEL").unwrap_or_else(|_| "lb".to_string()) -} /// Clear the stored OAuth token (logout). Deletes the token file used by the longbridge SDK. pub fn clear_token() -> Result<()> { From 2d6d225b684ffef8a7d8b7c8b2cab78b54c08be3 Mon Sep 17 00:00:00 2001 From: Endless Agent Date: Thu, 7 May 2026 05:37:37 +0000 Subject: [PATCH 05/44] feat(final): task #14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目标:基于 `docs/api-reference.md` 中的接口,为 `longbridge-terminal` CLI 开发对应的子命令, commit 和 pr 都使用英文 --- ## 🤖 Generated with Endless Co-authored-by: 老袁 Yuan Zhanghong --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++ src/cli/search.rs | 4 +++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 06d48f7..fd7d360 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,60 @@ 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 for a date range +longbridge analyst-estimates AAPL.US # Multi-dimensional analyst consensus 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 ``` +### Account Assets + +```bash +longbridge short-margin # Short-selling margin deposit details +longbridge pnl-calendar # Daily P&L calendar for the current account +longbridge holding-period TSLA.US # Holding period breakdown for a stock position +longbridge trade-info TSLA.US # Pre-trade position and cash snapshot for a symbol +longbridge order-stats # Account-level trade analysis and statistics +``` + +### Deposits & Withdrawals + +```bash +longbridge withdrawal-cards # List linked bank cards for withdrawals +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 info TSLA.US # IPO subscription page info for a symbol +longbridge ipo profile TSLA.US # IPO prospectus profile for a symbol +longbridge ipo timeline TSLA.US [--market HK] [--flag 0] # IPO timeline for a symbol +longbridge ipo order TSLA.US # Current active IPO order status for a symbol +longbridge ipo orders [TSLA.US] # Active IPO holding orders for the current account +longbridge ipo order-detail # IPO order detail by order ID +longbridge ipo history [--market HK] [--status 0] [--page 1] # IPO subscription history +longbridge ipo eligibility TSLA.US # Check subscription eligibility for a symbol +longbridge ipo profit-loss [--period all|1m|3m|6m|1y] # IPO P&L summary +longbridge ipo profit-loss-items [--period all] [--page 1] # IPO P&L item list +longbridge ipo holdings TSLA.US # IPO holding portfolio detail for a symbol +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 diff --git a/src/cli/search.rs b/src/cli/search.rs index a9c6ad1..b60a8af 100644 --- a/src/cli/search.rs +++ b/src/cli/search.rs @@ -275,7 +275,9 @@ pub async fn cmd_search( } } } - _ => unreachable!(), + _ => anyhow::bail!( + "Unknown --tab value: {tab}. Valid: market, news, posts, hashtags, help, share-lists, users, institutions" + ), } Ok(()) } From aee0e3c85b0f0912a9b0be7d943ed75f29470deb Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 15:56:12 +0800 Subject: [PATCH 06/44] cli: Add search, IPO, financial-statement, asset, and ATM commands - Fix all new command API paths to use OpenAPI paths (/v1/...) - Remove delete-marked features: pnl-calendar, ipo submit/withdraw - Move search under news/topic subcommands (news search, topic search) - Fix val_str null handling to return "-" consistently - Add fmt_ts() for RFC 3339 timestamp formatting of created_at fields - Fix Pretty mode for single-object commands to use print_json_value - Add empty-array guard in institution-rating --history Pretty output Co-Authored-By: Claude Sonnet 4.6 --- src/cli/asset.rs | 50 ++------- src/cli/atm.rs | 21 ++-- src/cli/fundamental.rs | 35 ++++-- src/cli/ipo.rs | 144 +++++++------------------ src/cli/mod.rs | 134 ++++++----------------- src/cli/search.rs | 240 ++--------------------------------------- 6 files changed, 129 insertions(+), 495 deletions(-) diff --git a/src/cli/asset.rs b/src/cli/asset.rs index 9816c13..003f004 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) { @@ -501,7 +503,7 @@ pub async fn cmd_profit_analysis_by_market( // ── short margin ───────────────────────────────────────────────────────────── pub async fn cmd_short_margin(format: &OutputFormat, verbose: bool) -> Result<()> { - let data = http_get("/v1/asset/cash/short/detail", &[], verbose).await?; + let data = http_get("/v1/asset/cash/short-margin", &[], verbose).await?; match format { OutputFormat::Json => print_json(&data), OutputFormat::Pretty => { @@ -530,38 +532,6 @@ pub async fn cmd_short_margin(format: &OutputFormat, verbose: bool) -> Result<() Ok(()) } -// ── pnl calendar ───────────────────────────────────────────────────────────── - -pub async fn cmd_pnl_calendar(format: &OutputFormat, verbose: bool) -> Result<()> { - let data = http_get("/portfolio/pnl/calendar", &[], verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => { - if let Some(list) = data["calendar_list"].as_array() { - if list.is_empty() { - println!("No P&L calendar data."); - return Ok(()); - } - let headers = ["date", "pnl", "currency"]; - let rows: Vec> = list - .iter() - .map(|item| { - vec![ - val_str(&item["date"]), - val_str(&item["pnl"]), - val_str(&item["currency"]), - ] - }) - .collect(); - print_table(&headers, rows, &OutputFormat::Pretty); - } else { - print_json(&data); - } - } - } - Ok(()) -} - // ── holding period ──────────────────────────────────────────────────────────── pub async fn cmd_holding_period( @@ -571,14 +541,14 @@ pub async fn cmd_holding_period( ) -> Result<()> { let cid = crate::utils::counter::symbol_to_counter_id(&symbol); let data = http_get( - "/portfolio/asset/stock_holding_period", + "/v1/asset/positions/holding-period", &[("counter_id", cid.as_str())], verbose, ) .await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json(&data), + OutputFormat::Pretty => print_json_value(&data, format), } Ok(()) } @@ -588,14 +558,14 @@ pub async fn cmd_holding_period( 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/portfolio/asset/trade/detail", + "/v1/asset/positions/trade-info", &[("counter_id", cid.as_str())], verbose, ) .await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json(&data), + OutputFormat::Pretty => print_json_value(&data, format), } Ok(()) } @@ -603,10 +573,10 @@ pub async fn cmd_trade_info(symbol: String, format: &OutputFormat, verbose: bool // ── order stats (today's account trade summary) ─────────────────────────────── pub async fn cmd_order_stats(format: &OutputFormat, verbose: bool) -> Result<()> { - let data = http_get("/v1/orders/trade_analysis", &[], verbose).await?; + let data = http_get("/v1/asset/trade-analysis", &[], verbose).await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json(&data), + OutputFormat::Pretty => print_json_value(&data, format), } Ok(()) } diff --git a/src/cli/atm.rs b/src/cli/atm.rs index 9dfd7ac..14b1a9f 100644 --- a/src/cli/atm.rs +++ b/src/cli/atm.rs @@ -17,16 +17,25 @@ fn val_str(v: &Value) -> String { Value::String(s) => s.clone(), Value::Number(n) => n.to_string(), Value::Bool(b) => b.to_string(), - Value::Null => String::new(), + 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) +} + // ── withdrawal cards ────────────────────────────────────────────────────────── /// List withdrawal bank cards for the current user. pub async fn cmd_withdrawal_cards(format: &OutputFormat, verbose: bool) -> Result<()> { - let data = http_get("/v3/portfolio/withdraw/cards", &[], verbose).await?; + let data = http_get("/v1/account/bank-cards", &[], verbose).await?; match format { OutputFormat::Json => print_json(&data), OutputFormat::Pretty => { @@ -69,7 +78,7 @@ pub async fn cmd_withdrawals( let page_str = page.to_string(); let size_str = limit.to_string(); let data = http_get( - "/v6/portfolio/withdraw/record", + "/v1/account/withdrawals", &[ ("page", page_str.as_str()), ("size", size_str.as_str()), @@ -95,7 +104,7 @@ pub async fn cmd_withdrawals( .iter() .map(|item| { vec![ - val_str(&item["created_at"]), + fmt_ts(&item["created_at"]), val_str(&item["amount"]), val_str(&item["currency"]), val_str(&item["status"]), @@ -137,7 +146,7 @@ pub async fn cmd_deposits( if let Some(c) = currencies { params.push(("currencies", c)); } - let data = http_get("/v5/portfolio/deposit/notify", ¶ms, verbose).await?; + let data = http_get("/v1/account/deposits", ¶ms, verbose).await?; match format { OutputFormat::Json => print_json(&data), OutputFormat::Pretty => { @@ -161,7 +170,7 @@ pub async fn cmd_deposits( s => s.to_string(), }; vec![ - val_str(&item["created_at"]), + fmt_ts(&item["created_at"]), val_str(&item["amount"]), val_str(&item["currency"]), state, diff --git a/src/cli/fundamental.rs b/src/cli/fundamental.rs index eff79b2..5953722 100644 --- a/src/cli/fundamental.rs +++ b/src/cli/fundamental.rs @@ -1984,7 +1984,7 @@ pub async fn cmd_financial_statement( ) -> Result<()> { let cid = symbol_to_counter_id(&symbol); let data = http_get( - "/v3/stock-info/statement", + "/v1/quote/financials/statements", &[ ("counter_id", cid.as_str()), ("kind", kind), @@ -2009,7 +2009,7 @@ pub async fn cmd_financial_report_latest( ) -> Result<()> { let cid = symbol_to_counter_id(&symbol); let data = http_get( - "/v4/stock-info/latest-financial-report", + "/v1/quote/financials/latest-report", &[("counter_id", cid.as_str())], verbose, ) @@ -2032,7 +2032,7 @@ pub async fn cmd_valuation_rank( ) -> Result<()> { let cid = symbol_to_counter_id(&symbol); let data = http_get( - "/stock-info/valuation-rank", + "/v1/quote/valuation/rank", &[ ("counter_id", cid.as_str()), ("start_date", start), @@ -2057,7 +2057,7 @@ pub async fn cmd_analyst_estimates( ) -> Result<()> { let cid = symbol_to_counter_id(&symbol); let data = http_get( - "/stock-info/estimate", + "/v1/quote/estimates", &[("counter_id", cid.as_str())], verbose, ) @@ -2078,7 +2078,7 @@ pub async fn cmd_institution_rating_history( ) -> Result<()> { let cid = symbol_to_counter_id(&symbol); let data = http_get( - "/stock-info/recommendation/history", + "/v1/quote/ratings/history", &[("counter_id", cid.as_str())], verbose, ) @@ -2087,12 +2087,27 @@ pub async fn cmd_institution_rating_history( OutputFormat::Json => print_json(&data), OutputFormat::Pretty => { if let Some(target) = data.get("target_history") { - println!("Target price history:"); - print_kv(target); + if !target.as_array().map_or(true, |a| a.is_empty()) { + println!("Target price history:"); + print_kv(target); + } } if let Some(eval) = data.get("evaluate_history") { - println!("\nRating history:"); - print_kv(eval); + if !eval.as_array().map_or(true, |a| a.is_empty()) { + println!("\nRating history:"); + print_kv(eval); + } + } + if data + .get("target_history") + .and_then(|v| v.as_array()) + .map_or(true, |a| a.is_empty()) + && data + .get("evaluate_history") + .and_then(|v| v.as_array()) + .map_or(true, |a| a.is_empty()) + { + println!("No rating history found."); } } } @@ -2112,7 +2127,7 @@ pub async fn cmd_institution_rating_industry_rank( let page_str = page.to_string(); let size_str = limit.to_string(); let data = http_get( - "/v2/fa/institution-rating-industry-rank", + "/v1/quote/institution-ratings/industry-rank", &[ ("counter_id", cid.as_str()), ("page", page_str.as_str()), diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index ae78ce1..f921914 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -1,9 +1,8 @@ use anyhow::Result; use serde_json::Value; -use std::io::Write; -use super::api::{http_get, http_post}; -use super::output::print_table; +use super::api::http_get; +use super::output::{print_json_value, print_table}; use super::OutputFormat; use crate::utils::counter::symbol_to_counter_id; @@ -19,17 +18,18 @@ fn val_str(v: &Value) -> String { Value::String(s) => s.clone(), Value::Number(n) => n.to_string(), Value::Bool(b) => b.to_string(), - Value::Null => String::new(), + Value::Null => "-".to_string(), other => other.to_string(), } } -fn confirm_action(action: &str) -> Result { - print!("Are you sure you want to {action}? [y/N] "); - std::io::stdout().flush()?; - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - Ok(input.trim().eq_ignore_ascii_case("y")) +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) } async fn member_id() -> Result { @@ -46,7 +46,7 @@ pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Resu let mid = member_id().await?; let mid_str = mid.to_string(); let data = http_get( - "/newmarket/hk/ipo/subscribing", + "/v1/ipo/subscriptions", &[("memebr_id", mid_str.as_str())], verbose, ) @@ -101,7 +101,7 @@ pub async fn cmd_ipo_wait_listing(format: &OutputFormat, verbose: bool) -> Resul let mid_str = mid.to_string(); let day_str = now.to_string(); let data = http_get( - "/newmarket/hk/ipo/wait_listing", + "/v1/ipo/wait-listing", &[ ("day_time", day_str.as_str()), ("memebr_id", mid_str.as_str()), @@ -150,7 +150,7 @@ pub async fn cmd_ipo_listed( let page_str = page.to_string(); let size_str = limit.to_string(); let data = http_get( - "/newmarket/hk/ipo/ipo_listing", + "/v1/ipo/listed", &[ ("page", page_str.as_str()), ("size", size_str.as_str()), @@ -190,7 +190,7 @@ pub async fn cmd_ipo_listed( /// Show the IPO calendar (all upcoming and recent IPOs). pub async fn cmd_ipo_calendar(format: &OutputFormat, verbose: bool) -> Result<()> { - let data = http_get("/newmarket/ipo/calendar", &[], verbose).await?; + let data = http_get("/v1/ipo/calendar", &[], verbose).await?; match format { OutputFormat::Json => print_json(&data), OutputFormat::Pretty => { @@ -225,7 +225,7 @@ pub async fn cmd_ipo_info(symbol: String, format: &OutputFormat, verbose: bool) let account_channel = crate::auth::account_channel_or_default(); let cid = symbol_to_counter_id(&symbol); let data = http_get( - "/v3/ipo/info", + "/v1/ipo/info", &[ ("counter_id", cid.as_str()), ("account_channel", account_channel.as_str()), @@ -235,7 +235,7 @@ pub async fn cmd_ipo_info(symbol: String, format: &OutputFormat, verbose: bool) .await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json(&data), + OutputFormat::Pretty => print_json_value(&data, format), } Ok(()) } @@ -243,15 +243,10 @@ pub async fn cmd_ipo_info(symbol: String, format: &OutputFormat, verbose: bool) /// Show IPO profile (prospectus summary) for a symbol. pub async fn cmd_ipo_profile(symbol: String, format: &OutputFormat, verbose: bool) -> Result<()> { let cid = symbol_to_counter_id(&symbol); - let data = http_get( - "/v1/stock-info/ipo-profile", - &[("counter_id", cid.as_str())], - verbose, - ) - .await?; + let data = http_get("/v1/ipo/profile", &[("counter_id", cid.as_str())], verbose).await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json(&data), + OutputFormat::Pretty => print_json_value(&data, format), } Ok(()) } @@ -267,7 +262,7 @@ pub async fn cmd_ipo_timeline( let cid = symbol_to_counter_id(&symbol); let flag_str = flag.to_string(); let data = http_get( - "/stock-info/ipo-timeline", + "/v1/ipo/timeline", &[ ("counter_id", cid.as_str()), ("market", market), @@ -304,14 +299,14 @@ pub async fn cmd_ipo_timeline( pub async fn cmd_ipo_order(symbol: String, format: &OutputFormat, verbose: bool) -> Result<()> { let cid = symbol_to_counter_id(&symbol); let data = http_get( - "/ipo/active_order", + "/v1/ipo/active-order", &[("counter_id", cid.as_str())], verbose, ) .await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json(&data), + OutputFormat::Pretty => print_json_value(&data, format), } Ok(()) } @@ -329,7 +324,7 @@ pub async fn cmd_ipo_orders( cid = symbol_to_counter_id(sym); params.push(("counter_id", cid.as_str())); } - let data = http_get("/ipo/holding", ¶ms, verbose).await?; + let data = http_get("/v1/ipo/orders", ¶ms, verbose).await?; match format { OutputFormat::Json => print_json(&data), OutputFormat::Pretty => { @@ -367,18 +362,16 @@ pub async fn cmd_ipo_order_detail( verbose: bool, ) -> Result<()> { let account_channel = crate::auth::account_channel_or_default(); + let path = format!("/v1/ipo/orders/{order_id}"); let data = http_get( - "/v1/ipo/detail", - &[ - ("order_id", order_id.as_str()), - ("account_channel", account_channel.as_str()), - ], + &path, + &[("account_channel", account_channel.as_str())], verbose, ) .await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json(&data), + OutputFormat::Pretty => print_json_value(&data, format), } Ok(()) } @@ -402,7 +395,7 @@ pub async fn cmd_ipo_history( if let Some(ref s) = status { params.push(("status", s.as_str())); } - let data = http_get("/ipo/history", ¶ms, verbose).await?; + let data = http_get("/v1/ipo/orders/history", ¶ms, verbose).await?; match format { OutputFormat::Json => print_json(&data), OutputFormat::Pretty => { @@ -422,7 +415,7 @@ pub async fn cmd_ipo_history( val_str(&o["sub_qty"]), val_str(&o["lot_win_qty"]), val_str(&o["status"]), - val_str(&o["created_at"]), + fmt_ts(&o["created_at"]), ] }) .collect(); @@ -443,14 +436,14 @@ pub async fn cmd_ipo_eligibility( ) -> Result<()> { let cid = symbol_to_counter_id(&symbol); let data = http_get( - "/v1/ipo/check_user_can_subscribe", + "/v1/ipo/eligibility", &[("counter_id", cid.as_str())], verbose, ) .await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json(&data), + OutputFormat::Pretty => print_json_value(&data, format), } Ok(()) } @@ -459,7 +452,7 @@ pub async fn cmd_ipo_eligibility( pub async fn cmd_ipo_profit_loss(period: &str, format: &OutputFormat, verbose: bool) -> Result<()> { let account_channel = crate::auth::account_channel_or_default(); let data = http_get( - "/portfolio/asset/ipo_analysis", + "/v1/ipo/profit-loss", &[ ("period", period), ("account_channel", account_channel.as_str()), @@ -469,7 +462,7 @@ pub async fn cmd_ipo_profit_loss(period: &str, format: &OutputFormat, verbose: b .await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json(&data), + OutputFormat::Pretty => print_json_value(&data, format), } Ok(()) } @@ -486,7 +479,7 @@ pub async fn cmd_ipo_profit_loss_items( let page_str = page.to_string(); let size_str = limit.to_string(); let data = http_get( - "/portfolio/asset/ipo_analysis_sublist", + "/v1/ipo/profit-loss/items", &[ ("period", period), ("page", page_str.as_str()), @@ -498,7 +491,7 @@ pub async fn cmd_ipo_profit_loss_items( .await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json(&data), + OutputFormat::Pretty => print_json_value(&data, format), } Ok(()) } @@ -508,7 +501,7 @@ pub async fn cmd_ipo_holdings(symbol: String, format: &OutputFormat, verbose: bo let account_channel = crate::auth::account_channel_or_default(); let cid = symbol_to_counter_id(&symbol); let data = http_get( - "/portfolio/ipo/detail", + "/v1/ipo/holdings", &[ ("counter_id", cid.as_str()), ("need_realtime", "true"), @@ -519,72 +512,7 @@ pub async fn cmd_ipo_holdings(symbol: String, format: &OutputFormat, verbose: bo .await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json(&data), - } - Ok(()) -} - -// ── write IPO commands (require confirmation) ────────────────────────────────── - -/// Submit an IPO subscription order. -pub async fn cmd_ipo_submit( - symbol: String, - qty: String, - amount: String, - financing_amount: String, - method: u8, - financing_interest: String, - format: &OutputFormat, - verbose: bool, -) -> Result<()> { - let account_channel = crate::auth::account_channel_or_default(); - let cid = symbol_to_counter_id(&symbol); - if !confirm_action(&format!("submit IPO subscription for {symbol}"))? { - println!("Cancelled."); - return Ok(()); - } - let body = serde_json::json!({ - "counter_id": cid, - "sub_qty": qty, - "sub_amount": amount, - "financing_amount": financing_amount, - "method": method, - "financing_interest": financing_interest, - "account_channel": account_channel, - }); - let data = http_post("/ipo/submit", body, verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => { - println!("IPO subscription submitted."); - print_json(&data); - } - } - Ok(()) -} - -/// Withdraw an IPO subscription order by order ID. -pub async fn cmd_ipo_withdraw( - order_id: String, - format: &OutputFormat, - verbose: bool, -) -> Result<()> { - let account_channel = crate::auth::account_channel_or_default(); - if !confirm_action(&format!("withdraw IPO order {order_id}"))? { - println!("Cancelled."); - return Ok(()); - } - let body = serde_json::json!({ - "order_id": order_id, - "account_channel": account_channel, - }); - let data = http_post("/ipo/withdraw", body, verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => { - println!("IPO order withdrawn."); - print_json(&data); - } + OutputFormat::Pretty => print_json_value(&data, format), } Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f8937b6..77dd0a1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -494,11 +494,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, @@ -529,11 +530,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, @@ -1080,11 +1082,6 @@ pub enum Commands { /// Example: longbridge short-margin ShortMargin, - /// Daily profit/loss calendar for the current account - /// - /// Example: longbridge pnl-calendar - PnlCalendar, - /// Stock holding period breakdown for a symbol /// /// Example: longbridge holding-period TSLA.US @@ -1146,39 +1143,11 @@ pub enum Commands { }, // ── Search (new) ───────────────────────────────────────────────────────── - /// Search across market, news, posts, hashtags, help, share-lists, users, and institutions - /// - /// Example: longbridge search TSLA - /// Example: longbridge search "AI stocks" --tab news - /// Example: longbridge search TSLA --tab market --market US - Search { - /// Search keyword - keyword: String, - /// Search tab: market | news | posts | hashtags | help | share-lists | users | institutions - #[arg(long, default_value = "market")] - tab: String, - /// Market filter for --tab market: US | HK | SG - #[arg(long)] - market: Option, - /// Product filter for --tab market (comma-separated: BK,ETF,ST,…) - #[arg(long)] - product: Option, - /// Maximum results to display (default: 20) - #[arg(long, default_value = "20")] - limit: usize, - }, - - /// Show hot search keywords - /// - /// Example: longbridge search-hot - SearchHot, - // ── IPO (new) ──────────────────────────────────────────────────────────── /// IPO (new listings) commands — subscriptions, calendar, orders, profit/loss /// /// Example: longbridge ipo subscriptions /// Example: longbridge ipo calendar - /// Example: longbridge ipo submit TSLA.US --qty 200 --amount 1000 --financing-amount 0 --method 2 --financing-interest 0 Ipo { #[command(subcommand)] cmd: IpoCmd, @@ -1712,27 +1681,6 @@ pub enum IpoCmd { }, /// Show IPO holding portfolio detail for a symbol Holdings { symbol: String }, - /// Submit an IPO subscription order (requires confirmation) - Submit { - symbol: String, - /// Number of shares to subscribe - #[arg(long)] - qty: String, - /// Subscription amount - #[arg(long)] - amount: String, - /// Financing amount (0 for cash subscription) - #[arg(long, default_value = "0")] - financing_amount: String, - /// Subscription method: 1=financing, 2=cash - #[arg(long, default_value = "2")] - method: u8, - /// Financing daily interest (0 for cash subscription) - #[arg(long, default_value = "0")] - financing_interest: String, - }, - /// Withdraw an IPO subscription order (requires confirmation) - Withdraw { order_id: String }, } #[derive(Subcommand)] @@ -2239,6 +2187,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 --limit 10 + Search { + /// Search keyword + keyword: String, + /// Maximum results to display (default: 20) + #[arg(long, default_value = "20")] + limit: usize, + }, } #[derive(Subcommand)] @@ -2350,6 +2310,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" --limit 10 + Search { + /// Search keyword + keyword: String, + /// Maximum results to display (default: 20) + #[arg(long, default_value = "20")] + limit: usize, + }, } #[derive(Subcommand)] @@ -2728,6 +2700,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, limit }) => { + search::cmd_search(keyword, "news", limit, format, verbose).await + } None => { let sym = symbol.ok_or_else(|| { anyhow::anyhow!("Symbol required. Usage: longbridge news ") @@ -2772,6 +2747,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, limit }) => { + search::cmd_search(keyword, "topics", limit, format, verbose).await + } None => { let sym = symbol.ok_or_else(|| { anyhow::anyhow!("Symbol required. Usage: longbridge topic ") @@ -3125,7 +3103,6 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re } Commands::ShortMargin => asset::cmd_short_margin(format, verbose).await, - Commands::PnlCalendar => asset::cmd_pnl_calendar(format, verbose).await, Commands::HoldingPeriod { symbol } => { asset::cmd_holding_period(symbol, format, verbose).await } @@ -3153,26 +3130,6 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re .await } - Commands::Search { - keyword, - tab, - market, - product, - limit, - } => { - search::cmd_search( - keyword, - &tab, - market.as_deref(), - product.as_deref(), - limit, - format, - verbose, - ) - .await - } - Commands::SearchHot => search::cmd_search_hot(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, @@ -3208,29 +3165,6 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re ipo::cmd_ipo_profit_loss_items(&period, page, limit, format, verbose).await } IpoCmd::Holdings { symbol } => ipo::cmd_ipo_holdings(symbol, format, verbose).await, - IpoCmd::Submit { - symbol, - qty, - amount, - financing_amount, - method, - financing_interest, - } => { - ipo::cmd_ipo_submit( - symbol, - qty, - amount, - financing_amount, - method, - financing_interest, - format, - verbose, - ) - .await - } - IpoCmd::Withdraw { order_id } => { - ipo::cmd_ipo_withdraw(order_id, format, verbose).await - } }, Commands::Auth { .. } diff --git a/src/cli/search.rs b/src/cli/search.rs index b60a8af..2425196 100644 --- a/src/cli/search.rs +++ b/src/cli/search.rs @@ -1,7 +1,7 @@ use anyhow::Result; -use serde_json::{json, Value}; +use serde_json::Value; -use super::api::{http_get, http_post}; +use super::api::http_get; use super::output::print_table; use super::OutputFormat; @@ -17,63 +17,24 @@ fn val_str(v: &Value) -> String { Value::String(s) => s.clone(), Value::Number(n) => n.to_string(), Value::Bool(b) => b.to_string(), - Value::Null => String::new(), + Value::Null => "-".to_string(), other => other.to_string(), } } // ── search ──────────────────────────────────────────────────────────────────── -/// Search across securities, news, community, help, and more. +/// Search news or community topics. pub async fn cmd_search( keyword: String, tab: &str, - market: Option<&str>, - product: Option<&str>, limit: usize, format: &OutputFormat, verbose: bool, ) -> Result<()> { match tab { - "market" => { - let mut params: Vec<(&str, &str)> = vec![("k", keyword.as_str())]; - if let Some(m) = market { - params.push(("market", m)); - } - if let Some(p) = product { - params.push(("product", p)); - } - let data = http_get("/v4/search", ¶ms, verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => { - if let Some(list) = data["product_list"].as_array() { - let items: Vec<&Value> = list.iter().take(limit).collect(); - if items.is_empty() { - println!("No results."); - return Ok(()); - } - let headers = ["symbol", "name", "market", "type"]; - let rows: Vec> = items - .iter() - .map(|item| { - vec![ - val_str(&item["code"]), - val_str(&item["name"]), - val_str(&item["market"]), - val_str(&item["type"]), - ] - }) - .collect(); - print_table(&headers, rows, &OutputFormat::Pretty); - } else { - print_json(&data); - } - } - } - } "news" => { - let data = http_get("/v1/news_search", &[("k", keyword.as_str())], verbose).await?; + let data = http_get("/v1/search/news", &[("k", keyword.as_str())], verbose).await?; match format { OutputFormat::Json => print_json(&data), OutputFormat::Pretty => { @@ -101,16 +62,15 @@ pub async fn cmd_search( } } } - "posts" => { - let body = json!({ "k": keyword }); - let data = http_post("/v1/search/social_topics", body, verbose).await?; + "topics" => { + let data = http_get("/v1/search/topics", &[("k", keyword.as_str())], verbose).await?; match format { OutputFormat::Json => 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 post results."); + println!("No topic results."); return Ok(()); } let headers = ["id", "author", "excerpt"]; @@ -129,189 +89,7 @@ pub async fn cmd_search( } } } - "hashtags" => { - let body = json!({ "k": keyword }); - let data = http_post("/v2/search/hashtag", body, verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => { - if let Some(list) = data["hashtag_list"].as_array() { - let items: Vec<&Value> = list.iter().take(limit).collect(); - if items.is_empty() { - println!("No hashtag results."); - return Ok(()); - } - let headers = ["id", "name", "topic_count"]; - let rows: Vec> = items - .iter() - .map(|item| { - vec![ - val_str(&item["id"]), - val_str(&item["name"]), - val_str(&item["topic_count"]), - ] - }) - .collect(); - print_table(&headers, rows, &OutputFormat::Pretty); - } else { - print_json(&data); - } - } - } - } - "help" => { - let limit_str = limit.to_string(); - let body = json!({ "k": keyword, "limit": limit_str }); - let data = http_post("/v1/helpcenter/search_main", body, verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => { - if let Some(list) = data["help_topic_list"].as_array() { - if list.is_empty() { - println!("No help results."); - return Ok(()); - } - let headers = ["id", "title"]; - let rows: Vec> = list - .iter() - .take(limit) - .map(|item| vec![val_str(&item["id"]), val_str(&item["title"])]) - .collect(); - print_table(&headers, rows, &OutputFormat::Pretty); - } else { - print_json(&data); - } - } - } - } - "share-lists" => { - let body = json!({ "k": keyword, "id": "" }); - let data = http_post("/v1/search/share_lists", body, verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => { - if let Some(list) = data["share_list"].as_array() { - let items: Vec<&Value> = list.iter().take(limit).collect(); - if items.is_empty() { - println!("No share-list results."); - return Ok(()); - } - let headers = ["id", "name", "author", "stock_count"]; - let rows: Vec> = items - .iter() - .map(|item| { - vec![ - val_str(&item["id"]), - val_str(&item["name"]), - val_str(&item["author_name"]), - val_str(&item["stock_count"]), - ] - }) - .collect(); - print_table(&headers, rows, &OutputFormat::Pretty); - } else { - print_json(&data); - } - } - } - } - "users" => { - let body = json!({ "k": keyword, "id": "" }); - let data = http_post("/v1/search/social_users", body, verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => { - if let Some(list) = data["user_list"].as_array() { - let items: Vec<&Value> = list.iter().take(limit).collect(); - if items.is_empty() { - println!("No user results."); - return Ok(()); - } - let headers = ["id", "name", "followers"]; - let rows: Vec> = items - .iter() - .map(|item| { - vec![ - val_str(&item["id"]), - val_str(&item["name"]), - val_str(&item["followers_count"]), - ] - }) - .collect(); - print_table(&headers, rows, &OutputFormat::Pretty); - } else { - print_json(&data); - } - } - } - } - "institutions" => { - let body = json!({ "k": keyword }); - let data = http_post("/v1/search/social_institutions", body, verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => { - if let Some(list) = data["institution_list"].as_array() { - let items: Vec<&Value> = list.iter().take(limit).collect(); - if items.is_empty() { - println!("No institution results."); - return Ok(()); - } - let headers = ["id", "name", "followers"]; - let rows: Vec> = items - .iter() - .map(|item| { - vec![ - val_str(&item["id"]), - val_str(&item["name"]), - val_str(&item["followers_count"]), - ] - }) - .collect(); - print_table(&headers, rows, &OutputFormat::Pretty); - } else { - print_json(&data); - } - } - } - } - _ => anyhow::bail!( - "Unknown --tab value: {tab}. Valid: market, news, posts, hashtags, help, share-lists, users, institutions" - ), - } - Ok(()) -} - -// ── search hot words ────────────────────────────────────────────────────────── - -/// Fetch hot search words. -pub async fn cmd_search_hot(format: &OutputFormat, verbose: bool) -> Result<()> { - let data = http_get("/search/gethotwords", &[], verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => { - // Try common response shapes for hot words - let list = data["hot_words"] - .as_array() - .or_else(|| data["list"].as_array()) - .or_else(|| data["words"].as_array()); - if let Some(words) = list { - if words.is_empty() { - println!("No hot words."); - return Ok(()); - } - for (i, w) in words.iter().enumerate() { - let word = if w.is_string() { - val_str(w) - } else { - val_str(&w["word"]) - }; - println!("{}. {word}", i + 1); - } - } else { - print_json(&data); - } - } + _ => unreachable!(), } Ok(()) } From 3028392afa7b68e5579f0aafc4ba29403786de43 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 15:57:48 +0800 Subject: [PATCH 07/44] . --- scripts/mock-server.ts | 102 ----------------------------------------- 1 file changed, 102 deletions(-) delete mode 100644 scripts/mock-server.ts 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(); From 5339e8c297048258f1ea1d4975e6d1e20dc52967 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 16:03:11 +0800 Subject: [PATCH 08/44] Update mod.rs --- src/cli/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 77dd0a1..a2e43c0 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -12,6 +12,7 @@ pub mod fundamental; pub mod init; pub mod insider_trades; pub mod investors; +pub mod my_quote; pub mod ipo; pub mod news; pub mod output; From ca633346542a44a246fb275a9f595369c31c439d Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 16:05:15 +0800 Subject: [PATCH 09/44] cli: Strip HTML tags from search result titles and excerpts Co-Authored-By: Claude Sonnet 4.6 --- src/cli/search.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/cli/search.rs b/src/cli/search.rs index 2425196..bf2f1a6 100644 --- a/src/cli/search.rs +++ b/src/cli/search.rs @@ -4,6 +4,7 @@ use serde_json::Value; use super::api::http_get; use super::output::print_table; use super::OutputFormat; +use crate::utils::text::strip_html; fn print_json(value: &Value) { println!( @@ -50,7 +51,7 @@ pub async fn cmd_search( .map(|item| { vec![ val_str(&item["id"]), - val_str(&item["title"]), + strip_html(&val_str(&item["title"])), val_str(&item["publish_at"]), ] }) @@ -77,8 +78,10 @@ pub async fn cmd_search( let rows: Vec> = items .iter() .map(|item| { - let excerpt: String = - val_str(&item["content"]).chars().take(60).collect(); + let excerpt: String = strip_html(&val_str(&item["content"])) + .chars() + .take(60) + .collect(); vec![val_str(&item["id"]), val_str(&item["author_name"]), excerpt] }) .collect(); From 57dfda481f21fb461a8d4322ee4039b3f3974a48 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 16:06:43 +0800 Subject: [PATCH 10/44] cli: Fix topic search field names (creator_name, description) Co-Authored-By: Claude Sonnet 4.6 --- src/cli/search.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cli/search.rs b/src/cli/search.rs index bf2f1a6..058c68c 100644 --- a/src/cli/search.rs +++ b/src/cli/search.rs @@ -78,11 +78,15 @@ pub async fn cmd_search( let rows: Vec> = items .iter() .map(|item| { - let excerpt: String = strip_html(&val_str(&item["content"])) + let excerpt: String = strip_html(&val_str(&item["description"])) .chars() .take(60) .collect(); - vec![val_str(&item["id"]), val_str(&item["author_name"]), excerpt] + vec![ + val_str(&item["id"]), + val_str(&item["creator_name"]), + excerpt, + ] }) .collect(); print_table(&headers, rows, &OutputFormat::Pretty); From 00b146b82723ee383fbfdb3486ade340c9b69aa0 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 16:10:30 +0800 Subject: [PATCH 11/44] cli: Fix IPO subscriptions display and news search time format - Add currency column to ipo subscriptions table - Format sub_deadline as RFC 3339 timestamp - Add state_stage "0" => "filing" mapping - Format news search publish_at using RFC 3339 timestamp Co-Authored-By: Claude Sonnet 4.6 --- src/cli/ipo.rs | 13 +++++++++++-- src/cli/search.rs | 11 ++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index f921914..3d8264f 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -59,11 +59,19 @@ pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Resu println!("No active IPO subscriptions."); return Ok(()); } - let headers = ["name", "counter_id", "issue_price", "deadline", "state"]; + let headers = [ + "name", + "counter_id", + "currency", + "issue_price", + "deadline", + "state", + ]; let rows: Vec> = list .iter() .map(|item| { let stage = match val_str(&item["state_stage"]).as_str() { + "0" => "filing", "1" => "sub-start", "2" => "sub-end", "3" => "allotment", @@ -75,8 +83,9 @@ pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Resu vec![ val_str(&item["name"]), val_str(&item["counter_id"]), + val_str(&item["currency"]), val_str(&item["issue_price"]), - val_str(&item["sub_deadline"]), + fmt_ts(&item["sub_deadline"]), stage, ] }) diff --git a/src/cli/search.rs b/src/cli/search.rs index 058c68c..819d186 100644 --- a/src/cli/search.rs +++ b/src/cli/search.rs @@ -6,6 +6,15 @@ 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!( "{}", @@ -52,7 +61,7 @@ pub async fn cmd_search( vec![ val_str(&item["id"]), strip_html(&val_str(&item["title"])), - val_str(&item["publish_at"]), + fmt_ts(&item["publish_at_timestamp"]), ] }) .collect(); From c97d719384a19734dbd034d70be94e0e5323b714 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 16:13:02 +0800 Subject: [PATCH 12/44] cli: Revert fabricated state_stage "0" mapping Co-Authored-By: Claude Sonnet 4.6 --- src/cli/ipo.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index 3d8264f..7be381d 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -71,7 +71,6 @@ pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Resu .iter() .map(|item| { let stage = match val_str(&item["state_stage"]).as_str() { - "0" => "filing", "1" => "sub-start", "2" => "sub-end", "3" => "allotment", From 800fda7dcccb847f2e212df8bbd62bd3d435b375 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 16:14:45 +0800 Subject: [PATCH 13/44] cli: Fix state_stage mapping and add time to topic search - state_stage 0 => "pending" (confirmed from API docs) - Add time column to topic search using created_at_timestamp Co-Authored-By: Claude Sonnet 4.6 --- src/cli/ipo.rs | 1 + src/cli/search.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index 7be381d..7f8155e 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -71,6 +71,7 @@ pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Resu .iter() .map(|item| { let stage = match val_str(&item["state_stage"]).as_str() { + "0" => "pending", "1" => "sub-start", "2" => "sub-end", "3" => "allotment", diff --git a/src/cli/search.rs b/src/cli/search.rs index 819d186..837765e 100644 --- a/src/cli/search.rs +++ b/src/cli/search.rs @@ -83,7 +83,7 @@ pub async fn cmd_search( println!("No topic results."); return Ok(()); } - let headers = ["id", "author", "excerpt"]; + let headers = ["id", "author", "time", "excerpt"]; let rows: Vec> = items .iter() .map(|item| { @@ -94,6 +94,7 @@ pub async fn cmd_search( vec![ val_str(&item["id"]), val_str(&item["creator_name"]), + fmt_ts(&item["created_at_timestamp"]), excerpt, ] }) From 2238de4ddfe29db36e5b78c758ef274fb2941d1c Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 16:16:54 +0800 Subject: [PATCH 14/44] cli: Convert counter_id to symbol format in IPO table output Co-Authored-By: Claude Sonnet 4.6 --- src/cli/ipo.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index 7f8155e..afe635f 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -4,7 +4,7 @@ use serde_json::Value; use super::api::http_get; use super::output::{print_json_value, print_table}; use super::OutputFormat; -use crate::utils::counter::symbol_to_counter_id; +use crate::utils::counter::{counter_id_to_symbol, symbol_to_counter_id}; fn print_json(value: &Value) { println!( @@ -61,7 +61,7 @@ pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Resu } let headers = [ "name", - "counter_id", + "symbol", "currency", "issue_price", "deadline", @@ -82,7 +82,7 @@ pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Resu .to_string(); vec![ val_str(&item["name"]), - val_str(&item["counter_id"]), + counter_id_to_symbol(&val_str(&item["counter_id"])), val_str(&item["currency"]), val_str(&item["issue_price"]), fmt_ts(&item["sub_deadline"]), @@ -126,13 +126,13 @@ pub async fn cmd_ipo_wait_listing(format: &OutputFormat, verbose: bool) -> Resul println!("No IPO stocks in wait-listing."); return Ok(()); } - let headers = ["name", "counter_id", "issue_price", "listing_date"]; + let headers = ["name", "symbol", "issue_price", "listing_date"]; let rows: Vec> = list .iter() .map(|item| { vec![ val_str(&item["name"]), - val_str(&item["counter_id"]), + counter_id_to_symbol(&val_str(&item["counter_id"])), val_str(&item["issue_price"]), val_str(&item["listing_date"]), ] @@ -176,13 +176,13 @@ pub async fn cmd_ipo_listed( println!("No listed IPO stocks found."); return Ok(()); } - let headers = ["name", "counter_id", "issue_price", "listing_date"]; + let headers = ["name", "symbol", "issue_price", "listing_date"]; let rows: Vec> = list .iter() .map(|item| { vec![ val_str(&item["name"]), - val_str(&item["counter_id"]), + counter_id_to_symbol(&val_str(&item["counter_id"])), val_str(&item["issue_price"]), val_str(&item["listing_date"]), ] @@ -208,14 +208,14 @@ pub async fn cmd_ipo_calendar(format: &OutputFormat, verbose: bool) -> Result<() println!("No IPO calendar entries found."); return Ok(()); } - let headers = ["date", "name", "counter_id", "type"]; + let headers = ["date", "name", "symbol", "type"]; let rows: Vec> = list .iter() .map(|item| { vec![ val_str(&item["date"]), val_str(&item["name"]), - val_str(&item["counter_id"]), + counter_id_to_symbol(&val_str(&item["counter_id"])), val_str(&item["type"]), ] }) From 080a9caefa1f23d1a6cca1850f92d1449b5891aa Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 17:00:38 +0800 Subject: [PATCH 15/44] cli: Improve JSON output quality across IPO, search, deposits, and news commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IPO: replace transform_symbol_item with transform_ipo_list_item; convert counter_id→symbol, state_stage/state/sub_state enums→labels, unix ts→RFC 3339; fix wait-listing to use correct ipos response key (was list) - Search: switch news/topic transforms to whitelist approach; add excerpt and URL (.md suffix) to results - Deposits: add DEPOSIT_SKIP to remove noisy fields from JSON; improve Pretty table to show id, type, and state - News/topics/filings: fix unix timestamps in JSON output (use format_datetime) Co-Authored-By: Claude Sonnet 4.6 --- .../cli-candlestick-chart/src/chart_data.rs | 2 +- src/auth.rs | 1 - src/cli/atm.rs | 91 ++++- src/cli/fundamental.rs | 49 ++- src/cli/ipo.rs | 355 +++++++++++++++++- src/cli/mod.rs | 21 +- src/cli/news.rs | 8 +- src/cli/quant_render.rs | 4 +- src/cli/search.rs | 81 +++- src/tui/systems/orders.rs | 64 ++-- 10 files changed, 583 insertions(+), 93 deletions(-) diff --git a/crates/cli-candlestick-chart/src/chart_data.rs b/crates/cli-candlestick-chart/src/chart_data.rs index b6bcfa8..ea2470d 100644 --- a/crates/cli-candlestick-chart/src/chart_data.rs +++ b/crates/cli-candlestick-chart/src/chart_data.rs @@ -43,7 +43,7 @@ impl ChartData { self.main_candle_set .candles .iter() - .skip((nb_candles as i64 - nb_visible_candles as i64).max(0) as usize) + .skip((nb_candles as i64 - nb_visible_candles).max(0) as usize) .cloned() .collect::>(), ); diff --git a/src/auth.rs b/src/auth.rs index ea61173..fd38d9e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -438,7 +438,6 @@ pub async fn auth_code_login() -> Result<()> { } } - /// Clear the stored OAuth token (logout). Deletes the token file used by the longbridge SDK. pub fn clear_token() -> Result<()> { let path = token_file_path()?; diff --git a/src/cli/atm.rs b/src/cli/atm.rs index 14b1a9f..fec87e8 100644 --- a/src/cli/atm.rs +++ b/src/cli/atm.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use serde_json::Value; +use serde_json::{Map, Value}; use super::api::http_get; use super::output::print_table; @@ -31,6 +31,42 @@ fn fmt_ts(v: &Value) -> String { 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; + } + obj.insert(k.clone(), v.clone()); + } + } + Value::Object(obj) +} + // ── withdrawal cards ────────────────────────────────────────────────────────── /// List withdrawal bank cards for the current user. @@ -88,7 +124,25 @@ pub async fn cmd_withdrawals( ) .await?; match format { - OutputFormat::Json => print_json(&data), + 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" { @@ -148,7 +202,23 @@ pub async fn cmd_deposits( } let data = http_get("/v1/account/deposits", ¶ms, verbose).await?; match format { - OutputFormat::Json => print_json(&data), + 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" { @@ -159,22 +229,17 @@ pub async fn cmd_deposits( println!("No deposit records."); return Ok(()); } - let headers = ["date", "amount", "currency", "state", "source"]; + let headers = ["id", "date", "amount", "currency", "type", "state"]; let rows: Vec> = items .iter() .map(|item| { - let state = match val_str(&item["state"]).as_str() { - "0" => "Pending".to_string(), - "1" => "Credited".to_string(), - "2" => "Failed".to_string(), - s => s.to_string(), - }; vec![ - fmt_ts(&item["created_at"]), + val_str(&item["id"]), + val_str(&item["created_at"]), val_str(&item["amount"]), val_str(&item["currency"]), - state, - val_str(&item["fund_source"]), + val_str(&item["bank_operation_type_name"]), + val_str(&item["state"]), ] }) .collect(); diff --git a/src/cli/fundamental.rs b/src/cli/fundamental.rs index 5953722..cf385e8 100644 --- a/src/cli/fundamental.rs +++ b/src/cli/fundamental.rs @@ -486,7 +486,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(()) @@ -2086,27 +2107,27 @@ pub async fn cmd_institution_rating_history( match format { OutputFormat::Json => print_json(&data), OutputFormat::Pretty => { - if let Some(target) = data.get("target_history") { - if !target.as_array().map_or(true, |a| a.is_empty()) { + 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 let Some(eval) = data.get("evaluate_history") { - if !eval.as_array().map_or(true, |a| a.is_empty()) { + if !eval_empty { + if let Some(eval) = data.get("evaluate_history") { println!("\nRating history:"); print_kv(eval); } } - if data - .get("target_history") - .and_then(|v| v.as_array()) - .map_or(true, |a| a.is_empty()) - && data - .get("evaluate_history") - .and_then(|v| v.as_array()) - .map_or(true, |a| a.is_empty()) - { + if target_empty && eval_empty { println!("No rating history found."); } } diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index afe635f..9f14572 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use serde_json::Value; +use serde_json::{Map, Value}; use super::api::http_get; use super::output::{print_json_value, print_table}; @@ -32,6 +32,143 @@ fn fmt_ts(v: &Value) -> String { ts.map_or_else(|| val_str(v), crate::utils::datetime::format_timestamp) } +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 { + obj.insert(k.clone(), v.clone()); + } + } + } + Value::Object(obj) +} + +// Transform history item: format created_at timestamp. +fn transform_history_item(item: &Value) -> Value { + let mut obj = Map::new(); + if let Some(map) = item.as_object() { + for (k, v) in map { + if k == "created_at" { + obj.insert(k.clone(), Value::String(fmt_ts(v))); + } else { + obj.insert(k.clone(), v.clone()); + } + } + } + Value::Object(obj) +} + async fn member_id() -> Result { crate::openapi::quote() .member_id() @@ -52,7 +189,14 @@ pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Resu ) .await?; match format { - OutputFormat::Json => print_json(&data), + 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() { @@ -63,6 +207,10 @@ pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Resu "name", "symbol", "currency", + "entrance_fee", + "est_sub", + "fin_rate", + "max_lev", "issue_price", "deadline", "state", @@ -70,20 +218,18 @@ pub async fn cmd_ipo_subscriptions(format: &OutputFormat, verbose: bool) -> Resu let rows: Vec> = list .iter() .map(|item| { - let stage = match val_str(&item["state_stage"]).as_str() { - "0" => "pending", - "1" => "sub-start", - "2" => "sub-end", - "3" => "allotment", - "4" => "grey-market", - "5" => "listed", - s => s, - } - .to_string(); + 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, @@ -119,14 +265,21 @@ pub async fn cmd_ipo_wait_listing(format: &OutputFormat, verbose: bool) -> Resul ) .await?; match format { - OutputFormat::Json => print_json(&data), + 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["list"].as_array() { + if let Some(list) = data["ipos"].as_array() { if list.is_empty() { println!("No IPO stocks in wait-listing."); return Ok(()); } - let headers = ["name", "symbol", "issue_price", "listing_date"]; + let headers = ["name", "symbol", "issue_price", "ipo_date", "state"]; let rows: Vec> = list .iter() .map(|item| { @@ -134,7 +287,8 @@ pub async fn cmd_ipo_wait_listing(format: &OutputFormat, verbose: bool) -> Resul val_str(&item["name"]), counter_id_to_symbol(&val_str(&item["counter_id"])), val_str(&item["issue_price"]), - val_str(&item["listing_date"]), + fmt_ts(&item["ipo_date"]), + state_stage_label(&item["state_stage"]).to_string(), ] }) .collect(); @@ -169,7 +323,14 @@ pub async fn cmd_ipo_listed( ) .await?; match format { - OutputFormat::Json => print_json(&data), + 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() { @@ -201,7 +362,14 @@ pub async fn cmd_ipo_listed( pub async fn cmd_ipo_calendar(format: &OutputFormat, verbose: bool) -> Result<()> { let data = http_get("/v1/ipo/calendar", &[], 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(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() { @@ -406,7 +574,14 @@ pub async fn cmd_ipo_history( } let data = http_get("/v1/ipo/orders/history", ¶ms, verbose).await?; match format { - OutputFormat::Json => print_json(&data), + OutputFormat::Json => { + if let Some(arr) = data.as_array() { + let transformed: Vec = arr.iter().map(transform_history_item).collect(); + print_json(&Value::Array(transformed)); + } else { + print_json(&data); + } + } OutputFormat::Pretty => { if let Some(arr) = data.as_array() { if arr.is_empty() { @@ -525,3 +700,145 @@ pub async fn cmd_ipo_holdings(symbol: String, format: &OutputFormat, verbose: bo } 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", "listing_date"]; + 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"]), + val_str(&item["listing_date"]), + ] + }) + .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 a2e43c0..76c988a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -12,8 +12,8 @@ pub mod fundamental; pub mod init; pub mod insider_trades; pub mod investors; -pub mod my_quote; pub mod ipo; +pub mod my_quote; pub mod news; pub mod output; pub mod quant_render; @@ -1682,6 +1682,20 @@ pub enum IpoCmd { }, /// Show IPO holding portfolio detail for a symbol Holdings { symbol: String }, + /// 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, default_value = "20")] + limit: u32, + }, } #[derive(Subcommand)] @@ -3166,6 +3180,11 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re ipo::cmd_ipo_profit_loss_items(&period, page, limit, format, verbose).await } IpoCmd::Holdings { symbol } => ipo::cmd_ipo_holdings(symbol, 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, limit } => { + ipo::cmd_ipo_us_listed(page, limit, format, verbose).await + } }, Commands::Auth { .. } 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 index 837765e..1d146c0 100644 --- a/src/cli/search.rs +++ b/src/cli/search.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use serde_json::Value; +use serde_json::{Map, Value}; use super::api::http_get; use super::output::print_table; @@ -32,6 +32,65 @@ fn val_str(v: &Value) -> 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. @@ -46,7 +105,15 @@ pub async fn cmd_search( "news" => { let data = http_get("/v1/search/news", &[("k", keyword.as_str())], verbose).await?; match format { - OutputFormat::Json => print_json(&data), + 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(); @@ -75,7 +142,15 @@ pub async fn cmd_search( "topics" => { let data = http_get("/v1/search/topics", &[("k", keyword.as_str())], verbose).await?; match format { - OutputFormat::Json => print_json(&data), + 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(); diff --git a/src/tui/systems/orders.rs b/src/tui/systems/orders.rs index 854395b..4a191ec 100644 --- a/src/tui/systems/orders.rs +++ b/src/tui/systems/orders.rs @@ -800,42 +800,38 @@ pub fn render_orders( super::Key::Tab => { toggle_orders_mode(); } - super::Key::Up => { - if orders_len > 0 { - if is_history { - let mut table = HISTORY_ORDERS_TABLE.lock().expect("poison"); - let cur = table.selected(); - table.select(Some(cur.map_or(0, |i| i.saturating_sub(1)))); - } else { - let mut table = ORDERS_TABLE.lock().expect("poison"); - let cur = table.selected(); - table.select(Some(cur.map_or(0, |i| i.saturating_sub(1)))); - } + super::Key::Up if orders_len > 0 => { + if is_history { + let mut table = HISTORY_ORDERS_TABLE.lock().expect("poison"); + let cur = table.selected(); + table.select(Some(cur.map_or(0, |i| i.saturating_sub(1)))); + } else { + let mut table = ORDERS_TABLE.lock().expect("poison"); + let cur = table.selected(); + table.select(Some(cur.map_or(0, |i| i.saturating_sub(1)))); } } - super::Key::Down => { - if orders_len > 0 { - if is_history { - let mut table = HISTORY_ORDERS_TABLE.lock().expect("poison"); - let cur = table.selected(); - table.select(Some(cur.map_or(0, |i| { - if i + 1 < orders_len { - i + 1 - } else { - i - } - }))); - } else { - let mut table = ORDERS_TABLE.lock().expect("poison"); - let cur = table.selected(); - table.select(Some(cur.map_or(0, |i| { - if i + 1 < orders_len { - i + 1 - } else { - i - } - }))); - } + super::Key::Down if orders_len > 0 => { + if is_history { + let mut table = HISTORY_ORDERS_TABLE.lock().expect("poison"); + let cur = table.selected(); + table.select(Some(cur.map_or(0, |i| { + if i + 1 < orders_len { + i + 1 + } else { + i + } + }))); + } else { + let mut table = ORDERS_TABLE.lock().expect("poison"); + let cur = table.selected(); + table.select(Some(cur.map_or(0, |i| { + if i + 1 < orders_len { + i + 1 + } else { + i + } + }))); } } super::Key::Enter => { From 90e805542fcb40ade5906e23ac0a86ff9fada99f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 17:13:23 +0800 Subject: [PATCH 16/44] cli: Rename withdrawal-cards to bank-cards and improve output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename command withdrawal-cards → bank-cards - Fix Pretty output: use correct list key and actual field names - Add swift_code and region columns to Pretty table - Add JSON transform with whitelist fields and status enum labels - Fix deposits JSON/Pretty: format created_at timestamp as RFC 3339 Co-Authored-By: Claude Sonnet 4.6 --- src/cli/atm.rs | 65 +++++++++++++++++++++++++++++++++++++++++--------- src/cli/mod.rs | 9 +++---- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/cli/atm.rs b/src/cli/atm.rs index fec87e8..99a458f 100644 --- a/src/cli/atm.rs +++ b/src/cli/atm.rs @@ -61,34 +61,77 @@ fn transform_deposit_item(item: &Value) -> Value { if DEPOSIT_SKIP.contains(&k.as_str()) { continue; } - obj.insert(k.clone(), v.clone()); + 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) } -// ── withdrawal cards ────────────────────────────────────────────────────────── +// ── 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 => print_json(&data), + 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["cards"].as_array() { + if let Some(cards) = data["list"].as_array() { if cards.is_empty() { - println!("No withdrawal cards found."); + println!("No bank cards found."); return Ok(()); } - let headers = ["bank_name", "account_number", "currency", "status"]; + let headers = ["bank", "account", "currency", "swift", "region", "status"]; let rows: Vec> = cards .iter() .map(|card| { vec![ - val_str(&card["bank_name"]), - val_str(&card["account_number"]), - val_str(&card["currency"]), - val_str(&card["status"]), + 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(); @@ -235,7 +278,7 @@ pub async fn cmd_deposits( .map(|item| { vec![ val_str(&item["id"]), - val_str(&item["created_at"]), + fmt_ts(&item["created_at"]), val_str(&item["amount"]), val_str(&item["currency"]), val_str(&item["bank_operation_type_name"]), diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 76c988a..3511b1d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1106,10 +1106,11 @@ pub enum Commands { OrderStats, // ── ATM (new) ──────────────────────────────────────────────────────────── - /// List withdrawal bank cards for the current account + /// List bank cards for the current account /// - /// Example: longbridge withdrawal-cards - WithdrawalCards, + /// Example: longbridge bank-cards + #[command(name = "bank-cards")] + BankCards, /// List withdrawal history for the current account /// @@ -3124,7 +3125,7 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re Commands::TradeInfo { symbol } => asset::cmd_trade_info(symbol, format, verbose).await, Commands::OrderStats => asset::cmd_order_stats(format, verbose).await, - Commands::WithdrawalCards => atm::cmd_withdrawal_cards(format, verbose).await, + Commands::BankCards => atm::cmd_withdrawal_cards(format, verbose).await, Commands::Withdrawals { page, limit } => { atm::cmd_withdrawals(page, limit, format, verbose).await } From 877a02ac596166661e55eb02cc23cdd813a5af36 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 17:52:44 +0800 Subject: [PATCH 17/44] =?UTF-8?q?cli:=20Improve=20IPO=20commands=20?= =?UTF-8?q?=E2=80=94=20merge=20HK/US,=20fix=20dates,=20rename=20bank-cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ipo subscriptions`, `ipo wait-listing`, `ipo listed`: now fetch both HK and US endpoints concurrently and display results in separate sections - `ipo calendar`: split Pretty output into HK / US sections; use `fmt_ts_opt` for `sub_date`/`sub_end_date` to show `-` for US IPOs with zero/negative timestamps; format `ipo_date` from YYYYMMDD string or unix timestamp - `ipo us-listed`: align columns with HK listed view - `bank-cards`: rename from `withdrawal-cards`, fix JSON whitelist/status enum, fix Pretty field names and add swift/region columns - `deposits`: format `created_at` as RFC 3339 in both JSON and Pretty - `--limit` → `--count` (with `--limit` alias) on all paginated IPO, ATM, news-search, and topic-search commands Co-Authored-By: Claude Sonnet 4.6 --- src/cli/atm.rs | 23 ++- src/cli/ipo.rs | 488 ++++++++++++++++++++++++++++++++++--------------- src/cli/mod.rs | 68 +++---- 3 files changed, 397 insertions(+), 182 deletions(-) diff --git a/src/cli/atm.rs b/src/cli/atm.rs index 99a458f..2d61191 100644 --- a/src/cli/atm.rs +++ b/src/cli/atm.rs @@ -82,9 +82,21 @@ fn bank_card_status_label(v: &Value) -> &'static str { } 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", + "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 { @@ -92,7 +104,10 @@ fn transform_bank_card(card: &Value) -> Value { 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())); + 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()); } diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index 9f14572..aa1c62d 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -32,6 +32,19 @@ fn fmt_ts(v: &Value) -> String { ts.map_or_else(|| val_str(v), crate::utils::datetime::format_timestamp) } +fn fmt_ts_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_timestamp(n), + Some(_) => "-".to_string(), + None => val_str(v), + } +} + fn state_stage_label(v: &Value) -> &'static str { match val_str(v).as_str() { "0" => "pending", @@ -146,6 +159,14 @@ fn transform_ipo_list_item(item: &Value) -> Value { 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()); } @@ -178,74 +199,114 @@ async fn member_id() -> Result { // ── read-only IPO list commands ──────────────────────────────────────────────── -/// List IPO stocks currently in subscription or pre-filing stage. +/// 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 data = http_get( - "/v1/ipo/subscriptions", - &[("memebr_id", mid_str.as_str())], - verbose, - ) - .await?; + 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 => { - if let Some(list) = data["list"].as_array() { + 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(); - print_json(&Value::Array(transformed)); - } else { - print_json(&data); + 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 => { - if let Some(list) = data["list"].as_array() { - if list.is_empty() { - println!("No active IPO subscriptions."); - return Ok(()); + 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; } - 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); - } else { - print_json(&data); + } + 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. +/// 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() @@ -255,53 +316,115 @@ pub async fn cmd_ipo_wait_listing(format: &OutputFormat, verbose: bool) -> Resul let mid = member_id().await?; let mid_str = mid.to_string(); let day_str = now.to_string(); - let data = http_get( - "/v1/ipo/wait-listing", - &[ - ("day_time", day_str.as_str()), - ("memebr_id", mid_str.as_str()), - ], - verbose, - ) - .await?; + 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 => { - if let Some(list) = data["ipos"].as_array() { + 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(); - print_json(&Value::Array(transformed)); - } else { - print_json(&data); + 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 => { - if let Some(list) = data["ipos"].as_array() { - if list.is_empty() { - println!("No IPO stocks in wait-listing."); - return Ok(()); + 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; } - 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); + } + 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(()) } -/// List recently listed IPO stocks. +fn hk_listed_row(item: &Value) -> Vec { + let date = { + let s = val_str(&item["ipo_date"]); + if s.len() == 8 && s.chars().all(|c| c.is_ascii_digit()) { + format!("{}-{}-{}", &s[..4], &s[4..6], &s[6..]) + } else { + s + } + }; + let amount = val_str(&item["amount"]) + .parse::() + .map(|n| crate::utils::number::format_volume(n)) + .unwrap_or_else(|_| val_str(&item["amount"])); + 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 = { + let s = val_str(&item["listing_date"]); + if s.len() == 8 && s.chars().all(|c| c.is_ascii_digit()) { + format!("{}-{}-{}", &s[..4], &s[4..6], &s[6..]) + } else { + s + } + }; + 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"]), + val_str(&item["amount"]), + date, + ] +} + +/// List recently listed IPO stocks (HK and US). pub async fn cmd_ipo_listed( page: u32, limit: u32, @@ -312,46 +435,64 @@ pub async fn cmd_ipo_listed( let mid_str = mid.to_string(); let page_str = page.to_string(); let size_str = limit.to_string(); - let data = http_get( - "/v1/ipo/listed", - &[ - ("page", page_str.as_str()), - ("size", size_str.as_str()), - ("memebr_id", mid_str.as_str()), - ], - verbose, - ) - .await?; + 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 => { - if let Some(list) = data["list"].as_array() { + 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(); - print_json(&Value::Array(transformed)); - } else { - print_json(&data); + 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 => { - if let Some(list) = data["list"].as_array() { - if list.is_empty() { - println!("No listed IPO stocks found."); - return Ok(()); + let headers = [ + "name", + "symbol", + "issue_price", + "last_done", + "prev_close", + "change%", + "amount", + "ipo_date", + ]; + let mut printed = false; + if let Some(list) = hk_data["list"].as_array() { + if !list.is_empty() { + println!("── HK ──"); + let rows: Vec> = list.iter().map(hk_listed_row).collect(); + print_table(&headers, rows, &OutputFormat::Pretty); + printed = true; } - let headers = ["name", "symbol", "issue_price", "listing_date"]; - 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"]), - val_str(&item["listing_date"]), - ] - }) - .collect(); - print_table(&headers, rows, &OutputFormat::Pretty); - } else { - print_json(&data); + } + if let Some(list) = us_data["list"].as_array() { + if !list.is_empty() { + if printed { + println!(); + } + println!("── US ──"); + 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."); } } } @@ -376,19 +517,68 @@ pub async fn cmd_ipo_calendar(format: &OutputFormat, verbose: bool) -> Result<() println!("No IPO calendar entries found."); return Ok(()); } - let headers = ["date", "name", "symbol", "type"]; - let rows: Vec> = list - .iter() - .map(|item| { - vec![ - val_str(&item["date"]), - val_str(&item["name"]), - counter_id_to_symbol(&val_str(&item["counter_id"])), - val_str(&item["type"]), - ] - }) - .collect(); - print_table(&headers, rows, &OutputFormat::Pretty); + 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 ipo_date = { + let raw = &item["ipo_date"]; + if raw.is_number() { + fmt_ts_opt(raw) + } else { + let s = val_str(raw); + if s.len() == 8 && s.chars().all(|c| c.is_ascii_digit()) { + format!("{}-{}-{}", &s[..4], &s[4..6], &s[6..]) + } else { + s + } + } + }; + let row = vec![ + val_str(&item["name"]), + counter_id_to_symbol(&cid), + state_stage_label(&item["state_stage"]).to_string(), + fmt_ts_opt(&item["sub_date"]), + fmt_ts_opt(&item["sub_end_date"]), + 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); } @@ -644,6 +834,17 @@ pub async fn cmd_ipo_profit_loss(period: &str, format: &OutputFormat, verbose: b verbose, ) .await?; + let mut result = serde_json::Map::new(); + if let Some(obj) = data.as_object() { + for (k, v) in obj { + if k == "updated_at" { + result.insert(k.clone(), Value::String(fmt_ts(v))); + } else { + result.insert(k.clone(), v.clone()); + } + } + } + let data = Value::Object(result); match format { OutputFormat::Json => print_json(&data), OutputFormat::Pretty => print_json_value(&data, format), @@ -822,18 +1023,17 @@ pub async fn cmd_ipo_us_listed( println!("No listed US IPO stocks found."); return Ok(()); } - let headers = ["name", "symbol", "issue_price", "listing_date"]; - 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"]), - val_str(&item["listing_date"]), - ] - }) - .collect(); + 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); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3511b1d..3bafda2 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1121,8 +1121,8 @@ pub enum Commands { #[arg(long, default_value = "1")] page: u32, /// Records per page (default: 20) - #[arg(long, default_value = "20")] - limit: u32, + #[arg(long, alias = "limit", default_value = "20")] + count: u32, }, /// List deposit history for the current account @@ -1134,8 +1134,8 @@ pub enum Commands { #[arg(long, default_value = "1")] page: u32, /// Records per page (default: 20) - #[arg(long, default_value = "20")] - limit: u32, + #[arg(long, alias = "limit", default_value = "20")] + count: u32, /// Filter by state: 0=pending, 1=credited, 2=failed (comma-separated) #[arg(long)] states: Option, @@ -1623,8 +1623,8 @@ pub enum IpoCmd { Listed { #[arg(long, default_value = "1")] page: u32, - #[arg(long, default_value = "20")] - limit: u32, + #[arg(long, alias = "limit", default_value = "20")] + count: u32, }, /// Show the IPO calendar (all upcoming and recent IPOs) Calendar, @@ -1658,8 +1658,8 @@ pub enum IpoCmd { status: Option, #[arg(long, default_value = "1")] page: u32, - #[arg(long, default_value = "10")] - limit: u32, + #[arg(long, alias = "limit", default_value = "10")] + count: u32, }, /// Check if the current user is eligible to subscribe to an IPO Eligibility { symbol: String }, @@ -1678,8 +1678,8 @@ pub enum IpoCmd { period: String, #[arg(long, default_value = "1")] page: u32, - #[arg(long, default_value = "20")] - limit: u32, + #[arg(long, alias = "limit", default_value = "20")] + count: u32, }, /// Show IPO holding portfolio detail for a symbol Holdings { symbol: String }, @@ -1694,8 +1694,8 @@ pub enum IpoCmd { UsListed { #[arg(long, default_value = "1")] page: u32, - #[arg(long, default_value = "20")] - limit: u32, + #[arg(long, alias = "limit", default_value = "20")] + count: u32, }, } @@ -2207,13 +2207,13 @@ pub enum NewsCmd { /// Search news by keyword /// /// Example: longbridge news search "AI stocks" - /// Example: longbridge news search TSLA --limit 10 + /// Example: longbridge news search TSLA --count 10 Search { /// Search keyword keyword: String, /// Maximum results to display (default: 20) - #[arg(long, default_value = "20")] - limit: usize, + #[arg(long, alias = "limit", default_value = "20")] + count: usize, }, } @@ -2330,13 +2330,13 @@ pub enum TopicCmd { /// Search community topics by keyword /// /// Example: longbridge topic search TSLA - /// Example: longbridge topic search "AI stocks" --limit 10 + /// Example: longbridge topic search "AI stocks" --count 10 Search { /// Search keyword keyword: String, /// Maximum results to display (default: 20) - #[arg(long, default_value = "20")] - limit: usize, + #[arg(long, alias = "limit", default_value = "20")] + count: usize, }, } @@ -2716,8 +2716,8 @@ 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, limit }) => { - search::cmd_search(keyword, "news", limit, format, verbose).await + Some(NewsCmd::Search { keyword, count }) => { + search::cmd_search(keyword, "news", count, format, verbose).await } None => { let sym = symbol.ok_or_else(|| { @@ -2763,8 +2763,8 @@ 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, limit }) => { - search::cmd_search(keyword, "topics", limit, format, verbose).await + Some(TopicCmd::Search { keyword, count }) => { + search::cmd_search(keyword, "topics", count, format, verbose).await } None => { let sym = symbol.ok_or_else(|| { @@ -3126,18 +3126,18 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re Commands::OrderStats => asset::cmd_order_stats(format, verbose).await, Commands::BankCards => atm::cmd_withdrawal_cards(format, verbose).await, - Commands::Withdrawals { page, limit } => { - atm::cmd_withdrawals(page, limit, format, verbose).await + Commands::Withdrawals { page, count } => { + atm::cmd_withdrawals(page, count, format, verbose).await } Commands::Deposits { page, - limit, + count, states, currencies, } => { atm::cmd_deposits( page, - limit, + count, states.as_deref(), currencies.as_deref(), format, @@ -3149,8 +3149,8 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re 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, limit } => { - ipo::cmd_ipo_listed(page, limit, 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::Info { symbol } => ipo::cmd_ipo_info(symbol, format, verbose).await, @@ -3169,22 +3169,22 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re market, status, page, - limit, - } => ipo::cmd_ipo_history(market, status, page, limit, format, verbose).await, + count, + } => ipo::cmd_ipo_history(market, status, page, count, format, verbose).await, IpoCmd::Eligibility { symbol } => { ipo::cmd_ipo_eligibility(symbol, format, verbose).await } IpoCmd::ProfitLoss { period } => { ipo::cmd_ipo_profit_loss(&period, format, verbose).await } - IpoCmd::ProfitLossItems { period, page, limit } => { - ipo::cmd_ipo_profit_loss_items(&period, page, limit, format, verbose).await + IpoCmd::ProfitLossItems { period, page, count } => { + ipo::cmd_ipo_profit_loss_items(&period, page, count, format, verbose).await } IpoCmd::Holdings { symbol } => ipo::cmd_ipo_holdings(symbol, 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, limit } => { - ipo::cmd_ipo_us_listed(page, limit, format, verbose).await + IpoCmd::UsListed { page, count } => { + ipo::cmd_ipo_us_listed(page, count, format, verbose).await } }, From 9169f04efa5acb1147dd946c2bc8c14632db2990 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 17:56:33 +0800 Subject: [PATCH 18/44] cli: Fix IPO listed/calendar date and amount display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - US listed: read ipo_date (not listing_date), format amount with format_volume - Calendar: use date-only format (YYYY-MM-DD) for all date columns via fmt_date_opt - HK listed: also use fmt_date_opt for ipo_date consistency - Add fmt_date_opt helper: unix ts > 0 → date only, 0/neg → "-", YYYYMMDD string → YYYY-MM-DD Co-Authored-By: Claude Sonnet 4.6 --- src/cli/ipo.rs | 63 ++++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index aa1c62d..0862853 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -45,6 +45,26 @@ fn fmt_ts_opt(v: &Value) -> String { } } +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", @@ -379,14 +399,7 @@ pub async fn cmd_ipo_wait_listing(format: &OutputFormat, verbose: bool) -> Resul } fn hk_listed_row(item: &Value) -> Vec { - let date = { - let s = val_str(&item["ipo_date"]); - if s.len() == 8 && s.chars().all(|c| c.is_ascii_digit()) { - format!("{}-{}-{}", &s[..4], &s[4..6], &s[6..]) - } else { - s - } - }; + let date = fmt_date_opt(&item["ipo_date"]); let amount = val_str(&item["amount"]) .parse::() .map(|n| crate::utils::number::format_volume(n)) @@ -404,14 +417,11 @@ fn hk_listed_row(item: &Value) -> Vec { } fn us_listed_row(item: &Value) -> Vec { - let date = { - let s = val_str(&item["listing_date"]); - if s.len() == 8 && s.chars().all(|c| c.is_ascii_digit()) { - format!("{}-{}-{}", &s[..4], &s[4..6], &s[6..]) - } else { - s - } - }; + let date = fmt_date_opt(&item["ipo_date"]); + let amount = val_str(&item["amount"]) + .parse::() + .map(|n| crate::utils::number::format_volume(n)) + .unwrap_or_else(|_| val_str(&item["amount"])); vec![ val_str(&item["name"]), counter_id_to_symbol(&val_str(&item["counter_id"])), @@ -419,7 +429,7 @@ fn us_listed_row(item: &Value) -> Vec { val_str(&item["last_done"]), val_str(&item["prev_close"]), val_str(&item["ipo_change"]), - val_str(&item["amount"]), + amount, date, ] } @@ -530,26 +540,13 @@ pub async fn cmd_ipo_calendar(format: &OutputFormat, verbose: bool) -> Result<() let mut other_rows: Vec> = Vec::new(); for item in list { let cid = val_str(&item["counter_id"]); - let ipo_date = { - let raw = &item["ipo_date"]; - if raw.is_number() { - fmt_ts_opt(raw) - } else { - let s = val_str(raw); - if s.len() == 8 && s.chars().all(|c| c.is_ascii_digit()) { - format!("{}-{}-{}", &s[..4], &s[4..6], &s[6..]) - } else { - s - } - } - }; let row = vec![ val_str(&item["name"]), counter_id_to_symbol(&cid), state_stage_label(&item["state_stage"]).to_string(), - fmt_ts_opt(&item["sub_date"]), - fmt_ts_opt(&item["sub_end_date"]), - ipo_date, + 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); From cb22837b36565a662636fa27c1c99d33f1b2a956 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 17:57:10 +0800 Subject: [PATCH 19/44] cli: Rename IPO listed change% column to change The ipo_change field is an absolute price change, not a percentage. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/ipo.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index 0862853..272a6a2 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -477,7 +477,7 @@ pub async fn cmd_ipo_listed( "issue_price", "last_done", "prev_close", - "change%", + "change", "amount", "ipo_date", ]; @@ -1026,7 +1026,7 @@ pub async fn cmd_ipo_us_listed( "issue_price", "last_done", "prev_close", - "change%", + "change", "amount", "ipo_date", ]; From 4d3a9e03527d7046c320773f36fae038070d03da Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 17:57:59 +0800 Subject: [PATCH 20/44] cli: Fix IPO listed change column header to change% Both HK and US ipo_change fields are percentage values. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/ipo.rs | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index 272a6a2..9707d1d 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -471,20 +471,20 @@ pub async fn cmd_ipo_listed( print_json(&Value::Object(result)); } OutputFormat::Pretty => { - let headers = [ - "name", - "symbol", - "issue_price", - "last_done", - "prev_close", - "change", - "amount", - "ipo_date", - ]; 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; @@ -496,6 +496,16 @@ pub async fn cmd_ipo_listed( 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; @@ -1026,7 +1036,7 @@ pub async fn cmd_ipo_us_listed( "issue_price", "last_done", "prev_close", - "change", + "change%", "amount", "ipo_date", ]; From 422647b1763666cf1b67a02d64fe037c425c0878 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 18:11:01 +0800 Subject: [PATCH 21/44] cli: Improve `ipo detail` Pretty output and minor fixes - Replace table layout with key-value format for `ipo detail` Pretty output - Truncate long text fields (Description, Use of Proceeds) to 200 chars - Limit Underwriters to 5 entries with (+N) suffix for extras - Remove unused `fmt_ts_opt` function - Fix `hk_listed_row`/`us_listed_row` clippy warnings Co-Authored-By: Claude Sonnet 4.6 --- src/cli/fundamental.rs | 36 +++++- src/cli/ipo.rs | 274 +++++++++++++++++++++++++++++------------ src/cli/mod.rs | 26 ++-- 3 files changed, 237 insertions(+), 99 deletions(-) diff --git a/src/cli/fundamental.rs b/src/cli/fundamental.rs index cf385e8..eb99467 100644 --- a/src/cli/fundamental.rs +++ b/src/cli/fundamental.rs @@ -1,7 +1,7 @@ use anyhow::Result; use longbridge::httpclient::Json; use reqwest::Method; -use serde_json::Value; +use serde_json::{Map, Value}; use super::OutputFormat; @@ -2157,6 +2157,40 @@ pub async fn cmd_institution_rating_industry_rank( 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("").to_string(), + )), + ); + } 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), diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index 9707d1d..4e63873 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -32,19 +32,6 @@ fn fmt_ts(v: &Value) -> String { ts.map_or_else(|| val_str(v), crate::utils::datetime::format_timestamp) } -fn fmt_ts_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_timestamp(n), - Some(_) => "-".to_string(), - None => val_str(v), - } -} - fn fmt_date_opt(v: &Value) -> String { let ts = match v { Value::Number(n) => n.as_i64(), @@ -400,10 +387,10 @@ pub async fn cmd_ipo_wait_listing(format: &OutputFormat, verbose: bool) -> Resul fn hk_listed_row(item: &Value) -> Vec { let date = fmt_date_opt(&item["ipo_date"]); - let amount = val_str(&item["amount"]) - .parse::() - .map(|n| crate::utils::number::format_volume(n)) - .unwrap_or_else(|_| val_str(&item["amount"])); + 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"])), @@ -418,10 +405,10 @@ fn hk_listed_row(item: &Value) -> Vec { fn us_listed_row(item: &Value) -> Vec { let date = fmt_date_opt(&item["ipo_date"]); - let amount = val_str(&item["amount"]) - .parse::() - .map(|n| crate::utils::number::format_volume(n)) - .unwrap_or_else(|_| val_str(&item["amount"])); + 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"])), @@ -594,75 +581,200 @@ pub async fn cmd_ipo_calendar(format: &OutputFormat, verbose: bool) -> Result<() Ok(()) } -/// Show IPO subscription page information for a symbol. -pub async fn cmd_ipo_info(symbol: String, format: &OutputFormat, verbose: bool) -> Result<()> { - let account_channel = crate::auth::account_channel_or_default(); - let cid = symbol_to_counter_id(&symbol); - let data = http_get( - "/v1/ipo/info", - &[ - ("counter_id", cid.as_str()), - ("account_channel", account_channel.as_str()), - ], - verbose, - ) - .await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json_value(&data, format), - } - Ok(()) -} - -/// Show IPO profile (prospectus summary) for a symbol. -pub async fn cmd_ipo_profile(symbol: String, format: &OutputFormat, verbose: bool) -> Result<()> { - let cid = symbol_to_counter_id(&symbol); - let data = http_get("/v1/ipo/profile", &[("counter_id", cid.as_str())], verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json_value(&data, format), - } - Ok(()) -} - -/// Show the IPO timeline for a symbol. -pub async fn cmd_ipo_timeline( +/// Show IPO detail: profile (prospectus summary) + timeline for a symbol. +pub async fn cmd_ipo_detail( symbol: String, market: &str, - flag: u8, format: &OutputFormat, verbose: bool, ) -> Result<()> { let cid = symbol_to_counter_id(&symbol); - let flag_str = flag.to_string(); - let data = http_get( - "/v1/ipo/timeline", - &[ - ("counter_id", cid.as_str()), - ("market", market), - ("flag", flag_str.as_str()), - ], - verbose, - ) - .await?; + let profile_params = [("counter_id", cid.as_str())]; + let timeline_params = [ + ("counter_id", cid.as_str()), + ("market", market), + ("flag", "0"), + ]; + let (profile_data, timeline_data) = tokio::join!( + http_get("/v1/ipo/profile", &profile_params, verbose), + http_get("/v1/ipo/timeline", &timeline_params, verbose), + ); + let profile_data = profile_data?; + let timeline_data = timeline_data?; match format { - OutputFormat::Json => print_json(&data), + OutputFormat::Json => { + let mut result = serde_json::Map::new(); + result.insert("profile".to_string(), profile_data); + result.insert("timeline".to_string(), timeline_data); + print_json(&Value::Object(result)); + } OutputFormat::Pretty => { - if let Some(timeline) = data["timeline"].as_array() { - let headers = ["date", "event", "status"]; - let rows: Vec> = timeline - .iter() - .map(|item| { - vec![ - val_str(&item["date"]), - val_str(&item["event"]), - val_str(&item["status"]), - ] - }) - .collect(); - print_table(&headers, rows, &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 { - print_json(&data); + &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!(); + } + // Timeline section + let can_sub = timeline_data["can_subscribe"].as_bool().unwrap_or(false); + let pay_end = val_str(&timeline_data["pay_end_date"]); + if can_sub || (pay_end != "-" && !pay_end.is_empty()) { + kv("Can Subscribe", if can_sub { "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); } } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3bafda2..7a341b4 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1628,19 +1628,15 @@ pub enum IpoCmd { }, /// Show the IPO calendar (all upcoming and recent IPOs) Calendar, - /// Show IPO subscription page information for a symbol - Info { symbol: String }, - /// Show IPO profile (prospectus summary) for a symbol - Profile { symbol: String }, - /// Show IPO timeline for a symbol - Timeline { + /// 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, e.g. HK + /// Market: HK (default) or US #[arg(long, default_value = "HK")] market: String, - /// Flag: 0=normal, 2=international placement - #[arg(long, default_value = "0")] - flag: u8, }, /// Show the current active IPO order status for a symbol Order { symbol: String }, @@ -3153,13 +3149,9 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re ipo::cmd_ipo_listed(page, count, format, verbose).await } IpoCmd::Calendar => ipo::cmd_ipo_calendar(format, verbose).await, - IpoCmd::Info { symbol } => ipo::cmd_ipo_info(symbol, format, verbose).await, - IpoCmd::Profile { symbol } => ipo::cmd_ipo_profile(symbol, format, verbose).await, - IpoCmd::Timeline { - symbol, - market, - flag, - } => ipo::cmd_ipo_timeline(symbol, &market, flag, format, verbose).await, + IpoCmd::Detail { symbol, market } => { + ipo::cmd_ipo_detail(symbol, &market, format, verbose).await + } IpoCmd::Order { symbol } => ipo::cmd_ipo_order(symbol, format, verbose).await, IpoCmd::Orders { symbol } => ipo::cmd_ipo_orders(symbol, format, verbose).await, IpoCmd::OrderDetail { order_id } => { From bdb711ef6194b8025b52b34e71b3af31671cc962 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 18:21:14 +0800 Subject: [PATCH 22/44] cli: Merge `ipo orders` and `ipo history` into single command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ipo orders` now fetches active orders and history in parallel - Pretty output shows "── Active ──" and "── History ──" sections - JSON output: {"orders": [...], "history": [...]} - Both sections include symbol (counter_id converted), name, qty, status, date - Remove separate `ipo history` subcommand Co-Authored-By: Claude Sonnet 4.6 --- src/cli/ipo.rs | 188 ++++++++++++++++++++++++++----------------------- src/cli/mod.rs | 32 +++++---- 2 files changed, 115 insertions(+), 105 deletions(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index 4e63873..946b317 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -182,15 +182,24 @@ fn transform_ipo_list_item(item: &Value) -> Value { Value::Object(obj) } -// Transform history item: format created_at timestamp. -fn transform_history_item(item: &Value) -> Value { +// 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 { - if k == "created_at" { - obj.insert(k.clone(), Value::String(fmt_ts(v))); - } else { - obj.insert(k.clone(), v.clone()); + 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()); + } } } } @@ -797,44 +806,102 @@ pub async fn cmd_ipo_order(symbol: String, format: &OutputFormat, verbose: bool) Ok(()) } -/// List active IPO holding orders for the current account. +/// 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 params: Vec<(&str, &str)> = vec![("account_channel", account_channel.as_str())]; + 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); - params.push(("counter_id", cid.as_str())); + 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 data = http_get("/v1/ipo/orders", ¶ms, verbose).await?; + 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 => print_json(&data), + 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 => { - if let Some(orders) = data["orders"].as_array() { - if orders.is_empty() { - println!("No active IPO orders."); - return Ok(()); + 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; } - let headers = ["id", "name", "code", "qty", "status"]; - let rows: Vec> = orders - .iter() - .map(|o| { - vec![ - val_str(&o["id"]), - val_str(&o["name"]), - val_str(&o["code"]), - val_str(&o["sub_qty"]), - val_str(&o["status"]), - ] - }) - .collect(); - print_table(&headers, rows, &OutputFormat::Pretty); - } else { - print_json(&data); + } + 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."); } } } @@ -862,65 +929,6 @@ pub async fn cmd_ipo_order_detail( Ok(()) } -/// List IPO subscription history. -pub async fn cmd_ipo_history( - market: Option, - status: Option, - page: u32, - limit: u32, - format: &OutputFormat, - verbose: bool, -) -> Result<()> { - let page_str = page.to_string(); - let limit_str = limit.to_string(); - let mut params: Vec<(&str, &str)> = - vec![("page", page_str.as_str()), ("limit", limit_str.as_str())]; - if let Some(ref m) = market { - params.push(("market", m.as_str())); - } - if let Some(ref s) = status { - params.push(("status", s.as_str())); - } - let data = http_get("/v1/ipo/orders/history", ¶ms, verbose).await?; - match format { - OutputFormat::Json => { - if let Some(arr) = data.as_array() { - let transformed: Vec = arr.iter().map(transform_history_item).collect(); - print_json(&Value::Array(transformed)); - } else { - print_json(&data); - } - } - OutputFormat::Pretty => { - if let Some(arr) = data.as_array() { - if arr.is_empty() { - println!("No IPO history found."); - return Ok(()); - } - let headers = ["id", "name", "code", "qty", "won", "status", "date"]; - let rows: Vec> = arr - .iter() - .map(|o| { - vec![ - val_str(&o["id"]), - val_str(&o["name"]), - val_str(&o["code"]), - 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); - } else { - print_json(&data); - } - } - } - Ok(()) -} - /// Check if the current user is eligible to subscribe to an IPO. pub async fn cmd_ipo_eligibility( symbol: String, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7a341b4..f1eed8e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1640,23 +1640,25 @@ pub enum IpoCmd { }, /// Show the current active IPO order status for a symbol Order { symbol: String }, - /// List active IPO holding orders for the current account - Orders { symbol: Option }, - /// Show IPO order detail by order ID - #[command(name = "order-detail")] - OrderDetail { order_id: String }, - /// List IPO subscription history - History { + /// List IPO orders (active + history) for the current account + /// + /// Example: longbridge ipo orders + /// Example: longbridge ipo orders --status 4 + Orders { + symbol: Option, #[arg(long)] market: Option, - /// Status: 0=all, 1=subscribed, 2=debit-failed, 3=not-won, 4=won, 5=cancelled + /// 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 = "10")] + #[arg(long, alias = "limit", default_value = "20")] count: u32, }, + /// Show IPO order detail by order ID + #[command(name = "order-detail")] + OrderDetail { order_id: String }, /// Check if the current user is eligible to subscribe to an IPO Eligibility { symbol: String }, /// Show IPO profit/loss summary for a period @@ -3153,16 +3155,16 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re ipo::cmd_ipo_detail(symbol, &market, format, verbose).await } IpoCmd::Order { symbol } => ipo::cmd_ipo_order(symbol, format, verbose).await, - IpoCmd::Orders { symbol } => ipo::cmd_ipo_orders(symbol, format, verbose).await, - IpoCmd::OrderDetail { order_id } => { - ipo::cmd_ipo_order_detail(order_id, format, verbose).await - } - IpoCmd::History { + IpoCmd::Orders { + symbol, market, status, page, count, - } => ipo::cmd_ipo_history(market, status, page, count, format, verbose).await, + } => ipo::cmd_ipo_orders(symbol, market, status, page, count, format, verbose).await, + IpoCmd::OrderDetail { order_id } => { + ipo::cmd_ipo_order_detail(order_id, format, verbose).await + } IpoCmd::Eligibility { symbol } => { ipo::cmd_ipo_eligibility(symbol, format, verbose).await } From 33936528659ca394cb12e346e8e39922243d156a Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 18:31:06 +0800 Subject: [PATCH 23/44] cli: Align `ipo order` with top-level `order` command style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `orders` → `order` (list active + history) - Make `order detail ` a subcommand (was `order-detail`) - Remove the now-redundant `order ` (active-order by symbol) - Rewrite `order detail` Pretty output with kv format + timeline table Co-Authored-By: Claude Sonnet 4.6 --- src/cli/ipo.rs | 101 ++++++++++++++++++++++++++++++++++++++++--------- src/cli/mod.rs | 46 +++++++++++++--------- 2 files changed, 113 insertions(+), 34 deletions(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index 946b317..ed4f628 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -790,22 +790,6 @@ pub async fn cmd_ipo_detail( Ok(()) } -/// Show the current active IPO order status for a symbol. -pub async fn cmd_ipo_order(symbol: String, format: &OutputFormat, verbose: bool) -> Result<()> { - let cid = symbol_to_counter_id(&symbol); - let data = http_get( - "/v1/ipo/active-order", - &[("counter_id", cid.as_str())], - verbose, - ) - .await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json_value(&data, format), - } - Ok(()) -} - /// List IPO orders (active + history) for the current account. pub async fn cmd_ipo_orders( symbol: Option, @@ -924,7 +908,90 @@ pub async fn cmd_ipo_order_detail( .await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json_value(&data, format), + 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(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f1eed8e..f1e5b1b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1638,14 +1638,13 @@ pub enum IpoCmd { #[arg(long, default_value = "HK")] market: String, }, - /// Show the current active IPO order status for a symbol - Order { symbol: String }, - /// List IPO orders (active + history) for the current account + /// IPO orders (active + history) for the current account /// - /// Example: longbridge ipo orders - /// Example: longbridge ipo orders --status 4 - Orders { - symbol: Option, + /// Without a subcommand, lists active and historical orders. + /// Example: longbridge ipo order + /// Example: longbridge ipo order --status 4 + /// Example: longbridge ipo order detail 2452504 + Order { #[arg(long)] market: Option, /// Status filter for history: 0=all, 1=subscribed, 2=debit-failed, 3=not-won, 4=won, 5=cancelled @@ -1655,10 +1654,9 @@ pub enum IpoCmd { page: u32, #[arg(long, alias = "limit", default_value = "20")] count: u32, + #[command(subcommand)] + cmd: Option, }, - /// Show IPO order detail by order ID - #[command(name = "order-detail")] - OrderDetail { order_id: String }, /// Check if the current user is eligible to subscribe to an IPO Eligibility { symbol: String }, /// Show IPO profit/loss summary for a period @@ -1697,6 +1695,17 @@ pub enum IpoCmd { }, } +#[derive(Subcommand)] +pub enum IpoOrderCmd { + /// Full detail for a single IPO order + /// + /// Example: longbridge ipo order 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) @@ -3154,17 +3163,20 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re IpoCmd::Detail { symbol, market } => { ipo::cmd_ipo_detail(symbol, &market, format, verbose).await } - IpoCmd::Order { symbol } => ipo::cmd_ipo_order(symbol, format, verbose).await, - IpoCmd::Orders { - symbol, + IpoCmd::Order { market, status, page, count, - } => ipo::cmd_ipo_orders(symbol, market, status, page, count, format, verbose).await, - IpoCmd::OrderDetail { order_id } => { - ipo::cmd_ipo_order_detail(order_id, format, verbose).await - } + 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::Eligibility { symbol } => { ipo::cmd_ipo_eligibility(symbol, format, verbose).await } From dd0b88f5a03d5a6be8616203f84d6bcc5c2e8bd9 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 18:34:02 +0800 Subject: [PATCH 24/44] cli: Remove indent from ipo detail kv output Co-Authored-By: Claude Sonnet 4.6 --- src/cli/ipo.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index ed4f628..1cc6168 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -619,7 +619,7 @@ pub async fn cmd_ipo_detail( } OutputFormat::Pretty => { let kv = |label: &str, value: &str| { - println!(" {:<24}{value}", format!("{label}:")); + println!("{:<24}{value}", format!("{label}:")); }; let trunc = |s: String, max: usize| -> String { if s.chars().count() > max { @@ -910,7 +910,7 @@ pub async fn cmd_ipo_order_detail( OutputFormat::Json => print_json(&data), OutputFormat::Pretty => { let kv = |label: &str, value: &str| { - println!(" {:<24}{value}", format!("{label}:")); + println!("{:<24}{value}", format!("{label}:")); }; kv( "Symbol", From e15a4ecc0d20c3b8c385d51fd666e3a570a11a8d Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 18:36:06 +0800 Subject: [PATCH 25/44] cli: Restore `ipo orders` plural name Co-Authored-By: Claude Sonnet 4.6 --- src/cli/mod.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f1e5b1b..26a04ef 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1641,10 +1641,10 @@ pub enum IpoCmd { /// IPO orders (active + history) for the current account /// /// Without a subcommand, lists active and historical orders. - /// Example: longbridge ipo order - /// Example: longbridge ipo order --status 4 - /// Example: longbridge ipo order detail 2452504 - Order { + /// 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 @@ -1699,7 +1699,7 @@ pub enum IpoCmd { pub enum IpoOrderCmd { /// Full detail for a single IPO order /// - /// Example: longbridge ipo order detail 2452504 + /// Example: longbridge ipo orders detail 2452504 Detail { /// IPO order ID order_id: String, @@ -3163,7 +3163,7 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re IpoCmd::Detail { symbol, market } => { ipo::cmd_ipo_detail(symbol, &market, format, verbose).await } - IpoCmd::Order { + IpoCmd::Orders { market, status, page, From 4b8a758de4d864dc194ff919cbd897cf5b4677dd Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 18:43:06 +0800 Subject: [PATCH 26/44] cli: Merge `ipo eligibility` into `ipo detail` Fetch eligibility in parallel with profile and timeline; show "Can Subscribe" in the Pretty output. Remove the standalone `ipo eligibility` subcommand. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/ipo.rs | 33 +++++++++++---------------------- src/cli/mod.rs | 7 +------ 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index 1cc6168..f4933b1 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -604,17 +604,21 @@ pub async fn cmd_ipo_detail( ("market", market), ("flag", "0"), ]; - let (profile_data, timeline_data) = tokio::join!( + let eligibility_params = [("counter_id", cid.as_str())]; + let (profile_data, timeline_data, eligibility_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), ); let profile_data = profile_data?; let timeline_data = timeline_data?; + let eligibility_data = eligibility_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); print_json(&Value::Object(result)); } OutputFormat::Pretty => { @@ -758,11 +762,14 @@ pub async fn cmd_ipo_detail( } println!(); } - // Timeline section + // 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 can_sub || (pay_end != "-" && !pay_end.is_empty()) { - kv("Can Subscribe", if can_sub { "Yes" } else { "No" }); + 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); } @@ -997,24 +1004,6 @@ pub async fn cmd_ipo_order_detail( } /// Check if the current user is eligible to subscribe to an IPO. -pub async fn cmd_ipo_eligibility( - symbol: String, - format: &OutputFormat, - verbose: bool, -) -> Result<()> { - let cid = symbol_to_counter_id(&symbol); - let data = http_get( - "/v1/ipo/eligibility", - &[("counter_id", cid.as_str())], - verbose, - ) - .await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json_value(&data, format), - } - Ok(()) -} /// Show IPO profit/loss summary for the given period. pub async fn cmd_ipo_profit_loss(period: &str, format: &OutputFormat, verbose: bool) -> Result<()> { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 26a04ef..6f59907 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1657,8 +1657,6 @@ pub enum IpoCmd { #[command(subcommand)] cmd: Option, }, - /// Check if the current user is eligible to subscribe to an IPO - Eligibility { symbol: String }, /// Show IPO profit/loss summary for a period #[command(name = "profit-loss")] ProfitLoss { @@ -3177,10 +3175,7 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re ipo::cmd_ipo_orders(None, market, status, page, count, format, verbose).await } }, - IpoCmd::Eligibility { symbol } => { - ipo::cmd_ipo_eligibility(symbol, format, verbose).await - } - IpoCmd::ProfitLoss { period } => { +IpoCmd::ProfitLoss { period } => { ipo::cmd_ipo_profit_loss(&period, format, verbose).await } IpoCmd::ProfitLossItems { period, page, count } => { From f3565f18b9569774e5ea0a22fedd210052633659 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 18:49:14 +0800 Subject: [PATCH 27/44] cli: Merge `ipo holdings`, `ipo profit-loss-items` into detail/profit-loss - Merge `ipo holdings` into `ipo detail`: fetches holdings in parallel with profile/timeline/eligibility; Pretty output shows holdings fields when non-zero; removes standalone `ipo holdings` command - Merge `ipo profit-loss-items` into `ipo profit-loss`: fetches summary and items in parallel; JSON output wraps both under "summary"/"items" keys; Pretty shows summary kv then items table; removes standalone `ipo profit-loss-items` command Co-Authored-By: Claude Sonnet 4.6 --- src/cli/fundamental.rs | 2 +- src/cli/ipo.rs | 162 ++++++++++++++++++++++++----------------- src/cli/mod.rs | 19 +---- 3 files changed, 98 insertions(+), 85 deletions(-) diff --git a/src/cli/fundamental.rs b/src/cli/fundamental.rs index eb99467..3844949 100644 --- a/src/cli/fundamental.rs +++ b/src/cli/fundamental.rs @@ -2172,7 +2172,7 @@ pub async fn cmd_institution_rating_industry_rank( o.insert( "symbol".to_string(), Value::String(counter_id_to_symbol( - &iv.as_str().unwrap_or("").to_string(), + iv.as_str().unwrap_or(""), )), ); } else { diff --git a/src/cli/ipo.rs b/src/cli/ipo.rs index f4933b1..a1d4fbf 100644 --- a/src/cli/ipo.rs +++ b/src/cli/ipo.rs @@ -598,6 +598,7 @@ pub async fn cmd_ipo_detail( 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()), @@ -605,20 +606,28 @@ pub async fn cmd_ipo_detail( ("flag", "0"), ]; let eligibility_params = [("counter_id", cid.as_str())]; - let (profile_data, timeline_data, eligibility_data) = tokio::join!( + 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 => { @@ -792,6 +801,31 @@ pub async fn cmd_ipo_detail( } 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(()) @@ -1003,40 +1037,8 @@ pub async fn cmd_ipo_order_detail( Ok(()) } -/// Check if the current user is eligible to subscribe to an IPO. - -/// Show IPO profit/loss summary for the given period. -pub async fn cmd_ipo_profit_loss(period: &str, format: &OutputFormat, verbose: bool) -> Result<()> { - let account_channel = crate::auth::account_channel_or_default(); - let data = http_get( - "/v1/ipo/profit-loss", - &[ - ("period", period), - ("account_channel", account_channel.as_str()), - ], - verbose, - ) - .await?; - let mut result = serde_json::Map::new(); - if let Some(obj) = data.as_object() { - for (k, v) in obj { - if k == "updated_at" { - result.insert(k.clone(), Value::String(fmt_ts(v))); - } else { - result.insert(k.clone(), v.clone()); - } - } - } - let data = Value::Object(result); - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json_value(&data, format), - } - Ok(()) -} - -/// List IPO profit/loss items for the given period. -pub async fn cmd_ipo_profit_loss_items( +/// Show IPO profit/loss summary + items for the given period. +pub async fn cmd_ipo_profit_loss( period: &str, page: u32, limit: u32, @@ -1046,41 +1048,65 @@ pub async fn cmd_ipo_profit_loss_items( 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/ipo/profit-loss/items", - &[ - ("period", period), - ("page", page_str.as_str()), - ("size", size_str.as_str()), - ("account_channel", account_channel.as_str()), - ], - verbose, - ) - .await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json_value(&data, format), + 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()); + } + } } - Ok(()) -} - -/// Show IPO holding portfolio detail for a symbol. -pub async fn cmd_ipo_holdings(symbol: String, format: &OutputFormat, verbose: bool) -> Result<()> { - let account_channel = crate::auth::account_channel_or_default(); - let cid = symbol_to_counter_id(&symbol); - let data = http_get( - "/v1/ipo/holdings", - &[ - ("counter_id", cid.as_str()), - ("need_realtime", "true"), - ("account_channel", account_channel.as_str()), - ], - verbose, - ) - .await?; + let summary_val = Value::Object(summary); match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json_value(&data, 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(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6f59907..f2ef55e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1657,26 +1657,17 @@ pub enum IpoCmd { #[command(subcommand)] cmd: Option, }, - /// Show IPO profit/loss summary for a period + /// 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, - }, - /// List IPO profit/loss items for a period - #[command(name = "profit-loss-items")] - ProfitLossItems { - /// 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, }, - /// Show IPO holding portfolio detail for a symbol - Holdings { symbol: String }, /// List US IPO stocks currently in subscription stage #[command(name = "us-subscriptions")] UsSubscriptions, @@ -3175,13 +3166,9 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re ipo::cmd_ipo_orders(None, market, status, page, count, format, verbose).await } }, -IpoCmd::ProfitLoss { period } => { - ipo::cmd_ipo_profit_loss(&period, format, verbose).await - } - IpoCmd::ProfitLossItems { period, page, count } => { - ipo::cmd_ipo_profit_loss_items(&period, page, count, format, verbose).await +IpoCmd::ProfitLoss { period, page, count } => { + ipo::cmd_ipo_profit_loss(&period, page, count, format, verbose).await } - IpoCmd::Holdings { symbol } => ipo::cmd_ipo_holdings(symbol, 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 } => { From 9e72b98ae89bb4e29df5d61ee24d8d8c95c89304 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 18:59:23 +0800 Subject: [PATCH 28/44] cli: Improve `financial-statement` Pretty output; remove `order-stats` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite `financial-statement` Pretty output as a multi-period table: rows = metrics with level-based indentation, columns = up to 5 periods (FY or Q labels), rightmost column shows YoY for the latest period - Format large numbers with B/M/K/T suffixes; format YoY as ±X.X% - Fix CJK alignment using unicode-width for correct terminal column math - Change default `--kind` from non-functional ALL to IS - Remove `order-stats` command (endpoint /v1/asset/trade-analysis removed) Co-Authored-By: Claude Sonnet 4.6 --- src/cli/asset.rs | 11 --- src/cli/fundamental.rs | 167 ++++++++++++++++++++++++++++++++++++++++- src/cli/mod.rs | 9 +-- 3 files changed, 167 insertions(+), 20 deletions(-) diff --git a/src/cli/asset.rs b/src/cli/asset.rs index 003f004..1dd84c8 100644 --- a/src/cli/asset.rs +++ b/src/cli/asset.rs @@ -569,14 +569,3 @@ pub async fn cmd_trade_info(symbol: String, format: &OutputFormat, verbose: bool } Ok(()) } - -// ── order stats (today's account trade summary) ─────────────────────────────── - -pub async fn cmd_order_stats(format: &OutputFormat, verbose: bool) -> Result<()> { - let data = http_get("/v1/asset/trade-analysis", &[], verbose).await?; - match format { - OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json_value(&data, format), - } - Ok(()) -} diff --git a/src/cli/fundamental.rs b/src/cli/fundamental.rs index 3844949..e0204fe 100644 --- a/src/cli/fundamental.rs +++ b/src/cli/fundamental.rs @@ -2,6 +2,7 @@ use anyhow::Result; use longbridge::httpclient::Json; use reqwest::Method; use serde_json::{Map, Value}; +use unicode_width::UnicodeWidthStr; use super::OutputFormat; @@ -1996,6 +1997,78 @@ fn print_invest_relation(data: &Value) { // ── 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, @@ -2016,7 +2089,99 @@ pub async fn cmd_financial_statement( .await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_kv(&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(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f2ef55e..8143d72 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1047,7 +1047,7 @@ pub enum Commands { /// Symbol in . format symbol: String, /// Statement type: IS (income), BS (balance sheet), CF (cash flow), ALL - #[arg(long, value_name = "TYPE", default_value = "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")] @@ -1099,12 +1099,6 @@ pub enum Commands { symbol: String, }, - /// Account-level order trade analysis / statistics - /// - /// Example: longbridge order-stats - #[command(name = "order-stats")] - OrderStats, - // ── ATM (new) ──────────────────────────────────────────────────────────── /// List bank cards for the current account /// @@ -3119,7 +3113,6 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re asset::cmd_holding_period(symbol, format, verbose).await } Commands::TradeInfo { symbol } => asset::cmd_trade_info(symbol, format, verbose).await, - Commands::OrderStats => asset::cmd_order_stats(format, verbose).await, Commands::BankCards => atm::cmd_withdrawal_cards(format, verbose).await, Commands::Withdrawals { page, count } => { From 69ab08fa1e8d7dd116851160bcdfa9ddfc82a204 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:01:18 +0800 Subject: [PATCH 29/44] cli: Normalize `financial-statement` kind/report case kind is uppercased, report is lowercased before sending to the API. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/fundamental.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cli/fundamental.rs b/src/cli/fundamental.rs index e0204fe..88795e4 100644 --- a/src/cli/fundamental.rs +++ b/src/cli/fundamental.rs @@ -2077,12 +2077,14 @@ pub async fn cmd_financial_statement( 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), - ("report", report), + ("kind", kind_upper.as_str()), + ("report", report_lower.as_str()), ], verbose, ) From 06fe0ba43d09d889d6f5c4143ec9db64c55616ac Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:07:46 +0800 Subject: [PATCH 30/44] cli: Improve `valuation-rank` output; fix `analyst-estimates` item param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite `valuation-rank` Pretty output as a date × metric table with rank/total cells (e.g. 21/50); default start/end to last year–today - Add `--item` flag to `analyst-estimates` (default: EPS); the API returns 501400 without it Co-Authored-By: Claude Sonnet 4.6 --- src/cli/fundamental.rs | 103 +++++++++++++++++++++++++++++++++++++---- src/cli/mod.rs | 19 ++++---- 2 files changed, 106 insertions(+), 16 deletions(-) diff --git a/src/cli/fundamental.rs b/src/cli/fundamental.rs index 88795e4..0b1046a 100644 --- a/src/cli/fundamental.rs +++ b/src/cli/fundamental.rs @@ -2213,25 +2213,111 @@ pub async fn cmd_financial_report_latest( pub async fn cmd_valuation_rank( symbol: String, - start: &str, - end: &str, + start: Option<&str>, + end: Option<&str>, format: &OutputFormat, verbose: bool, ) -> Result<()> { - let cid = symbol_to_counter_id(&symbol); + 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_year_ago = now - time::Duration::days(365); + format!( + "{:04}{:02}{:02}", + one_year_ago.year(), + one_year_ago.month() as u8, + one_year_ago.day() + ) + }, + str::to_string, + ); + let counter_id = symbol_to_counter_id(&symbol); let data = http_get( "/v1/quote/valuation/rank", &[ - ("counter_id", cid.as_str()), - ("start_date", start), - ("end_date", end), + ("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 => print_kv(&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(()) } @@ -2240,13 +2326,14 @@ pub async fn cmd_valuation_rank( pub async fn cmd_analyst_estimates( symbol: String, + item: &str, format: &OutputFormat, verbose: bool, ) -> Result<()> { let cid = symbol_to_counter_id(&symbol); let data = http_get( "/v1/quote/estimates", - &[("counter_id", cid.as_str())], + &[("counter_id", cid.as_str()), ("item", item)], verbose, ) .await?; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8143d72..737fdf7 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1060,21 +1060,24 @@ pub enum Commands { ValuationRank { /// Symbol in . format symbol: String, - /// Start date YYYYMMDD + /// Start date YYYYMMDD (default: 1 year ago) #[arg(long)] - start: String, - /// End date YYYYMMDD + start: Option, + /// End date YYYYMMDD (default: today) #[arg(long)] - end: String, + end: Option, }, /// Analyst consensus estimates (EPS, revenue, ratings) for a symbol /// /// Example: longbridge analyst-estimates TSLA.US - /// Example: longbridge analyst-estimates 700.HK --format json + /// Example: longbridge analyst-estimates TSLA.US --item EPS AnalystEstimates { /// Symbol in . format symbol: String, + /// Indicator type, e.g. EPS, revenue + #[arg(long, default_value = "EPS")] + item: String, }, // ── Asset (new) ────────────────────────────────────────────────────────── @@ -3102,10 +3105,10 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re fundamental::cmd_financial_statement(symbol, &kind, &report, format, verbose).await } Commands::ValuationRank { symbol, start, end } => { - fundamental::cmd_valuation_rank(symbol, &start, &end, format, verbose).await + 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::AnalystEstimates { symbol, item } => { + fundamental::cmd_analyst_estimates(symbol, &item, format, verbose).await } Commands::ShortMargin => asset::cmd_short_margin(format, verbose).await, From 5359a8329178e2444477c8c6d2c7a6268a382080 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:09:16 +0800 Subject: [PATCH 31/44] cli: Change valuation-rank default range to 1 month Co-Authored-By: Claude Sonnet 4.6 --- src/cli/fundamental.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cli/fundamental.rs b/src/cli/fundamental.rs index 0b1046a..68235e4 100644 --- a/src/cli/fundamental.rs +++ b/src/cli/fundamental.rs @@ -2225,12 +2225,12 @@ pub async fn cmd_valuation_rank( ); let start_date = start.map_or_else( || { - let one_year_ago = now - time::Duration::days(365); + let one_month_ago = now - time::Duration::days(30); format!( "{:04}{:02}{:02}", - one_year_ago.year(), - one_year_ago.month() as u8, - one_year_ago.day() + one_month_ago.year(), + one_month_ago.month() as u8, + one_month_ago.day() ) }, str::to_string, From bfe9846a67bb78bc6e7e620b697a7d369e6be927 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:10:08 +0800 Subject: [PATCH 32/44] cli: Hardcode item=EPS in analyst-estimates, remove --item param Co-Authored-By: Claude Sonnet 4.6 --- src/cli/fundamental.rs | 3 +-- src/cli/mod.rs | 10 +++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/cli/fundamental.rs b/src/cli/fundamental.rs index 68235e4..530e1c5 100644 --- a/src/cli/fundamental.rs +++ b/src/cli/fundamental.rs @@ -2326,14 +2326,13 @@ pub async fn cmd_valuation_rank( pub async fn cmd_analyst_estimates( symbol: String, - item: &str, 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", item)], + &[("counter_id", cid.as_str()), ("item", "EPS")], verbose, ) .await?; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 737fdf7..369c1fa 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1068,16 +1068,12 @@ pub enum Commands { end: Option, }, - /// Analyst consensus estimates (EPS, revenue, ratings) for a symbol + /// Analyst consensus estimates (EPS) for a symbol /// /// Example: longbridge analyst-estimates TSLA.US - /// Example: longbridge analyst-estimates TSLA.US --item EPS AnalystEstimates { /// Symbol in . format symbol: String, - /// Indicator type, e.g. EPS, revenue - #[arg(long, default_value = "EPS")] - item: String, }, // ── Asset (new) ────────────────────────────────────────────────────────── @@ -3107,8 +3103,8 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re Commands::ValuationRank { symbol, start, end } => { fundamental::cmd_valuation_rank(symbol, start.as_deref(), end.as_deref(), format, verbose).await } - Commands::AnalystEstimates { symbol, item } => { - fundamental::cmd_analyst_estimates(symbol, &item, format, verbose).await + Commands::AnalystEstimates { symbol } => { + fundamental::cmd_analyst_estimates(symbol, format, verbose).await } Commands::ShortMargin => asset::cmd_short_margin(format, verbose).await, From 90875cb58bb768dbefb6dd2b606f0cb7c61041af Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:16:29 +0800 Subject: [PATCH 33/44] cli: Move short-margin under asset subcommand group Co-Authored-By: Claude Sonnet 4.6 --- src/cli/mod.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 369c1fa..242cf6f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1077,10 +1077,13 @@ pub enum Commands { }, // ── Asset (new) ────────────────────────────────────────────────────────── - /// Short-selling margin deposit details for the current account + /// Asset subcommands /// - /// Example: longbridge short-margin - ShortMargin, + /// Example: longbridge asset short-margin + Asset { + #[command(subcommand)] + cmd: AssetCmd, + }, /// Stock holding period breakdown for a symbol /// @@ -1606,6 +1609,15 @@ pub enum InstitutionRatingCmd { }, } +#[derive(Subcommand)] +pub enum AssetCmd { + /// Short-selling margin deposit details for the current account + /// + /// Example: longbridge asset short-margin + #[command(name = "short-margin")] + ShortMargin, +} + #[derive(Subcommand)] pub enum IpoCmd { /// List IPO stocks currently in filing or subscription stage @@ -3107,7 +3119,9 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re fundamental::cmd_analyst_estimates(symbol, format, verbose).await } - Commands::ShortMargin => asset::cmd_short_margin(format, verbose).await, + Commands::Asset { cmd } => match cmd { + AssetCmd::ShortMargin => asset::cmd_short_margin(format, verbose).await, + }, Commands::HoldingPeriod { symbol } => { asset::cmd_holding_period(symbol, format, verbose).await } From 7944d1df5e4ff7d877ac78eedc32a713b9b2dd0b Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:19:20 +0800 Subject: [PATCH 34/44] cli: Move short-margin under portfolio subcommand group Co-Authored-By: Claude Sonnet 4.6 --- src/cli/mod.rs | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 242cf6f..3a69a39 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -645,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 /// @@ -1077,14 +1082,6 @@ pub enum Commands { }, // ── Asset (new) ────────────────────────────────────────────────────────── - /// Asset subcommands - /// - /// Example: longbridge asset short-margin - Asset { - #[command(subcommand)] - cmd: AssetCmd, - }, - /// Stock holding period breakdown for a symbol /// /// Example: longbridge holding-period TSLA.US @@ -1610,10 +1607,10 @@ pub enum InstitutionRatingCmd { } #[derive(Subcommand)] -pub enum AssetCmd { +pub enum PortfolioCmd { /// Short-selling margin deposit details for the current account /// - /// Example: longbridge asset short-margin + /// Example: longbridge portfolio short-margin #[command(name = "short-margin")] ShortMargin, } @@ -2889,7 +2886,10 @@ 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, @@ -3119,9 +3119,6 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re fundamental::cmd_analyst_estimates(symbol, format, verbose).await } - Commands::Asset { cmd } => match cmd { - AssetCmd::ShortMargin => asset::cmd_short_margin(format, verbose).await, - }, Commands::HoldingPeriod { symbol } => { asset::cmd_holding_period(symbol, format, verbose).await } From 3f6086b24c0c13e74e5c030e43ce40b8da118747 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:24:23 +0800 Subject: [PATCH 35/44] cli: Support multiple symbols in holding-period; default to current positions Co-Authored-By: Claude Sonnet 4.6 --- src/cli/asset.rs | 27 +++++++++++++++++++-------- src/cli/mod.rs | 11 ++++++----- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/cli/asset.rs b/src/cli/asset.rs index 1dd84c8..c97335f 100644 --- a/src/cli/asset.rs +++ b/src/cli/asset.rs @@ -535,17 +535,28 @@ pub async fn cmd_short_margin(format: &OutputFormat, verbose: bool) -> Result<() // ── holding period ──────────────────────────────────────────────────────────── pub async fn cmd_holding_period( - symbol: String, + symbols: Vec, format: &OutputFormat, verbose: bool, ) -> Result<()> { - let cid = crate::utils::counter::symbol_to_counter_id(&symbol); - let data = http_get( - "/v1/asset/positions/holding-period", - &[("counter_id", cid.as_str())], - verbose, - ) - .await?; + 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| crate::utils::counter::symbol_to_counter_id(s)) + .collect(); + let params: Vec<(&str, &str)> = cids + .iter() + .map(|cid| ("counter_ids[]", cid.as_str())) + .collect(); + let data = http_get("/v1/asset/positions/holding-period", ¶ms, verbose).await?; match format { OutputFormat::Json => print_json(&data), OutputFormat::Pretty => print_json_value(&data, format), diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3a69a39..4e91248 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1082,12 +1082,13 @@ pub enum Commands { }, // ── Asset (new) ────────────────────────────────────────────────────────── - /// Stock holding period breakdown for a symbol + /// Stock holding period breakdown for one or more symbols /// /// Example: longbridge holding-period TSLA.US + /// Example: longbridge holding-period TSLA.US 700.HK AAPL.US HoldingPeriod { - /// Symbol in . format - symbol: String, + /// One or more symbols in . format + symbols: Vec, }, /// Trade-order detail and cash snapshot for a symbol (order entry page) @@ -3119,8 +3120,8 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re fundamental::cmd_analyst_estimates(symbol, format, verbose).await } - Commands::HoldingPeriod { symbol } => { - asset::cmd_holding_period(symbol, format, verbose).await + Commands::HoldingPeriod { symbols } => { + asset::cmd_holding_period(symbols, format, verbose).await } Commands::TradeInfo { symbol } => asset::cmd_trade_info(symbol, format, verbose).await, From 08ba531d9fcf1eb0fdff4df1863559d845b69ff9 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:25:50 +0800 Subject: [PATCH 36/44] cli: Improve holding-period Pretty output as symbol/days table Co-Authored-By: Claude Sonnet 4.6 --- src/cli/asset.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/cli/asset.rs b/src/cli/asset.rs index c97335f..77090ec 100644 --- a/src/cli/asset.rs +++ b/src/cli/asset.rs @@ -559,7 +559,30 @@ pub async fn cmd_holding_period( let data = http_get("/v1/asset/positions/holding-period", ¶ms, verbose).await?; match format { OutputFormat::Json => print_json(&data), - OutputFormat::Pretty => print_json_value(&data, format), + 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(()) } From 553177869a280dc22e03080d474f93fcee39ad71 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:28:09 +0800 Subject: [PATCH 37/44] cli: Move holding-period under portfolio subcommand group Co-Authored-By: Claude Sonnet 4.6 --- src/cli/mod.rs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 4e91248..6e19b9c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1082,15 +1082,6 @@ pub enum Commands { }, // ── Asset (new) ────────────────────────────────────────────────────────── - /// Stock holding period breakdown for one or more symbols - /// - /// Example: longbridge holding-period TSLA.US - /// Example: longbridge holding-period TSLA.US 700.HK AAPL.US - HoldingPeriod { - /// One or more symbols in . format - symbols: Vec, - }, - /// Trade-order detail and cash snapshot for a symbol (order entry page) /// /// Example: longbridge trade-info TSLA.US @@ -1614,6 +1605,16 @@ pub enum PortfolioCmd { /// Example: longbridge portfolio short-margin #[command(name = "short-margin")] ShortMargin, + + /// Stock holding period breakdown (defaults to current positions) + /// + /// Example: longbridge portfolio holding-period + /// Example: longbridge portfolio holding-period TSLA.US 700.HK + #[command(name = "holding-period")] + HoldingPeriod { + /// One or more symbols in . format + symbols: Vec, + }, } #[derive(Subcommand)] @@ -2890,6 +2891,9 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re Commands::Portfolio { cmd } => match cmd { None => trade::cmd_portfolio(format).await, Some(PortfolioCmd::ShortMargin) => asset::cmd_short_margin(format, verbose).await, + Some(PortfolioCmd::HoldingPeriod { symbols }) => { + asset::cmd_holding_period(symbols, format, verbose).await + } }, Commands::Positions => trade::cmd_positions(format).await, Commands::FundPositions => trade::cmd_fund_positions(format).await, @@ -3120,9 +3124,7 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re fundamental::cmd_analyst_estimates(symbol, format, verbose).await } - Commands::HoldingPeriod { symbols } => { - asset::cmd_holding_period(symbols, format, verbose).await - } + Commands::TradeInfo { symbol } => asset::cmd_trade_info(symbol, format, verbose).await, Commands::BankCards => atm::cmd_withdrawal_cards(format, verbose).await, From a269d1fd5dfe95c14a39d89ab019208240bc657f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:28:53 +0800 Subject: [PATCH 38/44] cli: Move trade-info under portfolio subcommand group Co-Authored-By: Claude Sonnet 4.6 --- src/cli/mod.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6e19b9c..37bcaec 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1082,14 +1082,6 @@ pub enum Commands { }, // ── Asset (new) ────────────────────────────────────────────────────────── - /// Trade-order detail and cash snapshot for a symbol (order entry page) - /// - /// Example: longbridge trade-info TSLA.US - TradeInfo { - /// Symbol in . format - symbol: String, - }, - // ── ATM (new) ──────────────────────────────────────────────────────────── /// List bank cards for the current account /// @@ -1615,6 +1607,15 @@ pub enum PortfolioCmd { /// One or more symbols in . format symbols: Vec, }, + + /// Trade-order detail and cash snapshot for a symbol (order entry page) + /// + /// Example: longbridge portfolio trade-info TSLA.US + #[command(name = "trade-info")] + TradeInfo { + /// Symbol in . format + symbol: String, + }, } #[derive(Subcommand)] @@ -2894,6 +2895,9 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re Some(PortfolioCmd::HoldingPeriod { symbols }) => { asset::cmd_holding_period(symbols, format, verbose).await } + Some(PortfolioCmd::TradeInfo { symbol }) => { + asset::cmd_trade_info(symbol, format, verbose).await + } }, Commands::Positions => trade::cmd_positions(format).await, Commands::FundPositions => trade::cmd_fund_positions(format).await, @@ -3125,8 +3129,6 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re } - Commands::TradeInfo { symbol } => asset::cmd_trade_info(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 From d175c782577297b976fa0345297507843cc2f70b Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:32:55 +0800 Subject: [PATCH 39/44] cli: Remove trade-info command Co-Authored-By: Claude Sonnet 4.6 --- src/cli/mod.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 37bcaec..a146e17 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1607,15 +1607,6 @@ pub enum PortfolioCmd { /// One or more symbols in . format symbols: Vec, }, - - /// Trade-order detail and cash snapshot for a symbol (order entry page) - /// - /// Example: longbridge portfolio trade-info TSLA.US - #[command(name = "trade-info")] - TradeInfo { - /// Symbol in . format - symbol: String, - }, } #[derive(Subcommand)] @@ -2895,9 +2886,6 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re Some(PortfolioCmd::HoldingPeriod { symbols }) => { asset::cmd_holding_period(symbols, format, verbose).await } - Some(PortfolioCmd::TradeInfo { symbol }) => { - asset::cmd_trade_info(symbol, format, verbose).await - } }, Commands::Positions => trade::cmd_positions(format).await, Commands::FundPositions => trade::cmd_fund_positions(format).await, From c436d43299b438f62f6cd29b94470eaad56e31ba Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:34:44 +0800 Subject: [PATCH 40/44] cli: Change holding-period to POST with JSON body Co-Authored-By: Claude Sonnet 4.6 --- src/cli/asset.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/cli/asset.rs b/src/cli/asset.rs index 77090ec..eb51896 100644 --- a/src/cli/asset.rs +++ b/src/cli/asset.rs @@ -548,15 +548,12 @@ pub async fn cmd_holding_period( } else { symbols }; - let cids: Vec = resolved + let cids: Vec = resolved .iter() - .map(|s| crate::utils::counter::symbol_to_counter_id(s)) + .map(|s| serde_json::Value::String(crate::utils::counter::symbol_to_counter_id(s))) .collect(); - let params: Vec<(&str, &str)> = cids - .iter() - .map(|cid| ("counter_ids[]", cid.as_str())) - .collect(); - let data = http_get("/v1/asset/positions/holding-period", ¶ms, verbose).await?; + 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 => { From 87f5a5fd3de9f642742f1d4463876d035e6ccb25 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:37:55 +0800 Subject: [PATCH 41/44] cli: Remove holding-period command Co-Authored-By: Claude Sonnet 4.6 --- src/cli/mod.rs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a146e17..c51b2ac 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1597,16 +1597,6 @@ pub enum PortfolioCmd { /// Example: longbridge portfolio short-margin #[command(name = "short-margin")] ShortMargin, - - /// Stock holding period breakdown (defaults to current positions) - /// - /// Example: longbridge portfolio holding-period - /// Example: longbridge portfolio holding-period TSLA.US 700.HK - #[command(name = "holding-period")] - HoldingPeriod { - /// One or more symbols in . format - symbols: Vec, - }, } #[derive(Subcommand)] @@ -2883,9 +2873,7 @@ pub async fn dispatch(cmd: Commands, format: &OutputFormat, verbose: bool) -> Re Commands::Portfolio { cmd } => match cmd { None => trade::cmd_portfolio(format).await, Some(PortfolioCmd::ShortMargin) => asset::cmd_short_margin(format, verbose).await, - Some(PortfolioCmd::HoldingPeriod { symbols }) => { - asset::cmd_holding_period(symbols, format, verbose).await - } + }, Commands::Positions => trade::cmd_positions(format).await, Commands::FundPositions => trade::cmd_fund_positions(format).await, From e723206446157e09c044afa027d8397846e57216 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:39:28 +0800 Subject: [PATCH 42/44] Update orders.rs --- src/tui/systems/orders.rs | 64 +++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/tui/systems/orders.rs b/src/tui/systems/orders.rs index 4a191ec..854395b 100644 --- a/src/tui/systems/orders.rs +++ b/src/tui/systems/orders.rs @@ -800,38 +800,42 @@ pub fn render_orders( super::Key::Tab => { toggle_orders_mode(); } - super::Key::Up if orders_len > 0 => { - if is_history { - let mut table = HISTORY_ORDERS_TABLE.lock().expect("poison"); - let cur = table.selected(); - table.select(Some(cur.map_or(0, |i| i.saturating_sub(1)))); - } else { - let mut table = ORDERS_TABLE.lock().expect("poison"); - let cur = table.selected(); - table.select(Some(cur.map_or(0, |i| i.saturating_sub(1)))); + super::Key::Up => { + if orders_len > 0 { + if is_history { + let mut table = HISTORY_ORDERS_TABLE.lock().expect("poison"); + let cur = table.selected(); + table.select(Some(cur.map_or(0, |i| i.saturating_sub(1)))); + } else { + let mut table = ORDERS_TABLE.lock().expect("poison"); + let cur = table.selected(); + table.select(Some(cur.map_or(0, |i| i.saturating_sub(1)))); + } } } - super::Key::Down if orders_len > 0 => { - if is_history { - let mut table = HISTORY_ORDERS_TABLE.lock().expect("poison"); - let cur = table.selected(); - table.select(Some(cur.map_or(0, |i| { - if i + 1 < orders_len { - i + 1 - } else { - i - } - }))); - } else { - let mut table = ORDERS_TABLE.lock().expect("poison"); - let cur = table.selected(); - table.select(Some(cur.map_or(0, |i| { - if i + 1 < orders_len { - i + 1 - } else { - i - } - }))); + super::Key::Down => { + if orders_len > 0 { + if is_history { + let mut table = HISTORY_ORDERS_TABLE.lock().expect("poison"); + let cur = table.selected(); + table.select(Some(cur.map_or(0, |i| { + if i + 1 < orders_len { + i + 1 + } else { + i + } + }))); + } else { + let mut table = ORDERS_TABLE.lock().expect("poison"); + let cur = table.selected(); + table.select(Some(cur.map_or(0, |i| { + if i + 1 < orders_len { + i + 1 + } else { + i + } + }))); + } } } super::Key::Enter => { From 34e3dcaaa8965df162085890c1426933a8e698f5 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:40:12 +0800 Subject: [PATCH 43/44] Update chart_data.rs --- crates/cli-candlestick-chart/src/chart_data.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli-candlestick-chart/src/chart_data.rs b/crates/cli-candlestick-chart/src/chart_data.rs index ea2470d..b6bcfa8 100644 --- a/crates/cli-candlestick-chart/src/chart_data.rs +++ b/crates/cli-candlestick-chart/src/chart_data.rs @@ -43,7 +43,7 @@ impl ChartData { self.main_candle_set .candles .iter() - .skip((nb_candles as i64 - nb_visible_candles).max(0) as usize) + .skip((nb_candles as i64 - nb_visible_candles as i64).max(0) as usize) .cloned() .collect::>(), ); From daaa11bd25cfd0a77120c077268fff7ef5a5d70a Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 8 May 2026 19:41:55 +0800 Subject: [PATCH 44/44] chore: Update README commands for task-14 changes Co-Authored-By: Claude Sonnet 4.6 --- README.md | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index fd7d360..eef4ae7 100644 --- a/README.md +++ b/README.md @@ -194,27 +194,17 @@ 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 for a date range -longbridge analyst-estimates AAPL.US # Multi-dimensional analyst consensus estimates +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 ``` -### Account Assets - -```bash -longbridge short-margin # Short-selling margin deposit details -longbridge pnl-calendar # Daily P&L calendar for the current account -longbridge holding-period TSLA.US # Holding period breakdown for a stock position -longbridge trade-info TSLA.US # Pre-trade position and cash snapshot for a symbol -longbridge order-stats # Account-level trade analysis and statistics -``` - ### Deposits & Withdrawals ```bash -longbridge withdrawal-cards # List linked bank cards for withdrawals +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 ``` @@ -233,17 +223,13 @@ longbridge ipo subscriptions # IPO stocks 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 info TSLA.US # IPO subscription page info for a symbol -longbridge ipo profile TSLA.US # IPO prospectus profile for a symbol -longbridge ipo timeline TSLA.US [--market HK] [--flag 0] # IPO timeline for a symbol -longbridge ipo order TSLA.US # Current active IPO order status for a symbol -longbridge ipo orders [TSLA.US] # Active IPO holding orders for the current account -longbridge ipo order-detail # IPO order detail by order ID -longbridge ipo history [--market HK] [--status 0] [--page 1] # IPO subscription history -longbridge ipo eligibility TSLA.US # Check subscription eligibility for a symbol -longbridge ipo profit-loss [--period all|1m|3m|6m|1y] # IPO P&L summary -longbridge ipo profit-loss-items [--period all] [--page 1] # IPO P&L item list -longbridge ipo holdings TSLA.US # IPO holding portfolio detail for a symbol +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) ``` @@ -300,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