use adapters::Client;
use ahash::AHashMap;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_with::{DisplayFromStr, serde_as};
use tracing::warn;
use v_exchanges_adapters::bybit::{BybitHttpAuth, BybitOption};
use v_utils::{macros::ScreamIt, trades::Asset};
use crate::{
ExchangeResult,
core::{ApiKeyInfo, AssetBalance, Balances, KeyPermission, PersonalInfo},
};
#[derive(Clone, Copy, Debug, ScreamIt)]
pub enum AccountType {
Spot,
Contract,
Unified,
Funding,
Option,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResponse {
pub result: AccountResult,
pub ret_code: i64,
pub ret_ext_info: RetExtInfo,
pub ret_msg: String,
pub time: i64,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct AccountResult {
pub list: Vec<AccountInfo>,
}
#[serde_as]
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountInfo {
#[serde(rename = "accountIMRate")]
pub account_im_rate: Option<Value>,
#[serde(rename = "accountLTV")]
pub account_ltv: Option<Value>,
#[serde(rename = "accountMMRate")]
pub account_mm_rate: Option<Value>,
pub account_type: AccountType,
pub coin: Vec<CoinInfo>,
pub total_available_balance: Option<Value>, #[serde_as(as = "DisplayFromStr")]
pub total_equity: f64,
pub total_initial_margin: Option<Value>, pub total_maintenance_margin: Option<Value>, pub total_margin_balance: Option<Value>, #[serde(rename = "totalPerpUPL")]
#[serde_as(as = "DisplayFromStr")]
pub total_perp_upl: f64,
#[serde_as(as = "DisplayFromStr")]
pub total_wallet_balance: f64,
}
#[serde_as]
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CoinInfo {
#[serde_as(as = "DisplayFromStr")]
pub accrued_interest: String,
__available_to_borrow: Option<Value>,
pub available_to_withdraw: Option<Value>, pub bonus: Option<String>, #[serde_as(as = "DisplayFromStr")]
pub borrow_amount: f64,
pub coin: String,
free: Option<String>, pub collateral_switch: bool,
#[serde_as(as = "DisplayFromStr")]
pub cum_realised_pnl: f64,
#[serde_as(as = "DisplayFromStr")]
pub equity: f64,
#[serde_as(as = "DisplayFromStr")]
pub locked: f64,
pub margin_collateral: bool,
#[serde_as(as = "DisplayFromStr")]
pub spot_hedging_qty: f64,
#[serde(rename = "totalOrderIM")]
pub total_order_im: Option<Value>, #[serde(rename = "totalPositionIM")]
#[serde_as(as = "DisplayFromStr")]
pub total_position_im: String,
#[serde(rename = "totalPositionMM")]
#[serde_as(as = "DisplayFromStr")]
pub total_position_mm: String,
#[serde_as(as = "DisplayFromStr")]
pub unrealised_pnl: f64,
#[serde_as(as = "DisplayFromStr")]
pub usd_value: f64,
#[serde_as(as = "DisplayFromStr")]
pub wallet_balance: f64,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RetExtInfo {}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EarnPositionResponse {
result: EarnPositionResult,
}
#[derive(Debug, Deserialize)]
struct EarnPositionResult {
list: Vec<EarnPosition>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EarnPosition {
coin: String,
#[serde_as(as = "DisplayFromStr")]
amount: f64,
}
pub(super) async fn personal_info(client: &Client, recv_window: Option<std::time::Duration>) -> ExchangeResult<PersonalInfo> {
assert!(client.is_authenticated::<BybitOption>());
let auth_options = |recv_window: Option<std::time::Duration>| {
let mut options = vec![BybitOption::HttpAuth(BybitHttpAuth::V3AndAbove)];
if let Some(rw) = recv_window {
options.push(BybitOption::RecvWindow(rw));
}
options
};
let (balances_result, api_result) = tokio::join!(
balances_inner(client, recv_window),
client.get_no_query::<QueryApiResponse, _>("/v5/user/query-api", auth_options(recv_window)),
);
let balances = balances_result?;
let api_response = api_result?;
let expire_time = match api_response.result.expired_at.as_str() {
"" | "0" => None,
s => Some(
s.parse::<Timestamp>()
.unwrap_or_else(|e| panic!("Bybit expiredAt={s:?} failed to parse as ISO 8601 timestamp: {e}")),
),
};
Ok(PersonalInfo {
api: ApiKeyInfo {
expire_time,
permissions: api_response.result.permissions.into(),
},
balances,
})
}
async fn balances_inner(client: &Client, recv_window: Option<std::time::Duration>) -> ExchangeResult<Balances> {
assert!(client.is_authenticated::<BybitOption>());
let auth_options = |recv_window: Option<std::time::Duration>| {
let mut options = vec![BybitOption::HttpAuth(BybitHttpAuth::V3AndAbove)];
if let Some(rw) = recv_window {
options.push(BybitOption::RecvWindow(rw));
}
options
};
let account_response: AccountResponse = client.get("/v5/account/wallet-balance", &[("accountType", "UNIFIED")], auth_options(recv_window)).await?;
assert_eq!(account_response.result.list.len(), 1);
let account_info = account_response.result.list.first().unwrap();
let mut usd_rates: AHashMap<String, f64> = AHashMap::default();
let mut vec_balance = Vec::default();
for r in &account_info.coin {
if r.wallet_balance > 0.0 {
usd_rates.insert(r.coin.clone(), r.usd_value / r.wallet_balance);
}
vec_balance.push(AssetBalance {
asset: (&*r.coin).into(),
underlying: r.wallet_balance,
usd: Some(r.usd_value.into()),
});
}
let mut total_equity = account_info.total_equity;
for category in ["FlexibleSaving", "OnChain"] {
let r: Result<EarnPositionResponse, _> = client.get("/v5/earn/position", &[("category", category)], auth_options(recv_window)).await;
match r {
Ok(earn_response) => {
for pos in &earn_response.result.list {
if pos.amount == 0.0 {
continue;
}
let usd_rate = match usd_rates.get(&pos.coin) {
Some(&rate) => rate,
None => {
match pos.coin.as_str() {
"USDT" | "USDC" | "DAI" | "BUSD" => 1.0,
_ => {
warn!("No USD rate for earn coin {}, skipping", pos.coin);
continue;
}
}
}
};
let usd_value = pos.amount * usd_rate;
total_equity += usd_value;
if let Some(existing) = vec_balance.iter_mut().find(|b| {
let asset: Asset = (&*pos.coin).into();
b.asset == asset
}) {
existing.underlying += pos.amount;
if let Some(ref mut usd) = existing.usd {
*usd = v_utils::trades::Usd(**usd + usd_value);
}
} else {
vec_balance.push(AssetBalance {
asset: (&*pos.coin).into(),
underlying: pos.amount,
usd: Some(v_utils::trades::Usd(usd_value)),
});
}
}
}
Err(e) => {
warn!("Failed to fetch {category} earn positions: {e}");
}
}
}
let balances = Balances::new(vec_balance, total_equity.into());
Ok(balances)
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct QueryApiResponse {
result: QueryApiResult,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct QueryApiResult {
expired_at: String,
permissions: BybitPermissions,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "PascalCase", default)]
struct BybitPermissions {
contract_trade: Vec<String>,
spot: Vec<String>,
wallet: Vec<String>,
options: Vec<String>,
derivatives: Vec<String>,
exchange: Vec<String>,
earn: Vec<String>,
}
impl From<BybitPermissions> for Vec<KeyPermission> {
fn from(p: BybitPermissions) -> Self {
let mut out = Vec::new();
if p.contract_trade.iter().any(|s| s == "Order" || s == "Position") {
out.push(KeyPermission::Futures);
}
if p.spot.iter().any(|s| s == "SpotTrade") {
out.push(KeyPermission::SpotTrade);
}
if p.wallet.iter().any(|s| s == "AccountTransfer" || s == "SubMemberTransfer") {
out.push(KeyPermission::Transfer);
}
if p.wallet.iter().any(|s| s == "Withdraw") {
out.push(KeyPermission::Withdraw);
}
if p.options.iter().any(|s| s == "OptionsTrade") {
out.push(KeyPermission::Options);
}
if p.derivatives.iter().any(|s| s == "DerivativesTrade") && !out.contains(&KeyPermission::Futures) {
out.push(KeyPermission::Futures);
}
for s in p.exchange {
out.push(KeyPermission::Other(format!("ExchangeHistory:{s}")));
}
if p.earn.iter().any(|s| s == "Earn") {
out.push(KeyPermission::Earn);
}
out
}
}