Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 4 additions & 10 deletions rust_hft/apps/live/src/helpers/sentinel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,10 @@ async fn run_sentinel_loop(
.orders_submitted
.saturating_sub(last_orders_submitted) as f64
/ (check_interval_ms.max(1) as f64 / 1_000.0),
// ponytail: engine does not expose venue reconnects; wire adapter metrics here
// if Sentinel policy begins to act on reconnect frequency.
ws_reconnect_count: 0,
data_gap_count: u32::try_from(
engine_stats
.market_events_dropped
.saturating_add(engine_stats.snapshot_publish_failed)
.saturating_add(engine_stats.data_integrity_gaps),
)
.unwrap_or(u32::MAX),
data_gap_count: engine_stats
.market_events_dropped
.saturating_add(engine_stats.snapshot_publish_failed)
.saturating_add(engine_stats.data_integrity_gaps),
};
last_orders_submitted = engine_stats.orders_submitted;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ mod tests {
jurisdiction: Some("SG".into()),
eligibility_confirmed: true,
allow_tokenized_securities: true,
..Default::default()
},
side: Side::Buy,
quantity: Quantity(Decimal::from(2)),
Expand Down
18 changes: 18 additions & 0 deletions rust_hft/market-core/core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,24 @@ pub struct ComplianceContext {
pub eligibility_confirmed: bool,
#[serde(default)]
pub allow_tokenized_securities: bool,
/// Executable top-of-book depth captured with the order decision.
#[serde(default)]
pub top_depth_usd: Option<rust_decimal::Decimal>,
/// Spread captured with the order decision, in basis points.
#[serde(default)]
pub spread_bps: Option<rust_decimal::Decimal>,
/// Corporate-action state from an authoritative reference-data source.
#[serde(default)]
pub corporate_action_active: Option<bool>,
/// Identity of the authoritative compliance/market-quality evidence producer.
#[serde(default)]
pub evidence_source: Option<String>,
/// Venue for which the evidence was produced.
#[serde(default)]
pub evidence_venue: Option<VenueId>,
/// Local observation timestamp of the evidence, in microseconds since epoch.
#[serde(default)]
pub evidence_observed_at: Option<Timestamp>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ use engine::{
use futures::stream;
use hft_core::{
AssetClass, ComplianceContext, HftResult, OrderId, OrderType, Price, ProductType, Quantity,
RegulatoryProfile, Side, Symbol, TimeInForce,
RegulatoryProfile, Side, Symbol, TimeInForce, VenueId,
};
use ports::{BoxStream, ConnectionHealth, ExecutionClient, ExecutionEvent, OpenOrder, OrderIntent};
use rust_decimal::Decimal;
use tokio::sync::Mutex;

#[derive(Default)]
Expand Down Expand Up @@ -95,6 +96,12 @@ fn tokenized_security_intent() -> OrderIntent {
jurisdiction: Some("AE".to_string()),
eligibility_confirmed: true,
allow_tokenized_securities: true,
top_depth_usd: Some(Decimal::from(100_000)),
spread_bps: Some(Decimal::ONE),
corporate_action_active: Some(false),
evidence_source: Some("paper-reference-feed".to_string()),
evidence_venue: Some(VenueId::BINANCE_TOKENIZED_SECURITIES),
evidence_observed_at: Some(hft_core::now_micros()),
})
}

Expand Down
27 changes: 25 additions & 2 deletions rust_hft/market-core/ports/src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,8 +560,11 @@ pub trait RiskManager: Send + Sync {
account: &AccountView,
venue_specs: &std::collections::HashMap<VenueId, VenueSpec>,
) -> Vec<OrderIntent> {
// 默認實現:批量調用 review(),根據 target_venue 或 symbol 查找對應 VenueSpec
// Keep a projected account across the whole batch. Calling review() with the same
// account snapshot for every intent lets individually-valid orders exceed aggregate
// position/notional limits when they are emitted in one engine tick.
let mut approved_intents = Vec::new();
let mut projected_account = account.clone();

for intent in intents {
if matches!(intent.asset_class, AssetClass::TokenizedSecurity)
Expand Down Expand Up @@ -589,7 +592,27 @@ pub trait RiskManager: Send + Sync {
};

if let Some(spec) = venue_spec {
let reviewed = self.review(vec![intent], account, spec);
let reviewed = self.review(vec![intent], &projected_account, spec);
for approved in &reviewed {
let signed_quantity = match approved.side {
Side::Buy => approved.quantity.0,
Side::Sell => -approved.quantity.0,
};
let position = projected_account
.positions
.entry(approved.symbol.clone())
.or_insert_with(|| Position {
symbol: approved.symbol.clone(),
quantity: Quantity::zero(),
avg_price: approved.price.unwrap_or_else(Price::zero),
unrealized_pnl: rust_decimal::Decimal::ZERO,
realized_pnl: rust_decimal::Decimal::ZERO,
});
position.quantity.0 += signed_quantity;
if let Some(price) = approved.price {
position.avg_price = price;
}
}
approved_intents.extend(reviewed);
} else {
// 沒有找到對應的 VenueSpec,拒絕此訂單
Expand Down
181 changes: 181 additions & 0 deletions rust_hft/market-core/runtime/src/exposure_projection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
use std::collections::HashMap;

use hft_core::{ProductType, Symbol, VenueId};
use ports::{AccountView, OrderIntent};
use rust_decimal::Decimal;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ExposureKey {
venue: Option<VenueId>,
product_type: ProductType,
symbol: Symbol,
}

#[derive(Debug, Clone)]
pub(crate) struct ExposureProjection {
pub symbol_gross_quantity: Decimal,
pub gross_notional: Decimal,
}

/// Conservative projector for a batch of intents. Existing positions do not carry venue/product
/// attribution, so they cannot be proven reducible by an intent for a particular venue. They are
/// therefore retained as gross exposure; new exposure is isolated by venue + product + symbol.
#[derive(Debug, Clone)]
pub(crate) struct ExposureProjector {
remaining_quantity: HashMap<Symbol, Decimal>,
keyed_quantity: HashMap<ExposureKey, Decimal>,
keyed_notional: HashMap<ExposureKey, Decimal>,
gross_notional: Decimal,
}

impl ExposureProjector {
pub(crate) fn new(account: &AccountView) -> Self {
let remaining_quantity = account
.positions
.iter()
.map(|(symbol, position)| (symbol.clone(), position.quantity.0))
.collect();
let remaining_notional: HashMap<_, _> = account
.positions
.iter()
.map(|(symbol, position)| {
(
symbol.clone(),
(position.avg_price.0 * position.quantity.0 + position.unrealized_pnl).abs(),
)
})
.collect();
let gross_notional = remaining_notional.values().copied().sum();
Self {
remaining_quantity,
keyed_quantity: HashMap::new(),
keyed_notional: HashMap::new(),
gross_notional,
}
}

pub(crate) fn project(
&mut self,
intent: &OrderIntent,
) -> Result<ExposureProjection, &'static str> {
let price = intent
.price
.map(|price| price.0)
.filter(|price| *price > Decimal::ZERO)
.ok_or("projected exposure requires a positive executable price")?;
let incoming = match intent.side {
hft_core::Side::Buy => intent.quantity.0,
hft_core::Side::Sell => -intent.quantity.0,
};
if incoming.is_zero() {
return Err("projected exposure requires non-zero quantity");
}

if !incoming.is_zero() {
let key = ExposureKey {
venue: intent.target_venue,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not net orders before venue resolution

When target_venue is None, risk runs before the execution router chooses a venue (engine/src/lib.rs:1592-1598, then execution_worker.rs:1231-1232), but this key makes every unresolved-venue order share the same venue bucket. A same-symbol buy and sell with no target venue therefore net to zero for symbol_gross_quantity and can both pass the position cap, even though RoundRobin/StrategyMap can later send them to different venues. Treat unresolved venues as non-nettable or route before this projection.

Useful? React with 👍 / 👎.

product_type: intent.product_type,
symbol: intent.symbol.clone(),
};
let old_quantity = self.keyed_quantity.get(&key).copied().unwrap_or_default();
let next_quantity = old_quantity + incoming;
let old_notional = self.keyed_notional.get(&key).copied().unwrap_or_default();
let next_notional = old_notional + incoming.abs() * price;
self.gross_notional += next_notional - old_notional;
self.keyed_quantity.insert(key.clone(), next_quantity);
self.keyed_notional.insert(key, next_notional);
}

let unattributed = self
.remaining_quantity
.get(&intent.symbol)
.copied()
.unwrap_or_default()
.abs();
let keyed = self
.keyed_quantity
.iter()
.filter(|(key, _)| key.symbol == intent.symbol)
.map(|(_, quantity)| quantity.abs())
.sum::<Decimal>();
Ok(ExposureProjection {
symbol_gross_quantity: unattributed + keyed,
gross_notional: self.gross_notional,
})
}
}

#[cfg(test)]
mod tests {
use super::*;
use hft_core::{OrderType, Price, Quantity, Side, TimeInForce};

fn intent(venue: VenueId, side: Side, quantity: i64) -> OrderIntent {
OrderIntent::crypto_spot(
Symbol::new("BTCUSDT"),
side,
Quantity(Decimal::from(quantity)),
OrderType::Limit,
Some(Price(Decimal::ONE)),
TimeInForce::GTC,
"alpha".to_string(),
Some(venue),
)
}

#[test]
fn opposite_orders_on_different_venues_do_not_net() {
let mut projector = ExposureProjector::new(&AccountView::default());
projector
.project(&intent(VenueId::BINANCE, Side::Buy, 60))
.unwrap();
let projected = projector
.project(&intent(VenueId::BITGET, Side::Sell, 60))
.unwrap();

assert_eq!(projected.symbol_gross_quantity, Decimal::from(120));
assert_eq!(projected.gross_notional, Decimal::from(120));
}

#[test]
fn unattributed_existing_position_cannot_be_net_reduced() {
let symbol = Symbol::new("BTCUSDT");
let mut account = AccountView::default();
account.positions.insert(
symbol.clone(),
ports::Position {
symbol,
quantity: Quantity(Decimal::from(60)),
avg_price: Price(Decimal::ONE),
unrealized_pnl: Decimal::ZERO,
realized_pnl: Decimal::ZERO,
},
);
let mut projector = ExposureProjector::new(&account);

let projected = projector
.project(&intent(VenueId::BITGET, Side::Sell, 60))
.unwrap();

assert_eq!(projected.symbol_gross_quantity, Decimal::from(120));
assert_eq!(projected.gross_notional, Decimal::from(120));
}

#[test]
fn later_low_price_does_not_revalue_prior_exposure_below_the_cap() {
let mut projector = ExposureProjector::new(&AccountView::default());
let mut expensive = intent(VenueId::BINANCE, Side::Buy, 100);
expensive.price = Some(Price(Decimal::from(1_000)));
let mut cheap = intent(VenueId::BINANCE, Side::Buy, 100);
cheap.price = Some(Price(Decimal::ONE));

assert_eq!(
projector.project(&expensive).unwrap().gross_notional,
Decimal::from(100_000)
);
assert_eq!(
projector.project(&cheap).unwrap().gross_notional,
Decimal::from(100_100)
);
}
}
1 change: 1 addition & 0 deletions rust_hft/market-core/runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
//! - Risk managers
//! - Event consumers

mod exposure_projection;
pub mod ipc_handler;
pub mod portfolio_manager;
pub mod risk_manager_factory;
Expand Down
Loading
Loading