747 lines
27 KiB
Rust
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('-'));
|
|
}
|