use nautilus_core::{Params, UnixNanos};
use nautilus_model::{
enums::{AssetClass, CurrencyType},
identifiers::{InstrumentId, Symbol},
instruments::{BinaryOption, InstrumentAny},
types::{Currency, Price, Quantity},
};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use ustr::Ustr;
use super::models::{FeeSchedule, GammaMarket};
use crate::common::{
consts::{MAX_PRICE, MIN_PRICE, POLYMARKET_VENUE, PUSD},
enums::PolymarketOutcome,
};
const DEFAULT_TICK_SIZE: &str = "0.001";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PolymarketInstrumentDef {
pub symbol: Ustr,
pub token_id: Ustr,
pub condition_id: Ustr,
pub market_id: String,
pub question_id: Option<String>,
pub outcome: PolymarketOutcome,
pub question: String,
pub description: Option<String>,
pub price_precision: u8,
pub tick_size: Decimal,
pub min_size: Option<Decimal>,
pub maker_fee: Option<Decimal>,
pub taker_fee: Option<Decimal>,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub active: bool,
pub market_slug: Option<String>,
pub neg_risk: bool,
pub fee_schedule: Option<FeeSchedule>,
pub game_id: Option<u64>,
}
pub fn parse_gamma_market(market: &GammaMarket) -> anyhow::Result<Vec<PolymarketInstrumentDef>> {
let game_id = market.game_id.or_else(|| {
market
.events
.as_ref()?
.iter()
.find_map(|event| event.game_id)
});
let token_ids: Vec<String> = serde_json::from_str(&market.clob_token_ids).map_err(|e| {
anyhow::anyhow!(
"Failed to parse clob_token_ids '{}': {e}",
market.clob_token_ids
)
})?;
if token_ids.len() != 2 {
anyhow::bail!("Expected 2 token IDs, received {}", token_ids.len());
}
let outcomes: Vec<String> = serde_json::from_str(&market.outcomes)
.map_err(|e| anyhow::anyhow!("Failed to parse outcomes '{}': {e}", market.outcomes))?;
if outcomes.len() != 2 {
anyhow::bail!("Expected 2 outcomes, received {}", outcomes.len());
}
let tick_size_str = market
.order_price_min_tick_size
.map_or_else(|| DEFAULT_TICK_SIZE.to_string(), |ts| ts.to_string());
let tick_size: Decimal = tick_size_str
.parse()
.map_err(|e| anyhow::anyhow!("Failed to parse tick size '{tick_size_str}': {e}"))?;
let price_precision = tick_size.scale() as u8;
let maker_fee: Option<Decimal> = market.fee_schedule.as_ref().map(|_| Decimal::ZERO);
let taker_fee: Option<Decimal> = market
.fee_schedule
.as_ref()
.and_then(|fs| Decimal::try_from(fs.rate).ok());
let min_size: Option<Decimal> = market
.order_min_size
.map(|s| s.to_string().parse())
.transpose()
.map_err(|e| anyhow::anyhow!("Failed to parse min size: {e}"))?;
let active = market.active.unwrap_or(false)
&& !market.closed.unwrap_or(false)
&& market.accepting_orders.unwrap_or(false);
let neg_risk = market.neg_risk.unwrap_or(false);
let mut defs = Vec::with_capacity(2);
for (token_id, outcome_label) in token_ids.iter().zip(outcomes.iter()) {
let outcome = PolymarketOutcome::from(outcome_label.as_str());
let symbol_str = format!("{}-{token_id}", market.condition_id);
defs.push(PolymarketInstrumentDef {
symbol: Ustr::from(&symbol_str),
token_id: Ustr::from(token_id.as_str()),
condition_id: Ustr::from(market.condition_id.as_str()),
market_id: market.id.clone(),
question_id: market.question_id.clone(),
outcome,
question: market.question.clone(),
description: market.description.clone(),
price_precision,
tick_size,
min_size,
maker_fee,
taker_fee,
start_date: market.start_date.clone(),
end_date: market.end_date.clone(),
active,
market_slug: market.market_slug.clone(),
neg_risk,
fee_schedule: market.fee_schedule.clone(),
game_id,
});
}
Ok(defs)
}
pub fn create_instrument_from_def(
def: &PolymarketInstrumentDef,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
let symbol = Symbol::new(def.symbol);
let venue = *POLYMARKET_VENUE;
let instrument_id = InstrumentId::new(symbol, venue);
let raw_symbol = Symbol::new(def.token_id);
let currency = get_currency(PUSD);
let price_increment = Price::from(def.tick_size.to_string());
let size_increment = Quantity::from("0.000001");
let activation_ns = def
.start_date
.as_deref()
.and_then(parse_datetime_to_nanos)
.unwrap_or_default();
let expiration_ns = def
.end_date
.as_deref()
.and_then(parse_datetime_to_nanos)
.unwrap_or_default();
let max_price = Some(Price::from(MAX_PRICE));
let min_price = Some(Price::from(MIN_PRICE));
let min_quantity: Option<Quantity> = None;
let info: Params = serde_json::from_value(build_info_json(def))?;
let binary_option = BinaryOption::new_checked(
instrument_id,
raw_symbol,
AssetClass::Alternative,
currency,
activation_ns,
expiration_ns,
def.price_precision,
6, price_increment,
size_increment,
Some(def.outcome.inner()),
Some(Ustr::from(def.question.as_str())),
None, min_quantity,
None, None, max_price,
min_price,
None, None, def.maker_fee,
def.taker_fee,
Some(info),
ts_init,
ts_init,
)?;
Ok(InstrumentAny::BinaryOption(binary_option))
}
#[must_use]
pub fn instruments_from_defs(
defs: &[PolymarketInstrumentDef],
ts_init: UnixNanos,
) -> Vec<InstrumentAny> {
defs.iter()
.filter_map(|def| {
create_instrument_from_def(def, ts_init)
.map_err(|e| log::warn!("Failed to create instrument {}: {e}", def.symbol))
.ok()
})
.collect()
}
pub fn rebuild_instrument_with_tick_size(
existing: &InstrumentAny,
new_tick_size: &str,
ts_event: UnixNanos,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
let bo = match existing {
InstrumentAny::BinaryOption(b) => b,
other => anyhow::bail!("Expected BinaryOption, was {other:?}"),
};
let tick_size: Decimal = new_tick_size
.parse()
.map_err(|e| anyhow::anyhow!("Failed to parse tick size '{new_tick_size}': {e}"))?;
let price_precision = tick_size.scale() as u8;
let price_increment = Price::from(tick_size.to_string());
let rebuilt = BinaryOption::new_checked(
bo.id,
bo.raw_symbol,
bo.asset_class,
bo.currency,
bo.activation_ns,
bo.expiration_ns,
price_precision,
bo.size_precision,
price_increment,
bo.size_increment,
bo.outcome,
bo.description,
bo.max_quantity,
None, bo.max_notional,
bo.min_notional,
bo.max_price,
bo.min_price,
Some(bo.margin_init),
Some(bo.margin_maint),
Some(bo.maker_fee),
Some(bo.taker_fee),
bo.info.clone(),
ts_event,
ts_init,
)?;
Ok(InstrumentAny::BinaryOption(rebuilt))
}
fn build_info_json(def: &PolymarketInstrumentDef) -> serde_json::Value {
let mut map = serde_json::Map::new();
map.insert(
"token_id".to_string(),
serde_json::Value::String(def.token_id.to_string()),
);
map.insert(
"condition_id".to_string(),
serde_json::Value::String(def.condition_id.to_string()),
);
map.insert(
"market_id".to_string(),
serde_json::Value::String(def.market_id.clone()),
);
if let Some(qid) = &def.question_id {
map.insert(
"question_id".to_string(),
serde_json::Value::String(qid.clone()),
);
}
if let Some(slug) = &def.market_slug {
map.insert(
"market_slug".to_string(),
serde_json::Value::String(slug.clone()),
);
}
map.insert(
"neg_risk".to_string(),
serde_json::Value::Bool(def.neg_risk),
);
if let Some(fee_schedule) = &def.fee_schedule
&& let Ok(value) = serde_json::to_value(fee_schedule)
{
map.insert("fee_schedule".to_string(), value);
}
if let Some(game_id) = def.game_id {
map.insert("game_id".to_string(), serde_json::Value::from(game_id));
}
serde_json::Value::Object(map)
}
fn get_currency(code: &str) -> Currency {
Currency::try_from_str(code).unwrap_or_else(|| {
let currency = Currency::new(code, 6, 0, code, CurrencyType::Crypto);
if let Err(e) = Currency::register(currency, false) {
log::error!("Failed to register currency '{code}': {e}");
}
currency
})
}
fn parse_datetime_to_nanos(s: &str) -> Option<UnixNanos> {
chrono::DateTime::parse_from_rfc3339(s)
.ok()
.and_then(|dt| dt.timestamp_nanos_opt())
.map(|ns| UnixNanos::from(ns as u64))
}
#[cfg(test)]
mod tests {
use nautilus_model::instruments::Instrument;
use rstest::rstest;
use rust_decimal_macros::dec;
use super::*;
fn load_gamma_market(filename: &str) -> GammaMarket {
let path = format!("test_data/{filename}");
let content = std::fs::read_to_string(path).expect("Failed to read test data");
serde_json::from_str(&content).expect("Failed to parse test data")
}
#[rstest]
fn test_parse_gamma_market_produces_two_defs() {
let market = load_gamma_market("gamma_market.json");
let defs = parse_gamma_market(&market).unwrap();
assert_eq!(defs.len(), 2);
assert_eq!(defs[0].outcome, PolymarketOutcome::from("Up"));
assert_eq!(defs[1].outcome, PolymarketOutcome::from("Down"));
}
#[rstest]
fn test_parse_gamma_market_fields() {
let market = load_gamma_market("gamma_market.json");
let defs = parse_gamma_market(&market).unwrap();
let yes_def = &defs[0];
assert_eq!(
yes_def.condition_id.as_str(),
"0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b"
);
assert_eq!(yes_def.market_id, "1557558");
assert_eq!(
yes_def.question_id.as_deref(),
Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
);
assert_eq!(
yes_def.question,
"Bitcoin Up or Down - March 12, 5:20AM-5:25AM ET"
);
assert_eq!(yes_def.tick_size, dec!(0.01));
assert_eq!(yes_def.price_precision, 2);
assert_eq!(yes_def.min_size, Some(dec!(5.0)));
assert!(yes_def.maker_fee.is_none());
assert!(yes_def.taker_fee.is_none());
assert!(yes_def.active);
assert_eq!(
yes_def.market_slug.as_deref(),
Some("btc-updown-5m-1773307200")
);
assert_eq!(yes_def.game_id, None);
}
#[rstest]
fn test_parse_gamma_market_sports_game_id_and_fee_schedule() {
let money_line = load_gamma_market("gamma_market_sports_market_money_line.json");
let map_handicap = load_gamma_market("gamma_market_sports_market_map_handicap.json");
let money_line_defs = parse_gamma_market(&money_line).unwrap();
let map_handicap_defs = parse_gamma_market(&map_handicap).unwrap();
assert_eq!(money_line_defs[0].game_id, Some(1_427_074));
assert_eq!(map_handicap_defs[0].game_id, Some(1_427_074));
assert_eq!(money_line_defs[0].fee_schedule, money_line.fee_schedule);
assert_eq!(map_handicap_defs[0].fee_schedule, map_handicap.fee_schedule);
assert_eq!(money_line_defs[0].maker_fee, Some(Decimal::ZERO));
assert_eq!(money_line_defs[0].taker_fee, Some(dec!(0.03)));
}
#[rstest]
fn test_parse_gamma_market_symbol_format() {
let market = load_gamma_market("gamma_market.json");
let defs = parse_gamma_market(&market).unwrap();
assert_eq!(
defs[0].symbol.as_str(),
"0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875"
);
assert_eq!(
defs[1].symbol.as_str(),
"0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-71183960810705820955071415844881728181970340514894896943812046065452395013351"
);
}
#[rstest]
fn test_parse_gamma_market_token_ids() {
let market = load_gamma_market("gamma_market.json");
let defs = parse_gamma_market(&market).unwrap();
assert_eq!(
defs[0].token_id.as_str(),
"104239898038807136052399800151408521467737075933964991162589336683346093173875"
);
assert_eq!(
defs[1].token_id.as_str(),
"71183960810705820955071415844881728181970340514894896943812046065452395013351"
);
}
#[rstest]
fn test_parse_gamma_market_derives_outcome_from_label() {
let mut market = load_gamma_market("gamma_market.json");
market.outcomes = r#"["No", "Yes"]"#.to_string();
let defs = parse_gamma_market(&market).unwrap();
assert_eq!(defs[0].outcome, PolymarketOutcome::no());
assert_eq!(defs[1].outcome, PolymarketOutcome::yes());
}
#[rstest]
fn test_parse_gamma_market_accepts_arbitrary_outcome_label() {
let mut market = load_gamma_market("gamma_market.json");
market.outcomes = r#"["Maybe", "No"]"#.to_string();
let defs = parse_gamma_market(&market).unwrap();
assert_eq!(defs[0].outcome, PolymarketOutcome::from("Maybe"));
assert_eq!(defs[1].outcome, PolymarketOutcome::no());
}
#[rstest]
fn test_parse_gamma_market_null_tick_size_uses_default() {
let mut market = load_gamma_market("gamma_market.json");
market.order_price_min_tick_size = None;
let defs = parse_gamma_market(&market).unwrap();
assert_eq!(defs[0].tick_size, dec!(0.001));
assert_eq!(defs[0].price_precision, 3);
}
#[rstest]
fn test_parse_gamma_market_closed_is_inactive() {
let mut market = load_gamma_market("gamma_market.json");
market.closed = Some(true);
let defs = parse_gamma_market(&market).unwrap();
assert!(!defs[0].active);
assert!(!defs[1].active);
}
#[rstest]
fn test_create_instrument_from_def() {
let market = load_gamma_market("gamma_market.json");
let defs = parse_gamma_market(&market).unwrap();
let ts_init = UnixNanos::from(1_000_000_000u64);
let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
let binary = match &instrument {
InstrumentAny::BinaryOption(b) => b,
other => panic!("Expected BinaryOption, was {other:?}"),
};
assert_eq!(
binary.id.to_string(),
"0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875.POLYMARKET"
);
assert_eq!(binary.outcome, Some(Ustr::from("Up")));
assert_eq!(binary.asset_class, AssetClass::Alternative);
assert_eq!(binary.currency.code.as_str(), "pUSD");
assert_eq!(binary.price_precision, 2);
assert_eq!(binary.size_precision, 6);
assert_eq!(binary.price_increment(), Price::from("0.01"));
assert_eq!(binary.size_increment(), Quantity::from("0.000001"));
}
#[rstest]
fn test_create_instrument_info_params() {
let market = load_gamma_market("gamma_market.json");
let defs = parse_gamma_market(&market).unwrap();
let ts_init = UnixNanos::from(1_000_000_000u64);
let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
let binary = match &instrument {
InstrumentAny::BinaryOption(b) => b,
other => panic!("Expected BinaryOption, was {other:?}"),
};
let info = binary.info.as_ref().expect("info should be Some");
assert_eq!(
info.get_str("token_id"),
Some("104239898038807136052399800151408521467737075933964991162589336683346093173875")
);
assert_eq!(
info.get_str("condition_id"),
Some("0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b")
);
assert_eq!(info.get_str("market_id"), Some("1557558"));
assert_eq!(
info.get_str("question_id"),
Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
);
assert_eq!(
info.get_str("market_slug"),
Some("btc-updown-5m-1773307200")
);
assert_eq!(info.get_u64("game_id"), None);
assert_eq!(info.get("fee_schedule"), None);
}
#[rstest]
fn test_create_instrument_info_params_includes_game_id_and_fee_schedule() {
let market = load_gamma_market("gamma_market_sports_market_money_line.json");
let defs = parse_gamma_market(&market).unwrap();
let ts_init = UnixNanos::from(1_000_000_000u64);
let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
let binary = match &instrument {
InstrumentAny::BinaryOption(b) => b,
other => panic!("Expected BinaryOption, was {other:?}"),
};
let info = binary.info.as_ref().expect("info should be Some");
assert_eq!(info.get_u64("game_id"), Some(1_427_074));
assert!(info.get("fee_schedule").is_some());
}
#[rstest]
fn test_instruments_from_defs_batch() {
let market = load_gamma_market("gamma_market.json");
let defs = parse_gamma_market(&market).unwrap();
let ts_init = UnixNanos::from(1_000_000_000u64);
let instruments = instruments_from_defs(&defs, ts_init);
assert_eq!(instruments.len(), 2);
}
#[rstest]
fn test_create_instrument_max_min_price() {
let market = load_gamma_market("gamma_market.json");
let defs = parse_gamma_market(&market).unwrap();
let ts_init = UnixNanos::from(1_000_000_000u64);
let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
let binary = match &instrument {
InstrumentAny::BinaryOption(b) => b,
other => panic!("Expected BinaryOption, was {other:?}"),
};
assert_eq!(binary.max_price, Some(Price::from("0.999")));
assert_eq!(binary.min_price, Some(Price::from("0.001")));
}
#[rstest]
fn test_rebuild_instrument_with_tick_size() {
let market = load_gamma_market("gamma_market.json");
let defs = parse_gamma_market(&market).unwrap();
let ts_init = UnixNanos::from(1_000_000_000u64);
let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
assert_eq!(instrument.price_precision(), 2);
let ts_event = UnixNanos::from(2_000_000_000u64);
let rebuilt =
rebuild_instrument_with_tick_size(&instrument, "0.001", ts_event, ts_event).unwrap();
assert_eq!(rebuilt.price_precision(), 3);
assert_eq!(rebuilt.price_increment(), Price::from("0.001"));
}
#[rstest]
fn test_rebuild_instrument_preserves_fields() {
let market = load_gamma_market("gamma_market.json");
let defs = parse_gamma_market(&market).unwrap();
let ts_init = UnixNanos::from(1_000_000_000u64);
let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
let ts_event = UnixNanos::from(2_000_000_000u64);
let rebuilt =
rebuild_instrument_with_tick_size(&instrument, "0.01", ts_event, ts_event).unwrap();
assert_eq!(rebuilt.id(), instrument.id());
assert_eq!(rebuilt.raw_symbol(), instrument.raw_symbol());
assert_eq!(rebuilt.size_precision(), instrument.size_precision());
let orig_bo = match &instrument {
InstrumentAny::BinaryOption(b) => b,
_ => panic!(),
};
let new_bo = match &rebuilt {
InstrumentAny::BinaryOption(b) => b,
_ => panic!(),
};
assert_eq!(new_bo.outcome, orig_bo.outcome);
assert_eq!(new_bo.currency, orig_bo.currency);
}
}