Files
fidc-backtest-engine/crates/fidc-core/src/manual_execution/tests.rs
T

747 lines
27 KiB
Rust

use super::*;
use serde_json::{Value, json};
fn sample() -> ManualExecutionReplay {
let fill = json!({"tradeId":"trade-1","observationEventId":"received-1","observationSequence":1,"tradeDate":"2026-09-14","executedAt":"2026-09-14T01:30:00Z",
"feeObservationEventId":"received-1","feeObservationSequence":1,"feeObservedAt":"2026-09-14T01:30:01Z",
"observedAt":"2026-09-14T01:30:01Z","timestampPrecision":"second","quantity":100,
"price":"10.1234567891","commission":"0.1000001","stampTax":"0","transferFee":"0.02","totalFee":"0.1200001"});
let mut input:ManualExecutionReplay=serde_json::from_value(json!({
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"runtime-1","accountId":"account-1",
"sourceContractSha256":"a".repeat(64),"contentSha256":"", "observationCutoff":"2026-09-14T08:00:00Z",
"actions":[{"actionId":"action-1","source":"manual_security_trade","auditEventIds":["audit-1"],
"confirmedAt":"2026-09-14T01:30:00.500Z","confirmationObservedAt":"2026-09-14T01:30:00.550Z","outcome":"orders_terminal","orders":[{
"orderId":"order-1","brokerOrderId":"broker-1","sourceAdapter":"gt-api","symbol":"000001.SZ","side":"Buy","quantity":100,
"orderCreatedAt":"2026-09-14T01:30:00.600Z","terminalObservedAt":"2026-09-14T01:30:00.900Z","terminalStatus":"filled",
"fills":[fill]
}]}]
})).unwrap();
reseal(&mut input);
input
}
fn reseal(input: &mut ManualExecutionReplay) {
input.content_sha256 = input.content_digest().unwrap();
}
fn delayed_buy_replay() -> ManualExecutionReplay {
let mut input = sample();
let template = input.actions[0].clone();
input.actions.clear();
for (index, side, executed, observed, price, fee) in [
(
0,
OrderSide::Buy,
"2026-09-14T01:30:00Z",
"2026-09-14T01:30:01Z",
"20",
"0.25",
),
(
1,
OrderSide::Buy,
"2026-09-11T06:00:00Z",
"2026-09-14T01:30:02Z",
"10",
"0.75",
),
(
2,
OrderSide::Sell,
"2026-09-14T01:31:00Z",
"2026-09-14T01:31:01Z",
"10",
"0.5",
),
(
3,
OrderSide::Sell,
"2026-09-14T01:32:00Z",
"2026-09-14T01:32:01Z",
"10",
"0.5",
),
] {
let executed: DateTime<Utc> = executed.parse().unwrap();
let observed: DateTime<Utc> = observed.parse().unwrap();
let mut action = template.clone();
action.action_id = format!("action-{index}");
action.audit_event_ids = vec![format!("audit-{index}")];
action.confirmed_at = executed - chrono::Duration::seconds(2);
action.confirmation_observed_at = action.confirmed_at;
let order = &mut action.orders[0];
order.order_id = format!("order-{index}");
order.broker_order_id = Some(format!("broker-{index}"));
order.side = side;
order.order_created_at = executed - chrono::Duration::seconds(1);
order.terminal_observed_at = observed;
let fill = &mut order.fills[0];
fill.trade_id = format!("trade-{index}");
fill.observation_event_id = format!("receipt-{index}");
fill.observation_sequence = index + 1;
fill.fee_observation_event_id = fill.observation_event_id.clone();
fill.fee_observation_sequence = fill.observation_sequence;
fill.trade_date = executed
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
.date_naive();
fill.executed_at = executed;
fill.observed_at = observed;
fill.fee_observed_at = observed;
fill.price = price.parse().unwrap();
fill.commission = None;
fill.stamp_tax = None;
fill.transfer_fee = None;
fill.total_fee = fee.parse().unwrap();
input.actions.push(action);
}
reseal(&mut input);
input.validate().unwrap();
input
}
#[test]
fn late_buy_retains_the_earliest_opening_and_latest_buy_dates() {
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut portfolio = PortfolioState::new(10000.);
let applications = cursor
.advance(
"2026-09-14T01:30:02Z".parse().unwrap(),
&mut portfolio,
&data,
false,
)
.unwrap();
assert_eq!(
applications
.iter()
.map(|row| row.trade_id.as_str())
.collect::<Vec<_>>(),
["trade-0", "trade-1"]
);
let position = portfolio.position("000001.SZ").unwrap();
assert_eq!(position.opened_date(), NaiveDate::from_ymd_opt(2026, 9, 11));
assert_eq!(
position.last_buy_date(),
NaiveDate::from_ymd_opt(2026, 9, 14)
);
assert_eq!(position.quantity, 200);
let calendar = crate::TradingCalendar::new(
[11, 14, 15, 16, 17, 18]
.map(|day| NaiveDate::from_ymd_opt(2026, 9, day).unwrap())
.into(),
);
let evidence = crate::holding_policy::HoldingLifecycleEvidence {
has_position: true,
opened_date: position.opened_date(),
last_buy_date: position.last_buy_date(),
last_sell_date: None,
};
let mut policy = crate::holding_policy::AutomaticTradeProtection {
max_holding_days: 1,
..Default::default()
};
assert!(
policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(),
&evidence,
&calendar
)
.unwrap()
.max_holding_exit
);
policy.buy_protection_days = 3;
for day in [14, 15, 16, 17] {
let permission = policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, day).unwrap(),
&evidence,
&calendar,
)
.unwrap();
assert_eq!(permission.sell_denial, Some("buy_fill_protection"));
assert!(!permission.max_holding_exit);
}
assert!(
policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, 18).unwrap(),
&evidence,
&calendar
)
.unwrap()
.max_holding_exit
);
}
#[test]
fn late_buy_fifo_depletion_preserves_costs_and_cannot_unlock_today_lots() {
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut portfolio = PortfolioState::new(10000.);
let applications = cursor
.advance(
"2026-09-14T01:31:01Z".parse().unwrap(),
&mut portfolio,
&data,
false,
)
.unwrap();
assert_eq!(applications.len(), 3);
let position = portfolio.position("000001.SZ").unwrap();
assert_eq!(position.quantity, 100);
assert_eq!(position.unrealized_pnl(), -1000.25);
assert_eq!(
position.sellable_qty(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()),
0
);
assert_eq!(position.realized_pnl(), -0.75);
assert_eq!(portfolio.cash(), 7998.5);
assert_eq!(portfolio.external_cash_flow_total(), 0.);
assert!(
cursor
.advance(
"2026-09-14T01:32:01Z".parse().unwrap(),
&mut portfolio,
&data,
false
)
.unwrap_err()
.contains("T+1")
);
assert_eq!(cursor.applied_count(), 3);
assert_eq!(portfolio.cash(), 7998.5);
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
}
fn semantic_result(input: &ManualExecutionReplay) -> Result<(), String> {
let mut input = input.clone();
reseal(&mut input);
input.validate()
}
#[test]
fn complete_exact_decimal_evidence_allows_later_observation_and_retains_source_digits() {
let input = sample();
input.validate().unwrap();
let fill = &input.actions[0].orders[0].fills[0];
assert_eq!(fill.gross_amount().unwrap().to_string(), "1012.3456789100");
assert_eq!(fill.total_fees().unwrap().to_string(), "0.1200001");
assert_eq!(
serde_json::to_value(&input).unwrap()["actions"][0]["orders"][0]["fills"][0]["price"],
"10.1234567891"
);
}
#[test]
fn data_scope_only_contains_actual_filled_securities_and_validates_the_source() {
let mut input = sample();
let mut rejected = input.actions[0].orders[0].clone();
rejected.order_id = "rejected-order".into();
rejected.broker_order_id = None;
rejected.source_adapter = None;
rejected.symbol = "510300.SH".into();
rejected.terminal_status = ManualOrderTerminalStatus::Rejected;
rejected.fills.clear();
input.actions[0].orders.push(rejected);
reseal(&mut input);
assert_eq!(
input.required_data_symbols().unwrap(),
BTreeSet::from(["000001.SZ".into()])
);
input.actions[0].orders[0].symbol = "600000.SH".into();
assert!(input.required_data_symbols().is_err());
}
#[test]
fn v2_facts_keep_their_encoding_but_cannot_silently_carry_new_runtime_settings() {
let mut input = sample();
input.schema = "fidc.observed-manual-executions/v2".into();
reseal(&mut input);
input.validate().unwrap();
let old = serde_json::to_value(&input).unwrap();
assert!(old.get("positionExposureEvents").is_none());
assert!(old.get("legacyPositionExposureBps").is_none());
input
.legacy_position_exposure_bps
.insert(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(), 5000);
reseal(&mut input);
assert!(input.validate().is_err());
input.schema = MANUAL_REPLAY_SCHEMA.into();
reseal(&mut input);
input.validate().unwrap();
}
#[test]
fn runtime_position_events_cannot_claim_observations_after_the_source_cutoff() {
let mut input = sample();
input.position_exposure_events.push(serde_json::from_value(json!({
"eventId": "position-event", "sequence": 1, "effectiveAt": input.observation_cutoff,
"action": "scale", "requestedBps": 5000
})).unwrap());
semantic_result(&input).unwrap();
input.position_exposure_events[0].effective_at += chrono::Duration::nanoseconds(1);
assert!(semantic_result(&input).unwrap_err().contains("after the evidence cutoff"));
}
#[test]
fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() {
let original = serde_json::to_value(sample()).unwrap();
for field in ["price", "totalFee"] {
let mut missing = original.clone();
missing["actions"][0]["orders"][0]["fills"][0]
.as_object_mut()
.unwrap()
.remove(field);
assert!(
serde_json::from_value::<ManualExecutionReplay>(missing).is_err(),
"{field}"
);
let mut numeric = original.clone();
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(1.1);
assert!(
serde_json::from_value::<ManualExecutionReplay>(numeric).is_err(),
"numeric {field}"
);
}
for mutate in [
("schema", json!("unknown")),
("sourceContractSha256", json!("broken")),
("accountId", json!(" ")),
] {
let mut value = original.clone();
value[mutate.0] = mutate.1;
assert!(
semantic_result(&serde_json::from_value::<ManualExecutionReplay>(value).unwrap())
.is_err()
);
}
}
#[test]
fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() {
let original = sample();
let mut invalid = original.clone();
invalid.actions[0].orders[0].quantity = 200;
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Rejected;
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions[0].audit_event_ids.clear();
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions.push(invalid.actions[0].clone());
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
let duplicate = invalid.actions[0].orders[0].fills[0].clone();
invalid.actions[0].orders[0].fills.push(duplicate);
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions[0].orders[0].broker_order_id = None;
assert!(semantic_result(&invalid).is_err());
invalid.actions[0].orders[0].source_adapter = Some("paper".into());
reseal(&mut invalid);
invalid.validate().unwrap();
}
#[test]
fn source_time_precision_is_not_invented_and_submitted_time_must_fit_the_interval() {
let mut input = sample();
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:00.999999Z".parse().unwrap();
input.actions[0].orders[0].terminal_observed_at = "2026-09-14T01:30:01.500Z".parse().unwrap();
input.actions[0].orders[0].fills[0].observed_at = "2026-09-14T01:30:02Z".parse().unwrap();
input.actions[0].orders[0].fills[0].fee_observed_at =
input.actions[0].orders[0].fills[0].observed_at;
reseal(&mut input);
input.validate().unwrap();
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:01Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
let mut input = sample();
input.actions[0].orders[0].fills[0].executed_at = "2026-09-14T01:30:00.800Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
input.actions[0].orders[0].fills[0].timestamp_precision = ManualTimestampPrecision::Millisecond;
reseal(&mut input);
input.validate().unwrap();
input.actions[0].orders[0].fills[0].executed_at =
"2026-09-14T01:30:00.800001Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
}
#[test]
fn confirmed_no_order_outcome_is_distinct_from_unconfirmed_or_unknown_work() {
let mut input = sample();
input.actions[0].orders.clear();
assert!(semantic_result(&input).is_err());
input.actions[0].outcome = ManualActionOutcome::NoOrdersNeeded;
reseal(&mut input);
input.validate().unwrap();
input.actions[0].outcome = ManualActionOutcome::NotExecuted;
reseal(&mut input);
input.validate().unwrap();
let mut value = serde_json::to_value(input).unwrap();
value["actions"][0]["outcome"] = json!("result_unknown");
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
}
#[test]
fn raw_timezone_and_cutoff_are_required() {
let mut value = serde_json::to_value(sample()).unwrap();
value["actions"][0]["orders"][0]["fills"][0]["executedAt"] = json!("2026-09-14T09:30:00");
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
let mut input = sample();
input.observation_cutoff = "2026-09-14T01:30:00.700Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
let mut value = serde_json::to_value(sample()).unwrap();
value["actions"][0]["orders"][0]["fills"][0]["totalFee"] = Value::Null;
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
}
#[test]
fn authoritative_total_fee_does_not_require_inventing_unknown_components() {
let mut input = sample();
let fill = &mut input.actions[0].orders[0].fills[0];
fill.commission = None;
fill.stamp_tax = None;
fill.transfer_fee = None;
assert_eq!(
fill.total_fees().unwrap(),
"0.1200001".parse::<Decimal>().unwrap()
);
assert!(semantic_result(&input).is_ok());
let value = serde_json::to_value(&input).unwrap();
assert!(value["actions"][0]["orders"][0]["fills"][0]["commission"].is_null());
assert_eq!(
value["actions"][0]["orders"][0]["fills"][0]["totalFee"],
"0.1200001"
);
for field in ["commission", "stampTax", "transferFee"] {
let mut numeric = value.clone();
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(0.1);
assert!(serde_json::from_value::<ManualExecutionReplay>(numeric).is_err());
}
}
#[test]
fn manual_fee_total_includes_extra_charges_and_rejects_inconsistent_components() {
let mut input = sample();
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
assert!(semantic_result(&input).is_ok());
assert_eq!(
input.actions[0].orders[0].fills[0]
.total_fees()
.unwrap()
.to_string(),
"0.15"
);
input.actions[0].orders[0].fills[0].total_fee = "0.1".parse().unwrap();
assert!(semantic_result(&input).is_err());
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
input.actions[0].orders[0].fills[0].commission = Some(Decimal::NEGATIVE_ONE);
assert!(semantic_result(&input).is_err());
}
#[test]
fn late_fee_evidence_keeps_the_original_fill_observation_clock() {
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut input = sample();
let fill = &mut input.actions[0].orders[0].fills[0];
let original = fill.observed_at;
fill.fee_observation_event_id = "fee-receipt-1".into();
fill.fee_observation_sequence = 2;
fill.fee_observed_at = original + chrono::Duration::hours(1);
let fee_time = fill.fee_observed_at;
reseal(&mut input);
let mut cursor = ManualReplayCursor::new(input).unwrap();
assert_eq!(cursor.next_observation_at(), Some(original));
let mut portfolio = PortfolioState::new(10_000.);
let result = cursor
.advance(original, &mut portfolio, &data, false)
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].observed_at, original);
assert_eq!(result[0].fee_observed_at, fee_time);
assert_eq!(result[0].source_total_fee, "0.1200001");
assert!(
cursor
.advance(fee_time, &mut portfolio, &data, false)
.unwrap()
.is_empty()
);
}
#[test]
fn changing_any_external_price_or_identity_invalidates_the_frozen_trace() {
let input = sample();
let original = input.content_sha256.clone();
let mut changed = input.clone();
changed.actions[0].orders[0].fills[0].price += Decimal::ONE;
assert_ne!(changed.content_digest().unwrap(), original);
assert_eq!(
changed.validate().unwrap_err(),
"manual replay content digest mismatch"
);
let mut changed = input;
changed.account_id = "another-account".into();
assert_ne!(changed.content_digest().unwrap(), original);
assert!(changed.validate().is_err());
}
fn identity_data(listed: NaiveDate) -> DataSet {
DataSet::from_components(
vec![crate::Instrument {
symbol: "000001.SZ".into(),
name: "test".into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(listed),
delisted_at: None,
status: "active".into(),
}],
vec![],
vec![],
vec![],
vec![crate::BenchmarkSnapshot {
date: listed,
benchmark: "000300.SH".into(),
open: 100.,
close: 100.,
prev_close: 100.,
volume: 0,
}],
)
.unwrap()
}
#[test]
fn confirmed_manual_fill_changes_cash_and_lots_but_not_external_cash_flow_units() {
let input = sample();
let observations = input.observations().unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut account = PortfolioState::new(10_000.);
let applied = observations[0].apply(&mut account, &data, false).unwrap();
assert_eq!(
applied.gross,
FixedMoney::from_decimal_str("1012.345679").unwrap()
);
assert_eq!(applied.fees, FixedMoney::from_decimal_str("0.12").unwrap());
assert_eq!(account.cash(), 8987.534321);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
assert_eq!(
account
.position("000001.SZ")
.unwrap()
.sellable_qty(input.actions[0].orders[0].fills[0].trade_date),
0
);
assert_eq!(account.external_cash_flow_total(), 0.);
assert_eq!(account.starting_cash(), 10_000.);
}
#[test]
fn manual_mismatches_are_atomic_and_do_not_borrow_shares_cash_or_override_pending_orders() {
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let input = sample();
let observations = input.observations().unwrap();
let mut poor = PortfolioState::new(10.);
assert!(observations[0].apply(&mut poor, &data, false).is_err());
assert_eq!(poor.cash(), 10.);
assert!(poor.positions().is_empty());
let mut account = PortfolioState::new(10_000.);
assert!(observations[0].apply(&mut account, &data, true).is_err());
assert_eq!(account.cash(), 10_000.);
assert!(account.positions().is_empty());
observations[0].apply(&mut account, &data, false).unwrap();
let before = account.cash();
let mut sell = input.clone();
sell.actions[0].orders[0].side = OrderSide::Sell;
reseal(&mut sell);
assert!(
sell.observations().unwrap()[0]
.apply(&mut account, &data, false)
.unwrap_err()
.contains("T+1")
);
assert_eq!(account.cash(), before);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
let unlisted = identity_data(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap());
assert!(
observations[0]
.apply(&mut account, &unlisted, false)
.unwrap_err()
.contains("lifecycle")
);
assert_eq!(account.cash(), before);
}
#[test]
fn the_next_day_manual_sale_keeps_the_actual_quantity_and_fee_contract() {
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let input = sample();
let mut account = PortfolioState::new(10_000.);
input.observations().unwrap()[0]
.apply(&mut account, &data, false)
.unwrap();
let mut sell = input.clone();
let order = &mut sell.actions[0].orders[0];
order.side = OrderSide::Sell;
order.order_created_at += chrono::Duration::days(1);
order.terminal_observed_at += chrono::Duration::days(1);
order.fills[0].trade_date = order.fills[0].trade_date.succ_opt().unwrap();
order.fills[0].executed_at += chrono::Duration::days(1);
order.fills[0].observed_at += chrono::Duration::days(1);
order.fills[0].fee_observed_at += chrono::Duration::days(1);
sell.observation_cutoff += chrono::Duration::days(1);
reseal(&mut sell);
let applied = sell.observations().unwrap()[0]
.apply(&mut account, &data, false)
.unwrap();
assert_eq!(applied.quantity_after, 0);
assert_eq!(account.cash(), 9999.76);
assert_eq!(account.external_cash_flow_total(), 0.);
}
#[test]
fn observations_follow_durable_receipt_order_and_not_input_array_order() {
let mut input = sample();
let mut second = input.actions[0].orders[0].fills[0].clone();
second.trade_id = "trade-2".into();
second.observation_event_id = "received-2".into();
second.observation_sequence = 2;
second.fee_observation_event_id = "received-2".into();
second.fee_observation_sequence = 2;
input.actions[0].orders[0].quantity = 200;
input.actions[0].orders[0].fills.insert(0, second);
reseal(&mut input);
assert_eq!(
input
.observations()
.unwrap()
.iter()
.map(|row| row.fill.observation_sequence)
.collect::<Vec<_>>(),
vec![1, 2]
);
let mut invalid = input.clone();
invalid.actions[0].orders[0].fills[0].observation_sequence = 1;
assert!(
semantic_result(&invalid)
.unwrap_err()
.contains("observation")
);
let mut invalid = input;
invalid.actions[0].orders[0].fills[0].observation_event_id = "received-1".into();
assert!(
semantic_result(&invalid)
.unwrap_err()
.contains("observation")
);
}
#[test]
fn partial_cancel_is_valid_but_full_fill_cannot_be_reported_as_cancelled() {
let mut input = sample();
input.actions[0].orders[0].quantity = 200;
input.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Cancelled;
semantic_result(&input).unwrap();
input.actions[0].orders[0].quantity = 100;
assert!(
semantic_result(&input)
.unwrap_err()
.contains("terminal status")
);
}
#[test]
fn cursor_waits_for_observation_and_never_reapplies_or_rewinds() {
let input = sample();
let at = input.actions[0].orders[0].fills[0].observed_at;
let mut replay = ManualReplayCursor::new(input).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut account = PortfolioState::new(10_000.);
assert_eq!(replay.next_observation_at(), Some(at));
assert!(
replay
.advance(
at - chrono::Duration::milliseconds(1),
&mut account,
&data,
false
)
.unwrap()
.is_empty()
);
assert_eq!(account.cash(), 10_000.);
let records = replay.advance(at, &mut account, &data, false).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].cash_delta, "-1012.465679");
assert_eq!(replay.applied_count(), 1);
assert_eq!(replay.next_observation_at(), None);
let cash = account.cash();
assert!(
replay
.advance(at, &mut account, &data, false)
.unwrap()
.is_empty()
);
assert_eq!(account.cash(), cash);
assert!(
replay
.advance(
at - chrono::Duration::seconds(1),
&mut account,
&data,
false
)
.unwrap_err()
.contains("backwards")
);
}
#[test]
fn failed_multi_receipt_advance_keeps_both_progress_and_portfolio_unchanged() {
let mut input = sample();
let mut next = input.actions[0].orders[0].fills[0].clone();
next.trade_id = "trade-2".into();
next.observation_event_id = "received-2".into();
next.observation_sequence = 2;
next.fee_observation_event_id = "received-2".into();
next.fee_observation_sequence = 2;
input.actions[0].orders[0].quantity = 200;
input.actions[0].orders[0].fills.push(next);
reseal(&mut input);
let at = input.actions[0].orders[0].fills[0].observed_at;
let mut replay = ManualReplayCursor::new(input).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut account = PortfolioState::new(1_500.);
assert!(replay.advance(at, &mut account, &data, false).is_err());
assert_eq!(account.cash(), 1_500.);
assert!(account.positions().is_empty());
assert_eq!(replay.applied_count(), 0);
assert_eq!(replay.next_observation_at(), Some(at));
}
#[test]
fn fixed_money_decimal_text_preserves_micro_units_without_float_conversion() {
for text in [
"0",
"100",
"-100",
"0.000001",
"-0.000001",
"12345678901234567890123456.123456",
] {
assert_eq!(
FixedMoney::from_decimal_str(text)
.unwrap()
.to_decimal_string(),
text
);
}
let min = FixedMoney::from_raw(i128::MIN);
assert!(min.to_decimal_string().starts_with('-'));
}