use std::process::ExitCode;
use bezant::Client;
use clap::{Parser, Subcommand, ValueEnum};
use comfy_table::{Cell, ContentArrangement, Table};
use serde_json::Value;
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[arg(
long,
env = "IBKR_GATEWAY_URL",
default_value = bezant::DEFAULT_BASE_URL,
global = true
)]
gateway_url: String,
#[arg(long, global = true)]
pretty: bool,
#[arg(long, value_enum, default_value_t = OutputFormat::Json, global = true)]
output: OutputFormat,
#[arg(long, env = "BEZANT_VERIFY_TLS", global = true)]
verify_tls: bool,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum OutputFormat {
Json,
Table,
}
#[derive(Subcommand)]
enum Cmd {
Health,
Accounts,
Summary {
account: String,
},
Positions {
account: String,
},
Orders {
account: String,
},
Quote {
symbol: String,
},
Conid {
symbol: String,
},
Tickle,
}
#[tokio::main]
async fn main() -> ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
)
.with_writer(std::io::stderr)
.with_target(false)
.init();
let cli = Cli::parse();
match run(cli).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
let mut chain = e.chain();
if let Some(first) = chain.next() {
eprintln!("bezant: {first}");
}
for cause in chain {
eprintln!(" caused by: {cause}");
}
ExitCode::FAILURE
}
}
}
async fn run(cli: Cli) -> anyhow::Result<()> {
let client = Client::builder(&cli.gateway_url)
.accept_invalid_certs(!cli.verify_tls)
.build()?;
let output = cli.output;
let pretty = cli.pretty;
match cli.cmd {
Cmd::Health => {
let status = client.auth_status().await?;
let body = serde_json::json!({
"authenticated": status.authenticated,
"connected": status.connected,
"competing": status.competing,
"message": status.message,
});
render(output, pretty, &body, |body| Some(kv_table(body)))?;
}
Cmd::Tickle => {
let tickle = client.tickle().await?;
let body = serde_json::json!({ "session": tickle.session });
render(output, pretty, &body, |_| None)?; }
Cmd::Accounts => {
let raw = fetch_json(&client, &["portfolio", "accounts"]).await?;
render(output, pretty, &raw, |body| {
array_table(body, &["accountId", "accountTitle", "currency", "type"])
})?;
}
Cmd::Summary { account } => {
let raw = fetch_json(&client, &["portfolio", account.as_str(), "summary"]).await?;
render(output, pretty, &raw, |body| Some(kv_table(body)))?;
}
Cmd::Positions { account } => {
let positions = paginated_positions(&client, &account).await?;
let body = Value::Array(positions);
render(output, pretty, &body, |body| {
array_table(
body,
&[
"ticker",
"conid",
"position",
"avgCost",
"mktPrice",
"mktValue",
"currency",
],
)
})?;
}
Cmd::Orders { account } => {
let mut url = client.base_url().clone();
{
let mut segs = url
.path_segments_mut()
.map_err(|()| anyhow::anyhow!("base url cannot be a base"))?;
segs.push("iserver").push("account").push("orders");
}
url.query_pairs_mut().append_pair("accountId", &account);
let resp = client.http().get(url).send().await?;
let body = decode_json(resp).await?;
let orders = body
.get("orders")
.and_then(Value::as_array)
.cloned()
.map_or(body.clone(), Value::Array);
render(output, pretty, &orders, |body| {
array_table(
body,
&[
"orderId",
"ticker",
"side",
"totalSize",
"filledQuantity",
"status",
"orderType",
],
)
})?;
}
Cmd::Quote { symbol } => {
let cache = bezant::SymbolCache::new(client.clone());
let conid = cache.conid_for(&symbol).await?;
let mut url = client.base_url().clone();
{
let mut segs = url
.path_segments_mut()
.map_err(|()| anyhow::anyhow!("base url cannot be a base"))?;
segs.push("iserver").push("marketdata").push("snapshot");
}
url.query_pairs_mut()
.append_pair("conids", &conid.to_string())
.append_pair("fields", "31,84,86,87,85,88");
let resp = client.http().get(url).send().await?;
let body = decode_json(resp).await?;
let snapshot = body.get(0).cloned().unwrap_or(body.clone());
let quote = serde_json::json!({
"symbol": symbol,
"conid": conid,
"last": snapshot.get("31"),
"bid": snapshot.get("84"),
"ask": snapshot.get("86"),
"volume": snapshot.get("87"),
"bid_size": snapshot.get("88"),
"ask_size": snapshot.get("85"),
});
render(output, pretty, "e, |body| Some(kv_table(body)))?;
}
Cmd::Conid { symbol } => {
let cache = bezant::SymbolCache::new(client);
let conid = cache.conid_for(&symbol).await?;
let body = serde_json::json!({ "symbol": symbol, "conid": conid });
render(output, pretty, &body, |_| None)?; }
}
Ok(())
}
async fn paginated_positions(client: &Client, account: &str) -> anyhow::Result<Vec<Value>> {
let mut all = Vec::new();
for page in 0..bezant::MAX_POSITION_PAGES {
let page_str = page.to_string();
let raw = fetch_json(client, &["portfolio", account, "positions", &page_str]).await?;
let arr = raw.as_array().cloned().unwrap_or_default();
let n = arr.len();
all.extend(arr);
if n < bezant::POSITIONS_PAGE_SIZE {
return Ok(all);
}
}
eprintln!(
"bezant: warning — hit MAX_POSITION_PAGES ({}) for account {}; results may be truncated",
bezant::MAX_POSITION_PAGES,
account
);
Ok(all)
}
fn render(
output: OutputFormat,
pretty: bool,
body: &Value,
to_table: impl Fn(&Value) -> Option<Table>,
) -> anyhow::Result<()> {
match output {
OutputFormat::Json => print_json(body, pretty),
OutputFormat::Table => match to_table(body) {
Some(table) => {
println!("{table}");
Ok(())
}
None => print_json(body, true),
},
}
}
fn kv_table(body: &Value) -> Table {
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec![Cell::new("field"), Cell::new("value")]);
if let Some(obj) = body.as_object() {
for (k, v) in obj {
table.add_row(vec![Cell::new(k), Cell::new(value_cell(v))]);
}
}
table
}
fn array_table(body: &Value, columns: &[&str]) -> Option<Table> {
let arr = body.as_array()?;
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(columns.iter().map(Cell::new).collect::<Vec<_>>());
for item in arr {
let row = columns
.iter()
.map(|c| {
let v = item.get(*c).cloned().unwrap_or(Value::Null);
Cell::new(value_cell(&v))
})
.collect::<Vec<_>>();
table.add_row(row);
}
Some(table)
}
fn value_cell(v: &Value) -> String {
match v {
Value::Null => "-".to_owned(),
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Array(_) | Value::Object(_) => v.to_string(),
}
}
async fn fetch_json(client: &Client, path_segments: &[&str]) -> anyhow::Result<Value> {
let mut url = client.base_url().clone();
{
let mut segs = url
.path_segments_mut()
.map_err(|()| anyhow::anyhow!("base url cannot be a base"))?;
for s in path_segments {
segs.push(s);
}
}
let resp = client.http().get(url).send().await?;
decode_json(resp).await
}
async fn decode_json(resp: reqwest::Response) -> anyhow::Result<Value> {
let status = resp.status();
let bytes = resp.bytes().await?;
if !status.is_success() {
let body = String::from_utf8_lossy(&bytes);
anyhow::bail!("HTTP {status}: {body}");
}
let v: Value = serde_json::from_slice(&bytes)?;
Ok(v)
}
fn print_json(value: &Value, pretty: bool) -> anyhow::Result<()> {
let out = if pretty {
serde_json::to_string_pretty(value)?
} else {
serde_json::to_string(value)?
};
println!("{out}");
Ok(())
}