use crate::adapter::{Adapter, FileGroup, GroupOutput, SourceFile};
use crate::normalize::{exchange_wallet, raw_of, Direction, SourceRefMint};
use crate::parse::{parse_btc_to_sat, parse_timestamp_flex, parse_usd};
use crate::read::{read_table, RawRow, ReadOpts, TableRole};
use crate::AdapterError;
use btctax_core::{
Acquire, BasisSource, Dispose, DisposeKind, EventId, EventPayload, LedgerEvent, PriceProvider,
Source, TransferIn, TransferOut, Unclassified, Usd,
};
const SRC: &str = "gemini";
mod cols {
pub const TYPE: &str = "Type";
pub const DATE: &str = "Date"; pub const SYMBOL: &str = "Symbol"; pub const BTC_AMOUNT: &str = "BTC Amount BTC"; pub const USD_AMOUNT: &str = "USD Amount USD";
pub const FEE_USD: &str = "Fee (USD) USD";
pub const TRADE_ID: &str = "Trade ID";
pub const ORDER_ID: &str = "Order ID";
pub const TX_HASH: &str = "Tx Hash";
pub const DEPOSIT_DEST: &str = "Deposit Destination";
pub const WITHDRAWAL_DEST: &str = "Withdrawal Destination";
#[allow(dead_code)] pub const BTC_BALANCE: &str = "BTC Balance BTC";
}
pub struct Gemini;
impl Adapter for Gemini {
fn source(&self) -> Source {
Source::Gemini
}
fn detect(&self, file: &SourceFile) -> Result<bool, AdapterError> {
Ok(file
.path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("xlsx"))
.unwrap_or(false))
}
fn group(&self, files: Vec<SourceFile>) -> Vec<FileGroup> {
files
.into_iter()
.map(|f| FileGroup {
source: Source::Gemini,
label: f.path.display().to_string(),
files: vec![f],
})
.collect()
}
fn parse(&self, group: &FileGroup) -> Result<Vec<RawRow>, AdapterError> {
let opts = ReadOpts::default();
let mut rows = Vec::new();
for f in &group.files {
rows.extend(read_table(&f.path, TableRole::Single, SRC, &opts)?);
}
Ok(rows)
}
fn normalize(
&self,
_group: &FileGroup,
rows: Vec<RawRow>,
_prices: &dyn PriceProvider,
) -> Result<GroupOutput, AdapterError> {
let mut mint = SourceRefMint::default();
let mut out = GroupOutput {
parsed_rows: rows.len(),
..Default::default()
};
for row in &rows {
let sat = match row.opt(cols::BTC_AMOUNT) {
Some(s) => parse_btc_to_sat(SRC, row.line, "BTC Amount BTC", s)?.abs(),
None => 0,
};
if sat == 0 {
out.dropped_no_btc += 1; continue;
}
let ttype = row.get(SRC, cols::TYPE)?;
let (utc, tz) = parse_timestamp_flex(SRC, row.line, row.get(SRC, cols::DATE)?)?;
let txid = row.opt(cols::TX_HASH).map(|s| s.to_string());
let fee = match row.opt(cols::FEE_USD) {
Some(s) => parse_usd(SRC, row.line, "Fee (USD) USD", s)?.abs(),
None => Usd::ZERO,
};
let lower = ttype.to_ascii_lowercase();
let (dir, payload): (Direction, EventPayload) = match lower.as_str() {
"buy" | "sell" => {
let symbol = row.opt(cols::SYMBOL).unwrap_or("").trim();
let usd_str = row.opt(cols::USD_AMOUNT);
let is_btcusd = symbol.eq_ignore_ascii_case("btcusd");
let has_usd = usd_str.is_some(); if !is_btcusd && !has_usd {
out.unclassified += 1;
(
Direction::Trade,
EventPayload::Unclassified(Unclassified { raw: raw_of(row) }),
)
} else {
let usd_abs = usd_str
.map(|s| parse_usd(SRC, row.line, "USD Amount USD", s))
.transpose()?
.unwrap_or(Usd::ZERO)
.abs();
if lower == "buy" {
(
Direction::Trade,
EventPayload::Acquire(Acquire {
sat,
usd_cost: usd_abs,
fee_usd: fee, basis_source: BasisSource::ExchangeProvided,
}),
)
} else {
(
Direction::Trade,
EventPayload::Dispose(Dispose {
sat,
usd_proceeds: usd_abs,
fee_usd: fee, kind: DisposeKind::Sell,
}),
)
}
}
}
"debit" => (
Direction::Out,
EventPayload::TransferOut(TransferOut {
sat,
fee_sat: None,
dest_addr: row.opt(cols::WITHDRAWAL_DEST).map(|s| s.to_string()),
txid: txid.clone(),
}),
),
"credit" => (
Direction::In,
EventPayload::TransferIn(TransferIn {
sat,
src_addr: row.opt(cols::DEPOSIT_DEST).map(|s| s.to_string()),
txid: txid.clone(),
}),
),
_ => {
out.unclassified += 1;
(
Direction::Trade,
EventPayload::Unclassified(Unclassified { raw: raw_of(row) }),
)
}
};
let id_ref = match row.opt(cols::TRADE_ID) {
Some(tid) => {
let combined = match row.opt(cols::ORDER_ID) {
Some(oid) => format!("{tid}.{oid}"),
None => tid.to_string(),
};
mint.native(dir, &combined)
}
None => {
let utc_ms = (utc.unix_timestamp_nanos() / 1_000_000) as i64;
mint.semantic(dir, utc_ms, &lower, sat)
}
};
out.events.push(LedgerEvent {
id: EventId::import(Source::Gemini, id_ref),
utc_timestamp: utc,
original_tz: tz,
wallet: Some(exchange_wallet(Source::Gemini)),
payload,
});
}
Ok(out)
}
}