diff --git a/SPEC.md b/SPEC.md index 4c5b648a9..e7ebbc1b6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8363,12 +8363,15 @@ multiple broker-specific contexts. rather than losing them. Adapter-specific venue values use `trade_protocol=terminal_outcomes_v3`; legacy, v1, and v2 responses downgrade every adapter or unknown-onchain venue to Raindex so an older exhaustive - dashboard cannot enter a parse/reconnect loop. A newer dashboard requests v3 - and retries v2, then v1, when an older backend rejects an unknown protocol. - Legacy-protocol filter requests likewise collapse selected adapter venues to - Raindex so an old backend does not reject the venue query. The dashboard - continues to accept v1 responses and reconstructs the complete fill as the - legacy filled plus excess quantity. A legacy zero fill remains unknown + dashboard cannot enter a parse/reconnect loop. The v3 trade also carries + `price`, the fill price in USD per share: the onchain fill price, or the + broker fill price of a filled counter-trade, and `null` for a failed or + cancelled counter-trade. Older protocols omit the field. A newer dashboard + requests v3 and retries v2, then v1, when an older backend rejects an unknown + protocol. Legacy-protocol filter requests likewise collapse selected adapter + venues to Raindex so an old backend does not reject the venue query. The + dashboard continues to accept v1 responses and reconstructs the complete fill + as the legacy filled plus excess quantity. A legacy zero fill remains unknown because v1 synthesized zero when no fill evidence existed. A successful lower-version WebSocket fallback probes v3 again after its next disconnect so a transient restart cannot pin the client to an older protocol. Failed diff --git a/crates/dto/src/statement.rs b/crates/dto/src/statement.rs index e98829956..c3662ddcb 100644 --- a/crates/dto/src/statement.rs +++ b/crates/dto/src/statement.rs @@ -133,6 +133,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("AAPL").unwrap(), shares: Positive::new(FractionalShares::new(float!(10))).unwrap(), + price: None, outcome: crate::TradeOutcome::Filled, }; let msg = Statement::TradeUpdate(trade); diff --git a/crates/dto/src/trade.rs b/crates/dto/src/trade.rs index 3e0588e42..17f81ecb8 100644 --- a/crates/dto/src/trade.rs +++ b/crates/dto/src/trade.rs @@ -6,7 +6,7 @@ use serde::ser::{Error as _, SerializeStruct}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use ts_rs::TS; -use st0x_finance::{FractionalShares, NonNegative, Positive, Symbol}; +use st0x_finance::{FractionalShares, NonNegative, Positive, Symbol, Usd}; /// Where a trade was executed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] @@ -415,6 +415,11 @@ pub struct Trade { /// known. #[ts(type = "string")] pub shares: Positive, + /// Fill price in USD per share. Absent when the trade has no fill price: + /// a failed or cancelled counter-trade, or any trade whose price is not + /// known. Only `terminal_outcomes_v3` carries it on the wire. + #[ts(type = "string | null")] + pub price: Option, pub outcome: TradeOutcome, } @@ -423,7 +428,7 @@ impl Serialize for Trade { where S: Serializer, { - serialize_canonical_trade(self, self.venue, serializer) + serialize_canonical_trade(self, self.venue, WirePrice::Included, serializer) } } @@ -438,13 +443,28 @@ impl Serialize for LegacyCompatibleTrade<'_> { where S: Serializer, { - serialize_canonical_trade(self.trade, self.trade.venue.legacy_compatible(), serializer) + serialize_canonical_trade( + self.trade, + self.trade.venue.legacy_compatible(), + WirePrice::Omitted, + serializer, + ) } } +/// Whether a trade wire shape carries the fill price. Only +/// `terminal_outcomes_v3` does; older protocols keep the shape their clients +/// were built against. +#[derive(Clone, Copy)] +enum WirePrice { + Included, + Omitted, +} + fn serialize_canonical_trade( value: &Trade, venue: TradingVenue, + price: WirePrice, serializer: S, ) -> Result where @@ -454,7 +474,14 @@ where TradeOutcome::Filled => true, TradeOutcome::Failed { .. } | TradeOutcome::Cancelled { .. } => false, }; - let mut trade = serializer.serialize_struct("Trade", 7 + usize::from(is_filled))?; + let carries_price = match price { + WirePrice::Included => true, + WirePrice::Omitted => false, + }; + let mut trade = serializer.serialize_struct( + "Trade", + 7 + usize::from(is_filled) + usize::from(carries_price), + )?; trade.serialize_field("id", &value.id)?; trade.serialize_field("occurredAt", &value.occurred_at)?; if is_filled { @@ -464,6 +491,9 @@ where trade.serialize_field("direction", &value.direction)?; trade.serialize_field("symbol", &value.symbol)?; trade.serialize_field("shares", &value.shares)?; + if carries_price { + trade.serialize_field("price", &value.price)?; + } trade.serialize_field("outcome", &value.outcome)?; trade.end() } @@ -506,7 +536,12 @@ impl Serialize for TerminalOutcomesV1Trade<'_> { .. } = &self.trade.outcome else { - return serialize_canonical_trade(self.trade, self.venue, serializer); + return serialize_canonical_trade( + self.trade, + self.venue, + WirePrice::Omitted, + serializer, + ); }; // When acceptance provenance is unknown, v1 has no way to express @@ -734,6 +769,7 @@ mod tests { direction: Direction::Sell, symbol: Symbol::new("TSLA").unwrap(), shares: positive_shares("5.5"), + price: None, outcome: TradeOutcome::Filled, }; let json = serde_json::to_value(&trade).expect("serialization should succeed"); @@ -747,6 +783,98 @@ mod tests { assert_eq!(json["outcome"], json!({ "status": "filled" })); } + #[test] + fn canonical_trade_serializes_price_as_a_decimal_string() { + let trade = Trade { + id: "priced-order-id".to_string(), + occurred_at: DateTime::from_timestamp(1_700_000_000, 0).unwrap(), + venue: TradingVenue::Alpaca, + direction: Direction::Buy, + symbol: Symbol::new("TSLA").unwrap(), + shares: positive_shares("5.5"), + price: Some(Usd::new(float!(199.5))), + outcome: TradeOutcome::Filled, + }; + + let json = serde_json::to_value(&trade).unwrap(); + + assert_eq!(json["price"], json!("199.5")); + } + + #[test] + fn canonical_trade_serializes_an_absent_price_as_null() { + let trade = Trade { + id: "unpriced-order-id".to_string(), + occurred_at: DateTime::from_timestamp(1_700_000_000, 0).unwrap(), + venue: TradingVenue::Alpaca, + direction: Direction::Buy, + symbol: Symbol::new("TSLA").unwrap(), + shares: positive_shares("5.5"), + price: None, + outcome: TradeOutcome::Cancelled { + accepted_shares: None, + filled_shares: None, + remaining_shares: None, + excess_shares: None, + }, + }; + + let json = serde_json::to_value(&trade).unwrap(); + + assert_eq!(json.get("price"), Some(&json!(null))); + } + + #[test] + fn protocols_older_than_v3_keep_their_wire_shape_without_a_price() { + let trade = Trade { + id: "priced-order-id".to_string(), + occurred_at: DateTime::from_timestamp(1_700_000_000, 0).unwrap(), + venue: TradingVenue::Alpaca, + direction: Direction::Buy, + symbol: Symbol::new("TSLA").unwrap(), + shares: positive_shares("5.5"), + price: Some(Usd::new(float!(199.5))), + outcome: TradeOutcome::Filled, + }; + let failed = Trade { + outcome: TradeOutcome::Failed { + error: "placement rejected".to_string(), + accepted_shares: None, + filled_shares: None, + remaining_shares: None, + excess_shares: None, + }, + ..trade.clone() + }; + + for wire in [ + serde_json::to_value(trade.legacy_compatible()).unwrap(), + serde_json::to_value(trade.terminal_outcomes_v1()).unwrap(), + serde_json::to_value(trade.legacy_terminal_outcomes_v1()).unwrap(), + serde_json::to_value(failed.terminal_outcomes_v1()).unwrap(), + serde_json::to_value(trade.legacy_fill().unwrap()).unwrap(), + ] { + assert_eq!(wire.get("price"), None, "unexpected price in {wire}"); + } + } + + #[test] + fn trade_deserializes_a_payload_persisted_before_price_existed() { + let payload = json!({ + "id": "order-1", + "occurredAt": "2026-07-20T12:00:00Z", + "venue": "alpaca", + "direction": "buy", + "symbol": "AAPL", + "shares": "1", + "outcome": { "status": "filled" } + }); + + let trade: Trade = serde_json::from_value(payload).unwrap(); + + assert_eq!(trade.price, None); + } + #[test] fn trade_roundtrips_through_persistent_job_payload() { let trade = Trade { @@ -756,6 +884,7 @@ mod tests { direction: Direction::Sell, symbol: Symbol::new("TSLA").unwrap(), shares: positive_shares("5.5"), + price: Some(Usd::new(float!(199.5))), outcome: TradeOutcome::Filled, }; @@ -768,6 +897,7 @@ mod tests { assert_eq!(restored.direction, trade.direction); assert_eq!(restored.symbol, trade.symbol); assert_eq!(restored.shares, trade.shares); + assert_eq!(restored.price, Some(Usd::new(float!(199.5)))); assert_eq!(restored.outcome, trade.outcome); } @@ -804,6 +934,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("SPCX").unwrap(), shares: positive_shares("1"), + price: None, outcome: TradeOutcome::Failed { error: "asset is not tradable".to_string(), accepted_shares: Some(positive_shares("1")), @@ -881,6 +1012,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("SPCX").unwrap(), shares: positive_shares("1.5"), + price: None, outcome: TradeOutcome::Cancelled { accepted_shares: Some(positive_shares("1")), filled_shares: Some(NonNegative::new(FractionalShares::ZERO).unwrap()), @@ -917,6 +1049,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("SPCX").unwrap(), shares: positive_shares("1"), + price: None, outcome: TradeOutcome::Cancelled { accepted_shares: None, filled_shares: None, @@ -1141,6 +1274,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("SPCX").unwrap(), shares: positive_shares("2"), + price: None, outcome: TradeOutcome::Failed { error: "broker failed after overfill".to_string(), accepted_shares: Some(positive_shares("1")), @@ -1169,6 +1303,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("SPCX").unwrap(), shares: positive_shares("2"), + price: None, outcome: TradeOutcome::Failed { error: "asset is not tradable".to_string(), accepted_shares: None, @@ -1200,6 +1335,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("SPCX").unwrap(), shares: positive_shares("1"), + price: None, outcome: TradeOutcome::Filled, }; let failed = Trade { @@ -1235,6 +1371,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("AAPL").unwrap(), shares: positive_shares("1"), + price: None, outcome: TradeOutcome::Filled, }; let tx_hash = "0x0000000000000000000000000000000000000000000000000000000000000000"; @@ -1267,6 +1404,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("AAPL").unwrap(), shares: positive_shares("1"), + price: None, outcome: TradeOutcome::Filled, }; let mut trades = vec![ diff --git a/dashboard/src/lib/components/trade-history-panel.test.ts b/dashboard/src/lib/components/trade-history-panel.test.ts index 19b27db59..633b11717 100644 --- a/dashboard/src/lib/components/trade-history-panel.test.ts +++ b/dashboard/src/lib/components/trade-history-panel.test.ts @@ -19,6 +19,7 @@ const staleTrade: Trade = { direction: 'buy', symbol: 'SPCX', shares: '1', + price: null, outcome: { status: 'filled' } } @@ -29,6 +30,7 @@ const failedTrade = (id: string, overrides: Partial = {}): Trade => ({ direction: 'buy', symbol: 'SPCX', shares: '1', + price: null, outcome: { status: 'failed', error: 'broker rejected remainder', @@ -156,6 +158,7 @@ describe('TradeHistoryPanel', () => { direction: 'sell', symbol: 'SGOV', shares: '2', + price: null, outcome: { status: 'filled' } } const hedge: Trade = { @@ -165,6 +168,7 @@ describe('TradeHistoryPanel', () => { direction: 'buy', symbol: 'SGOV', shares: '2.02', + price: null, outcome: { status: 'filled' } } vi.stubGlobal( @@ -192,6 +196,7 @@ describe('TradeHistoryPanel', () => { direction: 'buy', symbol: 'COIN', shares: '1', + price: null, outcome: { status: 'filled' } } const fetchMock = vi.fn((_input: RequestInfo | URL) => @@ -237,6 +242,7 @@ describe('TradeHistoryPanel', () => { direction: 'buy', symbol: 'DOWNGRADED', shares: '1', + price: null, outcome: { status: 'filled' } } const fetchMock = vi.fn((input: RequestInfo | URL) => { @@ -317,6 +323,7 @@ describe('TradeHistoryPanel', () => { direction: 'buy', symbol: 'COIN', shares: '1', + price: null, outcome: { status: 'filled' } } vi.stubGlobal( diff --git a/dashboard/src/lib/trade-payload.test.ts b/dashboard/src/lib/trade-payload.test.ts index 938b8e45e..e50119985 100644 --- a/dashboard/src/lib/trade-payload.test.ts +++ b/dashboard/src/lib/trade-payload.test.ts @@ -14,6 +14,7 @@ const validTrade = (overrides: Record = {}): unknown => ({ direction: 'sell', symbol: 'AAPL', shares: '1.25', + price: '199.5', outcome: { status: 'filled' }, ...overrides }) @@ -40,6 +41,16 @@ describe('trade payload validation', () => { }) }) + it('keeps an absent fill price as null', () => { + expect(parseTrade(validTrade({ price: null }))).toEqual(validTrade({ price: null })) + }) + + it('treats a payload from a backend that predates the fill price as unpriced', () => { + const { price: _price, ...withoutPrice } = validTrade() as Record + + expect(parseTrade(withoutPrice)).toEqual({ ...withoutPrice, price: null }) + }) + it.each(['bebop', 'uniswap_v4', 'unknown_onchain'])( 'accepts %s as an onchain trading venue', (venue) => { @@ -74,6 +85,9 @@ describe('trade payload validation', () => { ['negative total shares', { shares: '-0.1' }, 'shares'], ['non-numeric total shares', { shares: 'many' }, 'shares'], ['non-decimal total shares', { shares: '0x10' }, 'shares'], + ['non-decimal price', { price: '0x10' }, 'price'], + ['zero price', { price: '0' }, 'price'], + ['non-string price', { price: 199.5 }, 'price'], ['outcome variant', { outcome: { status: 'pending' } }, 'outcome.status'], [ 'negative filled shares', diff --git a/dashboard/src/lib/trade-payload.ts b/dashboard/src/lib/trade-payload.ts index f73b3b3b5..ef17f72cb 100644 --- a/dashboard/src/lib/trade-payload.ts +++ b/dashboard/src/lib/trade-payload.ts @@ -278,6 +278,8 @@ export const parseCanonicalTrade = (value: unknown, path = ''): Trade => { return { ...parseCommonTradeFields(trade, path), occurredAt: parseTimestamp(trade['occurredAt'], fieldPath(path, 'occurredAt')), + // Absent before terminal_outcomes_v3 carried the fill price. + price: parseNullableDecimal(trade['price'] ?? null, fieldPath(path, 'price'), 'positive'), outcome: parseOutcome(trade['outcome'], fieldPath(path, 'outcome')) } } diff --git a/dashboard/src/lib/trade.test.ts b/dashboard/src/lib/trade.test.ts index 0fbc4bbba..c187ac198 100644 --- a/dashboard/src/lib/trade.test.ts +++ b/dashboard/src/lib/trade.test.ts @@ -109,6 +109,7 @@ describe('legacy trade normalization', () => { direction: 'buy', symbol: 'SPCX', shares: '1.5', + price: null, outcome: { status: 'filled' } }) }) @@ -121,6 +122,7 @@ describe('legacy trade normalization', () => { direction: 'sell', symbol: 'SPCX', shares: '2', + price: null, outcome: { status: 'filled' } } @@ -136,6 +138,7 @@ describe('live trade history', () => { direction: 'buy', symbol: 'AAPL', shares: '1', + price: null, outcome: { status: 'filled' }, ...overrides }) diff --git a/dashboard/src/lib/trade.ts b/dashboard/src/lib/trade.ts index bf6de58b5..aef9576f3 100644 --- a/dashboard/src/lib/trade.ts +++ b/dashboard/src/lib/trade.ts @@ -16,6 +16,7 @@ export const normalizeTrade = (trade: Trade | LegacyTrade): Trade => { direction: trade.direction, symbol: trade.symbol, shares: trade.shares, + price: null, outcome: { status: 'filled' } } } diff --git a/dashboard/src/lib/websocket/connection.svelte.test.ts b/dashboard/src/lib/websocket/connection.svelte.test.ts index ba518b4de..6cd111220 100644 --- a/dashboard/src/lib/websocket/connection.svelte.test.ts +++ b/dashboard/src/lib/websocket/connection.svelte.test.ts @@ -83,6 +83,7 @@ const makeTrade = (overrides: Partial = {}): Trade => ({ direction: 'buy', symbol: 'AAPL', shares: '10', + price: null, outcome: { status: 'filled' }, ...overrides }) @@ -563,6 +564,7 @@ describe('createWebSocket', () => { direction: 'sell', symbol: 'TSLA', shares: '2', + price: null, outcome: { status: 'filled' } } ]) @@ -597,6 +599,7 @@ describe('createWebSocket', () => { direction: 'buy', symbol: 'AAPL', shares: '1', + price: null, outcome: { status: 'filled' } } ]) diff --git a/src/api.rs b/src/api.rs index 2c56998e7..56d3291a8 100644 --- a/src/api.rs +++ b/src/api.rs @@ -4865,6 +4865,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("AAPL").unwrap(), shares: Positive::new(FractionalShares::new(float!(1))).unwrap(), + price: Some(st0x_finance::Usd::new(float!(199.5))), outcome: TradeOutcome::Filled, }; @@ -4885,6 +4886,16 @@ mod tests { assert_eq!(wire["entries"][0]["venue"], expected_venue); assert_eq!(wire["entries"][0]["filledAt"], "2026-01-01T00:00:00Z"); assert_eq!(wire["entries"][0]["outcome"]["status"], "filled"); + match trade_protocol { + TradeProtocol::TerminalOutcomesV3 => { + assert_eq!(wire["entries"][0]["price"], "199.5"); + } + TradeProtocol::LegacyFills + | TradeProtocol::TerminalOutcomesV1 + | TradeProtocol::TerminalOutcomesV2 => { + assert_eq!(wire["entries"][0].get("price"), None); + } + } assert_eq!(wire["total"], 1); assert_eq!(wire["hasMore"], false); } @@ -5091,6 +5102,78 @@ mod tests { assert_eq!(cancelled["outcome"]["excessShares"], "0"); } + async fn insert_offchain_view_row(pool: &sqlx::SqlitePool, view_id: &str, payload: &str) { + sqlx::query("INSERT INTO offchain_order_view (view_id, version, payload) VALUES (?, 1, ?)") + .bind(view_id) + .bind(payload) + .execute(pool) + .await + .unwrap(); + } + + async fn trades_wire(state: AppState, trade_protocol: &str) -> serde_json::Value { + let response = build_app(state) + .oneshot( + Request::builder() + .uri(format!("/trades?trade_protocol={trade_protocol}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + serde_json::from_str(&body_to_string(response).await).unwrap() + } + + #[tokio::test] + async fn trades_endpoint_serves_the_fill_price_on_v3_only() { + let state = empty_app_state(create_test_ctx_with_order_owner(Address::ZERO)).await; + let pool = state.pool.clone(); + let filled_view_id = "00000000-0000-0000-0000-000000000150"; + let filled_payload = r#"{"Live":{"Filled":{"symbol":"AAPL","shares":"2","direction":"Sell","executor":"AlpacaBrokerApi","executor_order_id":"broker-fill","price":"199.5","placed_at":"2026-01-01T00:00:00Z","submitted_at":"2026-01-01T00:00:00Z","filled_at":"2026-01-01T00:00:03Z"}}}"#; + insert_offchain_view_row(&pool, filled_view_id, filled_payload).await; + // A priced partial fill is retained, but a failed or cancelled + // counter-trade has no fill price of its own. + let failed_view_id = "00000000-0000-0000-0000-000000000151"; + let failed_payload = r#"{"Live":{"Failed":{"symbol":"SPCX","shares":"1","direction":"Buy","executor":"AlpacaBrokerApi","retained_fill":{"Priced":{"shares_filled":"0.25","avg_price":"25","partially_filled_at":"2026-01-01T00:00:00Z"}},"executor_order_id":"broker-order","error":"asset is not tradable","placed_at":"2026-01-01T00:00:00Z","failed_at":"2026-01-01T00:00:01Z"}}}"#; + insert_offchain_view_row(&pool, failed_view_id, failed_payload).await; + let cancelled_view_id = "00000000-0000-0000-0000-000000000152"; + let cancelled_payload = r#"{"Live":{"Cancelled":{"symbol":"MSFT","shares":"1","requested_shares":"1.5","filled_shares":"0","direction":"Sell","executor":"AlpacaBrokerApi","executor_order_id":"broker-cancel","reason":"MarketOpenReplacement","placed_at":"2026-01-01T00:00:01Z","cancelled_at":"2026-01-01T00:00:02Z"}}}"#; + insert_offchain_view_row(&pool, cancelled_view_id, cancelled_payload).await; + + let v3 = trades_wire(state.clone(), "terminal_outcomes_v3").await; + let v3_entry = |id: &str| { + v3["entries"] + .as_array() + .unwrap() + .iter() + .find(|trade| trade["id"] == id) + .unwrap_or_else(|| panic!("v3 clients should receive trade {id}")) + }; + assert_eq!(v3_entry(filled_view_id)["price"], "199.5"); + assert_eq!( + v3_entry(failed_view_id).get("price"), + Some(&serde_json::Value::Null) + ); + assert_eq!( + v3_entry(cancelled_view_id).get("price"), + Some(&serde_json::Value::Null) + ); + + for older_protocol in [ + "terminal_outcomes_v2", + "terminal_outcomes_v1", + "legacy_fills", + ] { + let wire = trades_wire(state.clone(), older_protocol).await; + let entries = wire["entries"].as_array().unwrap(); + assert!( + !entries.is_empty() && entries.iter().all(|trade| trade.get("price").is_none()), + "{older_protocol} keeps the wire shape its clients were built against" + ); + } + } + #[tokio::test] async fn offchain_trade_history_skips_malformed_rows() { let state = empty_app_state(create_test_ctx_with_order_owner(Address::ZERO)).await; diff --git a/src/dashboard/event.rs b/src/dashboard/event.rs index 5d4e2eb76..c9bc0ae45 100644 --- a/src/dashboard/event.rs +++ b/src/dashboard/event.rs @@ -23,7 +23,7 @@ use st0x_dto::{Statement, Trade, TradeOutcome}; use st0x_event_sorcery::{ AggregateError, EntityList, Reactor, SendError, deps, is_retryable_sqlite_busy, load_entity, }; -use st0x_finance::{FractionalShares, NotPositive, Positive}; +use st0x_finance::{FractionalShares, NotPositive, Positive, Usd}; use crate::conductor::job::{Job, JobQueue, Label, QueuePushError}; use crate::equity_redemption::EquityRedemption; @@ -1166,6 +1166,7 @@ impl Reactor for Broadcaster { symbol, amount, direction, + price_usdc, block_timestamp, .. } => { @@ -1176,6 +1177,7 @@ impl Reactor for Broadcaster { direction, symbol, shares: Positive::new(FractionalShares::new(amount))?, + price: Some(Usd::new(price_usdc)), outcome: TradeOutcome::Filled, }) .await?; @@ -1373,6 +1375,7 @@ mod tests { direction: st0x_execution::Direction::Sell, symbol: Symbol::new("AAPL").unwrap(), shares: Positive::new(FractionalShares::new(st0x_float_macro::float!(1))).unwrap(), + price: None, outcome: TradeOutcome::Filled, } } @@ -2376,6 +2379,10 @@ mod tests { panic!("expected TradeUpdate message"); }; assert_eq!(trade.venue, TradingVenue::Bebop); + assert_eq!( + trade.price, + Some(st0x_finance::Usd::new(st0x_float_macro::float!(150))) + ); } #[tokio::test] @@ -2865,6 +2872,10 @@ mod tests { assert!(matches!(trade.venue, TradingVenue::Alpaca)); assert!(matches!(trade.direction, st0x_dto::Direction::Sell)); assert_eq!(trade.symbol, Symbol::new("TSLA").unwrap()); + assert_eq!( + trade.price, + Some(st0x_finance::Usd::new(st0x_float_macro::float!(245))) + ); } other => panic!("expected TradeUpdate message, got {other:?}"), } @@ -2973,6 +2984,7 @@ mod tests { &trade.outcome, &history[0].outcome, "live and historical failure provenance must be identical" ); + assert_eq!(trade.price, None); match trade.outcome { st0x_dto::TradeOutcome::Failed { error, @@ -3119,6 +3131,7 @@ mod tests { .unwrap() .trades; assert_eq!(trade.outcome, history[0].outcome); + assert_eq!(trade.price, None); assert!(matches!( trade.outcome, TradeOutcome::Cancelled { diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index fe7a3cf2a..08f36feeb 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -545,6 +545,7 @@ mod tests { st0x_float_macro::float!(1), )) .unwrap(), + price: None, outcome: st0x_dto::TradeOutcome::Filled, }) } @@ -580,6 +581,7 @@ mod tests { direction: Direction::Sell, symbol: Symbol::new("AAPL").unwrap(), shares: Positive::new(FractionalShares::new(float!(1))).unwrap(), + price: None, outcome: TradeOutcome::Cancelled { accepted_shares: Some(Positive::new(FractionalShares::new(float!(1))).unwrap()), filled_shares: Some( @@ -614,6 +616,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("AAPL").unwrap(), shares: Positive::new(FractionalShares::new(float!(1))).unwrap(), + price: None, outcome: TradeOutcome::Filled, }; @@ -1100,6 +1103,7 @@ mod tests { direction: Direction::Buy, symbol: Symbol::new("TSLA").unwrap(), shares: Positive::new(FractionalShares::new(float!(1))).unwrap(), + price: None, outcome: TradeOutcome::Filled, }; server @@ -1132,6 +1136,7 @@ mod tests { direction: Direction::Sell, symbol: Symbol::new("TSLA").unwrap(), shares: Positive::new(FractionalShares::new(float!(1))).unwrap(), + price: None, outcome: TradeOutcome::Failed { error: "broker rejected order".to_string(), accepted_shares: None, diff --git a/src/dashboard/trade_loader.rs b/src/dashboard/trade_loader.rs index 253cb690c..03871cc81 100644 --- a/src/dashboard/trade_loader.rs +++ b/src/dashboard/trade_loader.rs @@ -685,6 +685,7 @@ mod tests { assert_eq!(trades[0].symbol, Symbol::new("AAPL").unwrap()); assert_eq!(trades[0].venue, TradingVenue::Raindex); assert_eq!(trades[0].direction, Direction::Buy); + assert_eq!(trades[0].price, Some(Usd::new(float!(150)))); assert_eq!(trades[0].outcome, TradeOutcome::Filled); } @@ -841,6 +842,11 @@ mod tests { assert_eq!(trades.len(), 3); assert_eq!(find(&filled_id).outcome, TradeOutcome::Filled); assert_eq!(find(&filled_id).venue, TradingVenue::Alpaca); + assert_eq!(find(&filled_id).price, Some(Usd::new(float!(200)))); + assert_eq!(find(&failed_id).price, None); + // A partial fill carried a price, but a cancelled trade has no fill + // price of its own. + assert_eq!(find(&cancelled_id).price, None); assert!(matches!( &find(&failed_id).outcome, TradeOutcome::Failed { error, .. } if error == "asset is not tradable" diff --git a/src/offchain/order.rs b/src/offchain/order.rs index 7ceb4ce4c..19882f1f1 100644 --- a/src/offchain/order.rs +++ b/src/offchain/order.rs @@ -1979,12 +1979,13 @@ impl OffchainOrder { self, id: &OffchainOrderId, ) -> Result { - let (symbol, shares, direction, executor, occurred_at, outcome) = match self { + let (symbol, shares, direction, executor, occurred_at, price, outcome) = match self { Self::Filled { symbol, shares, direction, executor, + price, filled_at, .. } => ( @@ -1993,6 +1994,7 @@ impl OffchainOrder { direction, executor, filled_at, + Some(price), TradeOutcome::Filled, ), Self::Failed { @@ -2020,6 +2022,7 @@ impl OffchainOrder { direction, executor, failed_at, + None, TradeOutcome::Failed { error, accepted_shares: quantities.accepted, @@ -2053,6 +2056,7 @@ impl OffchainOrder { direction, executor, cancelled_at, + None, TradeOutcome::Cancelled { accepted_shares: quantities.accepted, filled_shares: quantities.filled, @@ -2077,6 +2081,7 @@ impl OffchainOrder { direction, symbol, shares, + price, outcome, }) } @@ -4103,6 +4108,27 @@ mod tests { ); } + #[test] + fn filled_trade_carries_the_fill_price() { + let filled_at = "2026-01-05T14:30:00Z".parse::>().unwrap(); + let order = OffchainOrder::Filled { + symbol: Symbol::new("AAPL").unwrap(), + shares: Positive::new(FractionalShares::new(float!(2))).unwrap(), + direction: Direction::Sell, + executor: SupportedExecutor::AlpacaBrokerApi, + executor_order_id: ExecutorOrderId::new("filled-order"), + price: Usd::new(float!(199.5)), + placed_at: filled_at, + submitted_at: filled_at, + filled_at, + }; + + let trade = order.try_into_trade(&OffchainOrderId::new()).unwrap(); + + assert_eq!(trade.outcome, TradeOutcome::Filled); + assert_eq!(trade.price, Some(Usd::new(float!(199.5)))); + } + #[test] fn failed_trade_preserves_requested_accepted_and_filled_shares() { let fill_time = "2026-01-05T14:30:00Z".parse::>().unwrap(); @@ -4130,6 +4156,7 @@ mod tests { let trade = order .try_into_trade(&OffchainOrderId::new()) .expect("valid terminal failure should convert"); + assert_eq!(trade.price, None); assert_eq!(trade.occurred_at, failure_time); assert!(trade.shares.inner().inner().eq(float!(2)).unwrap()); match trade.outcome { @@ -4294,6 +4321,7 @@ mod tests { }; let trade = order.try_into_trade(&OffchainOrderId::new()).unwrap(); + assert_eq!(trade.price, None); let TradeOutcome::Cancelled { filled_shares, remaining_shares, diff --git a/src/onchain_trade.rs b/src/onchain_trade.rs index 25a7140ef..ea3ed5507 100644 --- a/src/onchain_trade.rs +++ b/src/onchain_trade.rs @@ -23,7 +23,7 @@ use st0x_dto::{Direction, Trade, TradeOutcome, TradingVenue}; use st0x_event_sorcery::{DomainEvent, EventSourced, Table}; use st0x_evm::{Chain, ParseChainError}; use st0x_execution::Symbol; -use st0x_finance::{FractionalShares, NotPositive, Positive}; +use st0x_finance::{FractionalShares, NotPositive, Positive, Usd}; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct OnChainTradeId { @@ -322,6 +322,7 @@ impl OnChainTrade { direction: self.direction, symbol: self.symbol, shares: Positive::new(FractionalShares::new(self.amount))?, + price: Some(Usd::new(self.price_usdc)), outcome: TradeOutcome::Filled, }) } @@ -1293,6 +1294,33 @@ mod tests { ); } + #[test] + fn dashboard_trade_carries_the_fill_price() { + let now = Utc::now(); + let trade = replay::(vec![OnChainTradeEvent::Filled { + source: OnChainTradeSource::Raindex, + symbol: Symbol::new("AAPL").unwrap(), + amount: float!(2), + direction: Direction::Buy, + price_usdc: float!(150.25), + block_number: 12345, + block_timestamp: now, + filled_at: now, + }]) + .unwrap() + .unwrap(); + + let dashboard_trade = trade + .try_into_trade(&OnChainTradeId { + chain: Chain::Base, + tx_hash: TxHash::ZERO, + log_index: 0, + }) + .unwrap(); + + assert_eq!(dashboard_trade.price, Some(Usd::new(float!(150.25)))); + } + #[test] fn dashboard_trade_rejects_non_positive_fill_quantity() { let now = Utc::now();