Compare commits

..

13 Commits

28 changed files with 4336 additions and 313 deletions
+7
View File
@@ -2754,6 +2754,13 @@ where
.insert(symbol.to_string()); .insert(symbol.to_string());
} }
pub(crate) fn record_observed_manual_execution(&self, execution: &crate::manual_execution::ManualReplayApplication) {
if execution.side == OrderSide::Sell {
let date = execution.executed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
self.mark_same_day_sold(date, &execution.symbol);
}
}
fn same_day_rebuy_rejection_reason( fn same_day_rebuy_rejection_reason(
&self, &self,
date: NaiveDate, date: NaiveDate,
+150 -46
View File
@@ -313,8 +313,8 @@ pub enum QuoteObservationKind {
/// Sparse same-day fields layered onto an already-built immutable daily panel. /// Sparse same-day fields layered onto an already-built immutable daily panel.
/// ///
/// These fields do not participate in daily price series, adjustment series, /// These fields leave daily OHLC, adjustment series and symbol indexes intact,
/// symbol indexes, or rolling windows. Applying them in place lets the runner /// but update quote history and Last-price rolling windows. Applying them lets the runner
/// reuse the candidate-planning `DataSet` as the final execution `DataSet` /// reuse the candidate-planning `DataSet` as the final execution `DataSet`
/// without rebuilding the full market panel. /// without rebuilding the full market panel.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -597,17 +597,21 @@ pub fn decision_free_float_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct SymbolPriceSeries { struct SymbolPriceSeries {
base: Arc<SymbolDailySeriesBase>, base: Arc<SymbolDailySeriesBase>,
timestamps: Vec<Option<String>>, timestamps: RepeatedValues<Option<String>>,
last_prices: Vec<f64>, last_prices: ReferenceMatchedValues,
bid1s: Vec<f64>, bid1s: ReferenceMatchedValues,
ask1s: Vec<f64>, ask1s: ReferenceMatchedValues,
minute_volumes: Vec<u64>, minute_volumes: RepeatedValues<u64>,
bid1_volumes: Vec<u64>, bid1_volumes: RepeatedValues<u64>,
ask1_volumes: Vec<u64>, ask1_volumes: RepeatedValues<u64>,
trading_phases: Vec<Option<String>>, trading_phases: RepeatedValues<Option<String>>,
last_prefix: Vec<f64>, last_prefix: ReferenceMatchedValues,
} }
#[path = "series_columns.rs"]
mod series_columns;
use series_columns::{ReferenceMatchedValues, RepeatedValues};
#[derive(Debug)] #[derive(Debug)]
struct SymbolDailySeriesBase { struct SymbolDailySeriesBase {
symbol: String, symbol: String,
@@ -623,6 +627,7 @@ struct SymbolDailySeriesBase {
upper_limits: Vec<f64>, upper_limits: Vec<f64>,
lower_limits: Vec<f64>, lower_limits: Vec<f64>,
price_ticks: Vec<f64>, price_ticks: Vec<f64>,
day_open_prefix: Vec<f64>,
open_prefix: Vec<f64>, open_prefix: Vec<f64>,
close_prefix: Vec<f64>, close_prefix: Vec<f64>,
prev_close_prefix: Vec<f64>, prev_close_prefix: Vec<f64>,
@@ -839,51 +844,52 @@ impl SymbolPriceSeries {
); );
let row_count = rows.len(); let row_count = rows.len();
let mut dates = Vec::with_capacity(row_count); let mut dates = Vec::with_capacity(row_count);
let mut timestamps = Vec::with_capacity(row_count); let mut timestamps = RepeatedValues::new();
let mut day_opens = Vec::with_capacity(row_count); let mut day_opens = Vec::with_capacity(row_count);
let mut opens = Vec::with_capacity(row_count); let mut opens = Vec::with_capacity(row_count);
let mut highs = Vec::with_capacity(row_count); let mut highs = Vec::with_capacity(row_count);
let mut lows = Vec::with_capacity(row_count); let mut lows = Vec::with_capacity(row_count);
let mut closes = Vec::with_capacity(row_count); let mut closes = Vec::with_capacity(row_count);
let mut prev_closes = Vec::with_capacity(row_count); let mut prev_closes = Vec::with_capacity(row_count);
let mut last_prices = Vec::with_capacity(row_count); let mut last_prices = ReferenceMatchedValues::Identical;
let mut bid1s = Vec::with_capacity(row_count); let mut bid1s = ReferenceMatchedValues::Identical;
let mut ask1s = Vec::with_capacity(row_count); let mut ask1s = ReferenceMatchedValues::Identical;
let mut volumes = Vec::with_capacity(row_count); let mut volumes = Vec::with_capacity(row_count);
let mut minute_volumes = Vec::with_capacity(row_count); let mut minute_volumes = RepeatedValues::new();
let mut bid1_volumes = Vec::with_capacity(row_count); let mut bid1_volumes = RepeatedValues::new();
let mut ask1_volumes = Vec::with_capacity(row_count); let mut ask1_volumes = RepeatedValues::new();
let mut trading_phases = Vec::with_capacity(row_count); let mut trading_phases = RepeatedValues::new();
let mut paused = Vec::with_capacity(row_count); let mut paused = Vec::with_capacity(row_count);
let mut upper_limits = Vec::with_capacity(row_count); let mut upper_limits = Vec::with_capacity(row_count);
let mut lower_limits = Vec::with_capacity(row_count); let mut lower_limits = Vec::with_capacity(row_count);
let mut price_ticks = Vec::with_capacity(row_count); let mut price_ticks = Vec::with_capacity(row_count);
for row in rows { for row in rows {
dates.push(row.date); dates.push(row.date);
timestamps.push(row.timestamp.clone()); timestamps.push(&row.timestamp, row_count);
day_opens.push(row.day_open); day_opens.push(row.day_open);
opens.push(row.open); opens.push(row.open);
highs.push(row.high); highs.push(row.high);
lows.push(row.low); lows.push(row.low);
closes.push(row.close); closes.push(row.close);
prev_closes.push(row.prev_close); prev_closes.push(row.prev_close);
last_prices.push(row.last_price); last_prices.push(row.last_price, &closes, row_count);
bid1s.push(row.bid1); bid1s.push(row.bid1, &closes, row_count);
ask1s.push(row.ask1); ask1s.push(row.ask1, &closes, row_count);
volumes.push(row.volume); volumes.push(row.volume);
minute_volumes.push(row.minute_volume); minute_volumes.push(&row.minute_volume, row_count);
bid1_volumes.push(row.bid1_volume); bid1_volumes.push(&row.bid1_volume, row_count);
ask1_volumes.push(row.ask1_volume); ask1_volumes.push(&row.ask1_volume, row_count);
trading_phases.push(row.trading_phase.clone()); trading_phases.push(&row.trading_phase, row_count);
paused.push(row.paused); paused.push(row.paused);
upper_limits.push(row.upper_limit); upper_limits.push(row.upper_limit);
lower_limits.push(row.lower_limit); lower_limits.push(row.lower_limit);
price_ticks.push(row.price_tick); price_ticks.push(row.price_tick);
} }
let day_open_prefix = prefix_sums(&day_opens);
let open_prefix = prefix_sums(&opens); let open_prefix = prefix_sums(&opens);
let close_prefix = prefix_sums(&closes); let close_prefix = prefix_sums(&closes);
let prev_close_prefix = prefix_sums(&prev_closes); let prev_close_prefix = prefix_sums(&prev_closes);
let last_prefix = prefix_sums(&last_prices); let last_prefix = last_prices.prefix();
let mut valid_volume_sum_prefix = Vec::with_capacity(volumes.len() + 1); let mut valid_volume_sum_prefix = Vec::with_capacity(volumes.len() + 1);
let mut valid_volume_count_prefix = Vec::with_capacity(volumes.len() + 1); let mut valid_volume_count_prefix = Vec::with_capacity(volumes.len() + 1);
valid_volume_sum_prefix.push(0.0); valid_volume_sum_prefix.push(0.0);
@@ -926,6 +932,7 @@ impl SymbolPriceSeries {
upper_limits, upper_limits,
lower_limits, lower_limits,
price_ticks, price_ticks,
day_open_prefix,
open_prefix, open_prefix,
close_prefix, close_prefix,
prev_close_prefix, prev_close_prefix,
@@ -955,23 +962,23 @@ impl SymbolPriceSeries {
.dates .dates
.binary_search(&overlay.date) .binary_search(&overlay.date)
.map_err(|_| overlay.date)?; .map_err(|_| overlay.date)?;
self.timestamps[index] = overlay.timestamp.clone(); self.timestamps.set(index, overlay.timestamp.clone());
if let Some(last_price) = overlay if let Some(last_price) = overlay
.last_price .last_price
.filter(|value| value.is_finite() && *value > 0.0) .filter(|value| value.is_finite() && *value > 0.0)
{ {
self.last_prices[index] = last_price; self.last_prices.set(index, last_price, &self.base.closes);
last_price_changed = true; last_price_changed = true;
} }
self.bid1s[index] = overlay.bid1; self.bid1s.set(index, overlay.bid1, &self.base.closes);
self.ask1s[index] = overlay.ask1; self.ask1s.set(index, overlay.ask1, &self.base.closes);
self.minute_volumes[index] = overlay.minute_volume; self.minute_volumes.set(index, overlay.minute_volume);
self.bid1_volumes[index] = overlay.bid1_volume; self.bid1_volumes.set(index, overlay.bid1_volume);
self.ask1_volumes[index] = overlay.ask1_volume; self.ask1_volumes.set(index, overlay.ask1_volume);
self.trading_phases[index] = overlay.trading_phase.clone(); self.trading_phases.set(index, overlay.trading_phase.clone());
} }
if last_price_changed { if last_price_changed {
self.last_prefix = prefix_sums(&self.last_prices); self.last_prefix = self.last_prices.prefix();
} }
Ok(()) Ok(())
} }
@@ -1191,7 +1198,7 @@ impl SymbolPriceSeries {
PriceField::DayOpen => &self.day_opens, PriceField::DayOpen => &self.day_opens,
PriceField::Open => &self.opens, PriceField::Open => &self.opens,
PriceField::Close => &self.closes, PriceField::Close => &self.closes,
PriceField::Last => &self.last_prices, PriceField::Last => self.last_prices.values(&self.closes),
} }
} }
@@ -1205,10 +1212,10 @@ impl SymbolPriceSeries {
fn prefix_for(&self, field: PriceField) -> &[f64] { fn prefix_for(&self, field: PriceField) -> &[f64] {
match field { match field {
PriceField::DayOpen => &self.open_prefix, PriceField::DayOpen => &self.day_open_prefix,
PriceField::Open => &self.open_prefix, PriceField::Open => &self.open_prefix,
PriceField::Close => &self.close_prefix, PriceField::Close => &self.close_prefix,
PriceField::Last => &self.last_prefix, PriceField::Last => self.last_prefix.values(&self.close_prefix),
} }
} }
@@ -1222,9 +1229,9 @@ impl SymbolPriceSeries {
high: self.highs[index], high: self.highs[index],
low: self.lows[index], low: self.lows[index],
close: self.closes[index], close: self.closes[index],
last_price: self.last_prices[index], last_price: self.last_prices.values(&self.closes)[index],
bid1: self.bid1s[index], bid1: self.bid1s.values(&self.closes)[index],
ask1: self.ask1s[index], ask1: self.ask1s.values(&self.closes)[index],
prev_close: self.prev_closes[index], prev_close: self.prev_closes[index],
volume: self.volumes[index], volume: self.volumes[index],
minute_volume: self.minute_volumes[index], minute_volume: self.minute_volumes[index],
@@ -1245,12 +1252,12 @@ impl SymbolPriceSeries {
"high" => Some(self.highs[index]), "high" => Some(self.highs[index]),
"low" => Some(self.lows[index]), "low" => Some(self.lows[index]),
"close" | "price" => Some(self.closes[index]), "close" | "price" => Some(self.closes[index]),
"last" | "last_price" => Some(self.last_prices[index]), "last" | "last_price" => Some(self.last_prices.values(&self.closes)[index]),
"prev_close" | "pre_close" => Some(self.prev_closes[index]), "prev_close" | "pre_close" => Some(self.prev_closes[index]),
"volume" => Some(self.volumes[index] as f64), "volume" => Some(self.volumes[index] as f64),
"minute_volume" => Some(self.minute_volumes[index] as f64), "minute_volume" => Some(self.minute_volumes[index] as f64),
"bid1" => Some(self.bid1s[index]), "bid1" => Some(self.bid1s.values(&self.closes)[index]),
"ask1" => Some(self.ask1s[index]), "ask1" => Some(self.ask1s.values(&self.closes)[index]),
"bid1_volume" => Some(self.bid1_volumes[index] as f64), "bid1_volume" => Some(self.bid1_volumes[index] as f64),
"ask1_volume" => Some(self.ask1_volumes[index] as f64), "ask1_volume" => Some(self.ask1_volumes[index] as f64),
"upper_limit" => Some(self.upper_limits[index]), "upper_limit" => Some(self.upper_limits[index]),
@@ -6553,6 +6560,103 @@ mod tests {
} }
} }
#[test]
fn series_columns_preserve_full_snapshots_and_distinct_price_bits() {
for mixed in [false, true] {
let mut rows = (0..6).map(|index| {
let date = NaiveDate::from_ymd_opt(2025, 1, 2 + index).unwrap();
let mut row = market_row(&date.to_string(), 10. + index as f64, 1_000);
row.minute_volume = 7;
row.trading_phase = Some("continuous".to_string());
row
}).collect::<Vec<_>>();
if mixed {
rows[2].last_price = 0.;
rows[3].bid1 = -0.;
rows[4].ask1 = f64::from_bits(0x7ff8_0000_0000_0042);
rows[4].timestamp = Some("2025-01-06 10:21:00".to_string());
rows[4].trading_phase = None;
rows[4].minute_volume = 10_000;
}
let series = SymbolPriceSeries::new("000001.SZ".to_string(), &rows);
for (index, expected) in rows.iter().enumerate() {
let actual = series.snapshot_at(index);
assert_eq!(serde_json::to_value(&actual).unwrap(), serde_json::to_value(expected).unwrap());
assert_eq!(actual.last_price.to_bits(), expected.last_price.to_bits());
assert_eq!(actual.bid1.to_bits(), expected.bid1.to_bits());
assert_eq!(actual.ask1.to_bits(), expected.ask1.to_bits());
}
let expected_prefix = prefix_sums(&rows.iter().map(|row| row.last_price).collect::<Vec<_>>());
let bits = |values: &[f64]| values.iter().map(|value| value.to_bits()).collect::<Vec<_>>();
assert_eq!(bits(series.prefix_for(PriceField::Last)), bits(&expected_prefix));
if !mixed {
assert!(matches!(series.last_prices, ReferenceMatchedValues::Identical));
assert!(matches!(series.bid1s, ReferenceMatchedValues::Identical));
assert!(matches!(series.ask1s, ReferenceMatchedValues::Identical));
assert_eq!(series.price_values_for(PriceField::Last).as_ptr(), series.closes.as_ptr());
}
}
}
#[test]
fn series_overlay_materializes_only_changed_values_and_preserves_history_cutoff() {
let rows = [
market_row("2025-01-02", 10., 1_000),
market_row("2025-01-03", 12., 2_000),
market_row("2025-01-06", 14., 3_000),
];
let original = SymbolPriceSeries::new("000001.SZ".to_string(), &rows);
let mut changed = original.clone();
let overlay = IntradayMarketSnapshotOverlay {
date: rows[2].date, symbol: "000001.SZ".to_string(),
timestamp: Some("2025-01-06 13:20:00".to_string()), last_price: Some(15.),
bid1: 14., ask1: 15.01, minute_volume: 30, bid1_volume: 20, ask1_volume: 10,
trading_phase: Some("continuous".to_string()),
};
changed.apply_intraday_market_overlays(&[&overlay]).unwrap();
assert!(Arc::ptr_eq(&original.base, &changed.base));
assert!(matches!(original.last_prices, ReferenceMatchedValues::Identical));
assert!(matches!(changed.last_prices, ReferenceMatchedValues::Owned(_)));
assert!(matches!(changed.bid1s, ReferenceMatchedValues::Identical));
let mut expected = rows[2].clone();
expected.timestamp = overlay.timestamp.clone();
expected.last_price = 15.;
expected.bid1 = overlay.bid1;
expected.ask1 = overlay.ask1;
expected.minute_volume = overlay.minute_volume;
expected.bid1_volume = overlay.bid1_volume;
expected.ask1_volume = overlay.ask1_volume;
expected.trading_phase = overlay.trading_phase.clone();
assert_eq!(serde_json::to_value(changed.snapshot_at(2)).unwrap(), serde_json::to_value(expected).unwrap());
assert_eq!(original.snapshot_at(2).last_price, 14.);
assert_eq!(changed.moving_average(rows[1].date, 2, PriceField::Last), Some(11.));
assert_eq!(changed.trailing_values(rows[1].date, 2, PriceField::Last), vec![10., 12.]);
assert_eq!(changed.trailing_snapshots(rows[2].date, 2, false).len(), 2);
assert_eq!(changed.trailing_numeric_values(rows[2].date, 2, "last", false), vec![10., 12.]);
assert_eq!(changed.moving_average(rows[2].date, 2, PriceField::Last), Some(13.5));
let mut unknown = overlay;
unknown.date = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
assert_eq!(changed.apply_intraday_market_overlays(&[&unknown]), Err(unknown.date));
}
#[test]
fn day_open_moving_average_uses_its_own_historical_column() {
let mut first = market_row("2025-01-02", 10.0, 100);
first.day_open = 10.0;
first.open = 20.0;
let mut second = market_row("2025-01-03", 12.0, 200);
second.day_open = 12.0;
second.open = 24.0;
let rows = [first, second];
let series = SymbolPriceSeries::new("000001.SZ".to_string(), &rows);
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
assert_eq!(series.trailing_values(date, 2, PriceField::DayOpen), vec![10.0, 12.0]);
assert_eq!(series.moving_average(date, 2, PriceField::DayOpen), Some(11.0));
assert_eq!(series.moving_average(date, 2, PriceField::Open), Some(22.0));
assert_eq!(series.moving_average(date, 0, PriceField::DayOpen), None);
assert_eq!(series.moving_average(date, 3, PriceField::DayOpen), None);
}
#[test] #[test]
fn symbol_price_series_test_constructor_sorts_unsorted_rows() { fn symbol_price_series_test_constructor_sorts_unsorted_rows() {
let series = SymbolPriceSeries::new( let series = SymbolPriceSeries::new(
File diff suppressed because it is too large Load Diff
+3
View File
@@ -311,6 +311,7 @@ pub enum ProcessEventKind {
OrderUpdateReject, OrderUpdateReject,
OrderUnsolicitedUpdate, OrderUnsolicitedUpdate,
Trade, Trade,
ManualExecutionObserved,
UniverseUpdated, UniverseUpdated,
UniverseSubscribed, UniverseSubscribed,
UniverseUnsubscribed, UniverseUnsubscribed,
@@ -358,6 +359,7 @@ impl ProcessEventKind {
Self::OrderUpdateReject => "order_update_reject", Self::OrderUpdateReject => "order_update_reject",
Self::OrderUnsolicitedUpdate => "order_unsolicited_update", Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
Self::Trade => "trade", Self::Trade => "trade",
Self::ManualExecutionObserved => "manual_execution_observed",
Self::UniverseUpdated => "universe_updated", Self::UniverseUpdated => "universe_updated",
Self::UniverseSubscribed => "universe_subscribed", Self::UniverseSubscribed => "universe_subscribed",
Self::UniverseUnsubscribed => "universe_unsubscribed", Self::UniverseUnsubscribed => "universe_unsubscribed",
@@ -391,6 +393,7 @@ impl ProcessEventKind {
| Self::OrderUpdateReject | Self::OrderUpdateReject
| Self::OrderUnsolicitedUpdate | Self::OrderUnsolicitedUpdate
| Self::Trade | Self::Trade
| Self::ManualExecutionObserved
| Self::UniverseUpdated | Self::UniverseUpdated
| Self::UniverseSubscribed | Self::UniverseSubscribed
| Self::UniverseUnsubscribed | Self::UniverseUnsubscribed
@@ -0,0 +1,196 @@
//! Check typed pending intent numbers before JSON could replace NaN/Inf with null.
//! This traverses the original Serialize representation without materializing it.
use serde::{Serialize, Serializer, ser};
#[derive(Clone, Copy)]
struct Finite;
pub(crate) fn validate(value: &impl Serialize) -> Result<(), serde_json::Error> {
value.serialize(Finite)
}
macro_rules! scalar {
($($method:ident: $ty:ty),* $(,)?) => {$(
fn $method(self, _: $ty) -> Result<(), Self::Error> { Ok(()) }
)*};
}
impl Serializer for Finite {
type Ok = ();
type Error = serde_json::Error;
type SerializeSeq = Self;
type SerializeTuple = Self;
type SerializeTupleStruct = Self;
type SerializeTupleVariant = Self;
type SerializeMap = Self;
type SerializeStruct = Self;
type SerializeStructVariant = Self;
scalar!(serialize_bool: bool, serialize_i8: i8, serialize_i16: i16,
serialize_i32: i32, serialize_i64: i64, serialize_i128: i128,
serialize_u8: u8, serialize_u16: u16, serialize_u32: u32,
serialize_u64: u64, serialize_u128: u128, serialize_char: char,
serialize_str: &str, serialize_bytes: &[u8]);
fn serialize_f32(self, value: f32) -> Result<(), Self::Error> {
self.serialize_f64(f64::from(value))
}
fn serialize_f64(self, value: f64) -> Result<(), Self::Error> {
if value.is_finite() {
Ok(())
} else {
Err(ser::Error::custom(
"pending strategy intent contains a non-finite number",
))
}
}
fn serialize_none(self) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_unit(self) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_unit_struct(self, _: &'static str) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_unit_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_newtype_struct<T: ?Sized + Serialize>(
self,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_newtype_variant<T: ?Sized + Serialize>(
self,
_: &'static str,
_: u32,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_seq(self, _: Option<usize>) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple(self, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
_: usize,
) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_map(self, _: Option<usize>) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_struct_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
_: usize,
) -> Result<Self, Self::Error> {
Ok(self)
}
}
macro_rules! sequence {
($trait:ident, $method:ident) => {
impl ser::$trait for Finite {
type Ok = ();
type Error = serde_json::Error;
fn $method<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
};
}
sequence!(SerializeSeq, serialize_element);
sequence!(SerializeTuple, serialize_element);
sequence!(SerializeTupleStruct, serialize_field);
sequence!(SerializeTupleVariant, serialize_field);
impl ser::SerializeMap for Finite {
type Ok = ();
type Error = serde_json::Error;
fn serialize_key<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn serialize_value<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
macro_rules! structure {
($trait:ident) => {
impl ser::$trait for Finite {
type Ok = ();
type Error = serde_json::Error;
fn serialize_field<T: ?Sized + Serialize>(
&mut self,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
};
}
structure!(SerializeStruct);
structure!(SerializeStructVariant);
#[cfg(test)]
mod tests {
use super::*;
use crate::strategy::{OrderIntent, StrategyDecision};
#[test]
fn pending_numbers_cannot_be_silently_serialized_as_optional_nulls() {
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let decision = StrategyDecision {
order_intents: vec![
OrderIntent::LimitTargetPercent {
symbol: "000001.SZ".into(),
target_percent: 0.5,
limit_price: value,
reason: "test".into(),
}
.with_time_in_force(crate::strategy::OrderTimeInForce::Day),
],
..Default::default()
};
assert!(validate(&decision).is_err());
assert!(validate(&vec![Some(value)]).is_err());
}
assert!(validate(&(None::<f64>, vec![0., -0., 0.123456789], "NaN")).is_ok());
}
}
+11
View File
@@ -28,6 +28,17 @@ impl FixedMoney {
self.0 self.0
} }
pub fn to_decimal_string(self) -> String {
let magnitude = self.0.unsigned_abs();
let scale = MONEY_SCALE as u128;
let sign = if self.0 < 0 { "-" } else { "" };
let width = MONEY_SCALE.ilog10() as usize;
format!("{sign}{}.{:0width$}", magnitude / scale, magnitude % scale)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
pub fn from_decimal_str(value: &str) -> Result<Self, String> { pub fn from_decimal_str(value: &str) -> Result<Self, String> {
let value = value.trim(); let value = value.trim();
if value.is_empty() { if value.is_empty() {
+4 -4
View File
@@ -26,7 +26,7 @@ fn sum_futures_money(values: impl IntoIterator<Item = FixedMoney>, label: &str)
}) })
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum FuturesDirection { pub enum FuturesDirection {
Long, Long,
Short, Short,
@@ -62,7 +62,7 @@ impl FuturesDirection {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum FuturesPositionEffect { pub enum FuturesPositionEffect {
Open, Open,
Close, Close,
@@ -81,7 +81,7 @@ impl FuturesPositionEffect {
} }
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy, Serialize)]
pub struct FuturesContractSpec { pub struct FuturesContractSpec {
pub contract_multiplier: f64, pub contract_multiplier: f64,
pub long_margin_rate: f64, pub long_margin_rate: f64,
@@ -190,7 +190,7 @@ impl FuturesTransactionCostModel {
} }
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)]
pub struct FuturesOrderIntent { pub struct FuturesOrderIntent {
pub symbol: String, pub symbol: String,
pub direction: FuturesDirection, pub direction: FuturesDirection,
+1 -1
View File
@@ -63,7 +63,7 @@ pub struct HoldingLifecycleEvidence {
pub last_sell_date: Option<NaiveDate>, pub last_sell_date: Option<NaiveDate>,
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct AutomaticTradePermission { pub struct AutomaticTradePermission {
pub buy_denial: Option<&'static str>, pub buy_denial: Option<&'static str>,
pub sell_denial: Option<&'static str>, pub sell_denial: Option<&'static str>,
+3 -1
View File
@@ -17,9 +17,11 @@ pub mod engine;
pub mod event_bus; pub mod event_bus;
pub mod events; pub mod events;
pub mod fixed_point; pub mod fixed_point;
mod finite_serialization;
pub mod futures; pub mod futures;
pub mod instrument; pub mod instrument;
pub mod metrics; pub mod metrics;
pub mod manual_execution;
mod numeric_expr_vm; mod numeric_expr_vm;
pub mod platform_expr_strategy; pub mod platform_expr_strategy;
pub mod platform_runtime_schema; pub mod platform_runtime_schema;
@@ -61,7 +63,7 @@ pub use engine::{
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder, BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig, BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
ProcessEventRetention, backtest_execution_dates, ProcessEventRetention, backtest_execution_dates, backtest_execution_dates_with_rules,
}; };
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus}; pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
pub use events::{ pub use events::{
+657
View File
@@ -0,0 +1,657 @@
//! Confirmed manual fills are external observations, not simulated broker fills.
//! The producer must bind these records to the runtime's durable order/audit facts.
use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, FixedOffset, NaiveDate, Timelike, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::events::OrderSide;
use crate::{DataSet, FixedMoney, PortfolioState};
use rust_decimal::prelude::ToPrimitive;
pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v3";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionReplay {
pub schema: String,
pub runtime_id: String,
pub account_id: String,
pub source_contract_sha256: String,
pub content_sha256: String,
pub observation_cutoff: DateTime<Utc>,
pub actions: Vec<ManualExecutionAction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub legacy_position_exposure_bps: BTreeMap<NaiveDate, i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionAction {
pub action_id: String,
pub source: ManualExecutionSource,
pub audit_event_ids: Vec<String>,
pub confirmed_at: DateTime<Utc>,
pub confirmation_observed_at: DateTime<Utc>,
pub outcome: ManualActionOutcome,
pub orders: Vec<ManualExecutionOrder>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualActionOutcome {
NoOrdersNeeded,
NotExecuted,
OrdersTerminal,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualExecutionSource {
ManualSecurityTrade,
ManualPositionAction,
ManualRebalance,
StockPoolAllocation,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionOrder {
pub order_id: String,
pub broker_order_id: Option<String>,
pub source_adapter: Option<String>,
pub symbol: String,
pub side: OrderSide,
pub quantity: u32,
pub order_created_at: DateTime<Utc>,
pub terminal_observed_at: DateTime<Utc>,
pub terminal_status: ManualOrderTerminalStatus,
pub fills: Vec<ManualExecutionFill>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualOrderTerminalStatus {
Filled,
Cancelled,
Rejected,
Expired,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionFill {
pub trade_id: String,
pub observation_event_id: String,
pub observation_sequence: u64,
pub fee_observation_event_id: String,
pub fee_observation_sequence: u64,
pub fee_observed_at: DateTime<Utc>,
pub trade_date: NaiveDate,
pub executed_at: DateTime<Utc>,
pub observed_at: DateTime<Utc>,
pub timestamp_precision: ManualTimestampPrecision,
pub quantity: u32,
#[serde(with = "rust_decimal::serde::str")]
pub price: Decimal,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub commission: Option<Decimal>,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub stamp_tax: Option<Decimal>,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub transfer_fee: Option<Decimal>,
/// Full observed charge, including any venue fees not itemized above.
#[serde(with = "rust_decimal::serde::str")]
pub total_fee: Decimal,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualTimestampPrecision {
Second,
Millisecond,
Microsecond,
Nanosecond,
}
impl ManualTimestampPrecision {
fn nanoseconds(self) -> i64 {
match self {
Self::Second => 1_000_000_000,
Self::Millisecond => 1_000_000,
Self::Microsecond => 1_000,
Self::Nanosecond => 1,
}
}
}
impl ManualExecutionFill {
pub fn gross_amount(&self) -> Result<Decimal, String> {
self.price
.checked_mul(Decimal::from(self.quantity))
.ok_or_else(|| "manual fill gross amount overflow".into())
}
pub fn total_fees(&self) -> Result<Decimal, String> {
let known = [self.commission, self.stamp_tax, self.transfer_fee]
.into_iter()
.flatten()
.try_fold(Decimal::ZERO, |sum, fee| {
if fee < Decimal::ZERO {
return Err("manual fill fee component is negative");
}
sum.checked_add(fee).ok_or("manual fill fees overflow")
})?;
if self.total_fee < known {
return Err("manual total fee is below its known components".into());
}
Ok(self.total_fee)
}
}
fn identifier(value: &str) -> Result<(), String> {
if value.is_empty()
|| value.trim() != value
|| value.len() > 256
|| value.chars().any(char::is_control)
{
return Err("manual execution identity is empty, untrimmed or invalid".into());
}
Ok(())
}
impl ManualExecutionReplay {
/// Market/indicator data is needed for securities whose observed fills
/// change the portfolio. A rejected, never-filled order is not data demand.
pub fn required_data_symbols(&self) -> Result<BTreeSet<String>, String> {
self.validate()?;
Ok(self
.actions
.iter()
.flat_map(|action| &action.orders)
.filter(|order| !order.fills.is_empty())
.map(|order| order.symbol.clone())
.collect())
}
pub fn observations(&self) -> Result<Vec<ManualFillObservation<'_>>, String> {
self.validate()?;
let mut observations = Vec::new();
for action in &self.actions {
for order in &action.orders {
for fill in &order.fills {
observations.push(ManualFillObservation {
action,
order,
fill,
});
}
}
}
observations.sort_by_key(|entry| (entry.fill.observed_at, entry.fill.observation_sequence));
Ok(observations)
}
pub fn content_digest(&self) -> Result<String, String> {
let mut value = serde_json::to_value(self).map_err(|error| error.to_string())?;
value
.as_object_mut()
.ok_or("manual replay is not an object")?
.remove("contentSha256");
let bytes = serde_json::to_vec(&value).map_err(|error| error.to_string())?;
Ok(format!("{:x}", Sha256::digest(bytes)))
}
pub fn validate(&self) -> Result<(), String> {
if self.schema != MANUAL_REPLAY_SCHEMA
&& self.schema != "fidc.observed-manual-executions/v2"
{
return Err("unsupported manual replay schema".into());
}
if self.schema == "fidc.observed-manual-executions/v2"
&& (!self.position_exposure_events.is_empty()
|| !self.legacy_position_exposure_bps.is_empty())
{
return Err("runtime configuration requires manual replay v3".into());
}
crate::position_exposure::PositionExposureTimeline::from_events(
&self.position_exposure_events,
)?;
if self.position_exposure_events.iter().any(|event| event.effective_at > self.observation_cutoff) {
return Err("observed runtime position event is after the evidence cutoff".into());
}
if self
.legacy_position_exposure_bps
.values()
.any(|value| !(0..=10000).contains(value))
{
return Err("legacy manual exposure is outside 0..10000 bps".into());
}
identifier(&self.runtime_id)?;
identifier(&self.account_id)?;
if self.source_contract_sha256.len() != 64
|| !self
.source_contract_sha256
.bytes()
.all(|v| v.is_ascii_hexdigit())
{
return Err("manual replay source contract hash is invalid".into());
}
if self.content_digest()? != self.content_sha256 {
return Err("manual replay content digest mismatch".into());
}
if self.actions.len() > 100_000 {
return Err("manual replay action limit exceeded; trace was not truncated".into());
}
let shanghai = FixedOffset::east_opt(8 * 3600).unwrap();
let mut actions = BTreeSet::new();
let mut audits = BTreeSet::new();
let mut orders = BTreeSet::new();
let mut broker_orders = BTreeSet::new();
let mut trades = BTreeSet::new();
let mut observation_events = BTreeSet::new();
let mut observation_sequences = BTreeSet::new();
let mut fee_observations = BTreeSet::new();
let mut receipt_ids = BTreeMap::new();
let mut receipt_sequences = BTreeMap::new();
for action in &self.actions {
identifier(&action.action_id)?;
if !actions.insert(action.action_id.as_str())
|| action.confirmed_at > self.observation_cutoff
|| action.confirmation_observed_at < action.confirmed_at
|| action.confirmation_observed_at > self.observation_cutoff
{
return Err("duplicate manual action or confirmation after cutoff".into());
}
if action.audit_event_ids.is_empty() {
return Err("manual action has no immutable audit binding".into());
}
if (action.outcome != ManualActionOutcome::OrdersTerminal) != action.orders.is_empty() {
return Err("manual action outcome does not prove its order coverage".into());
}
for id in &action.audit_event_ids {
identifier(id)?;
if !audits.insert(id.as_str()) {
return Err("manual audit event is bound more than once".into());
}
}
for order in &action.orders {
identifier(&order.order_id)?;
if let Some(adapter) = &order.source_adapter {
identifier(adapter)?;
}
identifier(&order.symbol)?;
if let Some(id) = &order.broker_order_id {
identifier(id)?;
if !broker_orders.insert((
order
.source_adapter
.as_deref()
.ok_or("broker identity requires its source adapter")?,
order.order_created_at.with_timezone(&shanghai).date_naive(),
id.as_str(),
)) {
return Err("manual local orders share one broker order identity".into());
}
}
if !order.fills.is_empty() && order.source_adapter.is_none() {
return Err("manual fills require a known source adapter".into());
}
if !order.fills.is_empty()
&& order.source_adapter.as_deref() != Some("paper")
&& order.broker_order_id.is_none()
{
return Err(
"manual broker fills require their original broker order identity".into(),
);
}
if !orders.insert(order.order_id.as_str())
|| order.quantity == 0
|| order.quantity > i32::MAX as u32
{
return Err("duplicate manual order or invalid quantity".into());
}
if order.order_created_at < action.confirmed_at
|| order.terminal_observed_at < order.order_created_at
|| order.terminal_observed_at > self.observation_cutoff
{
return Err(
"manual order confirmation/submission/terminal time is inconsistent".into(),
);
}
let mut filled = 0_u32;
for fill in &order.fills {
identifier(&fill.trade_id)?;
identifier(&fill.observation_event_id)?;
identifier(&fill.fee_observation_event_id)?;
if fill.observation_sequence == 0
|| fill.observation_sequence > i64::MAX as u64
|| !observation_events.insert(fill.observation_event_id.as_str())
|| !observation_sequences.insert(fill.observation_sequence)
{
return Err(
"manual fill requires a unique durable observation event and sequence"
.into(),
);
}
if fill.fee_observation_sequence == 0
|| fill.fee_observation_sequence > i64::MAX as u64
|| fill.fee_observed_at < fill.observed_at
|| fill.fee_observed_at > self.observation_cutoff
|| !fee_observations.insert((
fill.fee_observation_event_id.as_str(),
fill.fee_observation_sequence,
))
{
return Err("manual finalized fees require their own unique observation within the cutoff".into());
}
if (fill.fee_observation_event_id == fill.observation_event_id)
!= (fill.fee_observation_sequence == fill.observation_sequence)
|| (fill.fee_observation_event_id == fill.observation_event_id
&& fill.fee_observed_at != fill.observed_at)
{
return Err("manual fill and fee observation identities disagree".into());
}
if !trades.insert((fill.trade_date, fill.trade_id.as_str()))
|| fill.quantity == 0
{
return Err("duplicate manual trade or zero fill quantity".into());
}
for (id, sequence) in [
(&fill.observation_event_id, fill.observation_sequence),
(
&fill.fee_observation_event_id,
fill.fee_observation_sequence,
),
] {
if receipt_ids
.insert(id, (&fill.trade_id, sequence))
.is_some_and(|owner| owner != (&fill.trade_id, sequence))
|| receipt_sequences
.insert(sequence, (&fill.trade_id, id))
.is_some_and(|owner| owner != (&fill.trade_id, id))
{
return Err("manual observation identity is reused by a different trade or sequence".into());
}
}
if fill.executed_at.with_timezone(&shanghai).date_naive() != fill.trade_date
|| fill.observed_at > self.observation_cutoff
|| fill.observed_at < order.order_created_at
|| fill.observed_at < action.confirmation_observed_at
|| fill.observed_at < fill.executed_at
|| fill.executed_at > order.terminal_observed_at
{
return Err("manual fill execution/observation time is inconsistent".into());
}
if i64::from(fill.executed_at.nanosecond())
% fill.timestamp_precision.nanoseconds()
!= 0
{
return Err(
"broker timestamp contains digits finer than its declared precision"
.into(),
);
}
let upper = fill
.executed_at
.checked_add_signed(chrono::Duration::nanoseconds(
fill.timestamp_precision.nanoseconds(),
))
.ok_or("manual execution timestamp overflow")?;
let earliest = order.order_created_at.max(action.confirmation_observed_at);
if fill.executed_at < earliest && earliest >= upper {
return Err("manual fill predates its order or durable confirmation".into());
}
if fill.price <= Decimal::ZERO {
return Err("manual fill requires a positive price".into());
}
fill.gross_amount()?
.checked_add(fill.total_fees()?)
.ok_or("manual fill cash amount overflow")?;
filled = filled
.checked_add(fill.quantity)
.ok_or("manual cumulative fill quantity overflow")?;
}
if filled > order.quantity
|| (order.terminal_status == ManualOrderTerminalStatus::Filled
&& filled != order.quantity)
|| (order.terminal_status == ManualOrderTerminalStatus::Rejected && filled != 0)
|| (matches!(
order.terminal_status,
ManualOrderTerminalStatus::Cancelled | ManualOrderTerminalStatus::Expired
) && filled == order.quantity)
{
return Err("manual terminal status disagrees with cumulative fills".into());
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
pub struct ManualFillObservation<'a> {
pub action: &'a ManualExecutionAction,
pub order: &'a ManualExecutionOrder,
pub fill: &'a ManualExecutionFill,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AppliedManualFill {
pub gross: FixedMoney,
pub fees: FixedMoney,
pub cash_delta: FixedMoney,
pub quantity_after: u32,
}
/// One replay owns its immutable trace and progress. Advancing is atomic even
/// if a later receipt in the same step disagrees with the shadow account.
pub struct ManualReplayCursor {
replay: std::sync::Arc<ManualExecutionReplay>,
indices: Vec<(usize, usize, usize)>,
cursor: usize,
clock: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ManualReplayApplication {
pub action_id: String,
pub order_id: String,
pub trade_id: String,
pub observation_event_id: String,
pub observation_sequence: u64,
pub observed_at: DateTime<Utc>,
pub fee_observation_event_id: String,
pub fee_observed_at: DateTime<Utc>,
pub executed_at: DateTime<Utc>,
pub symbol: String,
pub side: OrderSide,
pub quantity: u32,
pub quantity_after: u32,
pub price: String,
pub commission: Option<String>,
pub stamp_tax: Option<String>,
pub transfer_fee: Option<String>,
pub source_total_fee: String,
pub source_gross_amount: String,
pub ledger_gross_amount: String,
pub ledger_fees: String,
pub cash_delta: String,
}
impl ManualReplayCursor {
pub fn new(replay: ManualExecutionReplay) -> Result<Self, String> {
Self::from_shared(std::sync::Arc::new(replay))
}
pub fn from_shared(replay: std::sync::Arc<ManualExecutionReplay>) -> Result<Self, String> {
replay.validate()?;
let mut indices = Vec::new();
for (a, action) in replay.actions.iter().enumerate() {
for (o, order) in action.orders.iter().enumerate() {
for f in 0..order.fills.len() {
indices.push((a, o, f));
}
}
}
indices.sort_by_key(|&(a, o, f)| {
let fill = &replay.actions[a].orders[o].fills[f];
(fill.observed_at, fill.observation_sequence)
});
Ok(Self {
replay,
indices,
cursor: 0,
clock: None,
})
}
pub fn next_observation_at(&self) -> Option<DateTime<Utc>> {
self.indices
.get(self.cursor)
.map(|&(a, o, f)| self.replay.actions[a].orders[o].fills[f].observed_at)
}
pub fn applied_count(&self) -> usize {
self.cursor
}
pub fn advance(
&mut self,
at: DateTime<Utc>,
portfolio: &mut PortfolioState,
data: &DataSet,
has_pending_orders: bool,
) -> Result<Vec<ManualReplayApplication>, String> {
let end = self.cursor
+ self.indices[self.cursor..].iter().take_while(|&&(a, o, f)| {
self.replay.actions[a].orders[o].fills[f].observed_at <= at
}).count();
self.advance_through(at, end, portfolio, data, has_pending_orders)
}
/// One receipt at a time lets callbacks observe the intermediate state
/// when multiple fills share a timestamp but have distinct durable sequences.
pub fn advance_next(
&mut self, portfolio: &mut PortfolioState, data: &DataSet, has_pending_orders: bool,
) -> Result<Option<ManualReplayApplication>, String> {
let Some(at) = self.next_observation_at() else { return Ok(None); };
let mut applications = self.advance_through(at, self.cursor + 1, portfolio, data, has_pending_orders)?;
Ok(applications.pop())
}
fn advance_through(
&mut self, at: DateTime<Utc>, end: usize, portfolio: &mut PortfolioState,
data: &DataSet, has_pending_orders: bool,
) -> Result<Vec<ManualReplayApplication>, String> {
if at > self.replay.observation_cutoff {
return Err("manual observation clock exceeds the frozen evidence cutoff".into());
}
if self.clock.is_some_and(|clock| at < clock) {
return Err("manual observation clock moved backwards".into());
}
if end == self.cursor {
self.clock = Some(at);
return Ok(vec![]);
}
let mut next = portfolio.clone();
let mut applications = Vec::with_capacity(end - self.cursor);
for &(a, o, f) in &self.indices[self.cursor..end] {
let action = &self.replay.actions[a];
let order = &action.orders[o];
let fill = &order.fills[f];
let applied = ManualFillObservation {
action,
order,
fill,
}
.apply(&mut next, data, has_pending_orders)?;
applications.push(ManualReplayApplication {
action_id: action.action_id.clone(),
order_id: order.order_id.clone(),
trade_id: fill.trade_id.clone(),
observation_event_id: fill.observation_event_id.clone(),
observation_sequence: fill.observation_sequence,
observed_at: fill.observed_at,
fee_observation_event_id: fill.fee_observation_event_id.clone(),
fee_observed_at: fill.fee_observed_at,
executed_at: fill.executed_at,
symbol: order.symbol.clone(),
side: order.side,
quantity: fill.quantity,
quantity_after: applied.quantity_after,
price: fill.price.to_string(),
commission: fill.commission.map(|fee| fee.to_string()),
stamp_tax: fill.stamp_tax.map(|fee| fee.to_string()),
transfer_fee: fill.transfer_fee.map(|fee| fee.to_string()),
source_total_fee: fill.total_fee.to_string(),
source_gross_amount: fill.gross_amount()?.to_string(),
ledger_gross_amount: applied.gross.to_decimal_string(),
ledger_fees: applied.fees.to_decimal_string(),
cash_delta: applied.cash_delta.to_decimal_string(),
});
}
*portfolio = next;
self.cursor = end;
self.clock = Some(at);
Ok(applications)
}
}
impl ManualFillObservation<'_> {
pub(crate) fn apply(
&self,
portfolio: &mut PortfolioState,
data: &DataSet,
has_pending_orders: bool,
) -> Result<AppliedManualFill, String> {
if has_pending_orders {
return Err("manual observation conflicts with pending shadow orders".into());
}
let instrument = data
.instrument(&self.order.symbol)
.ok_or("manual observation instrument is absent from frozen source data")?;
if instrument
.dated_market_absence_reason(self.fill.trade_date)
.is_some()
{
return Err("manual execution contradicts the frozen instrument lifecycle".into());
}
let gross = FixedMoney::from_decimal_str(&self.fill.gross_amount()?.to_string())?;
let fees = FixedMoney::from_decimal_str(&self.fill.total_fees()?.to_string())?;
let price = self
.fill
.price
.to_f64()
.filter(|price| price.is_finite() && *price > 0.)
.ok_or("manual execution price cannot be represented for valuation")?;
// This is the real observed trade price, not a fabricated quote. The
// normal market clock remains responsible for subsequent marks.
let cash_delta = portfolio.apply_observed_manual_fill(
self.fill.trade_date,
&self.order.symbol,
self.order.side,
self.fill.quantity,
price,
price,
gross,
fees,
)?;
Ok(AppliedManualFill {
gross,
fees,
cash_delta,
quantity_after: portfolio
.position(&self.order.symbol)
.map_or(0, |position| position.quantity),
})
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,552 @@
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 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('-'));
}
+18 -1
View File
@@ -93,6 +93,15 @@ pub fn compute_backtest_metrics(
account_events: &[AccountEvent], account_events: &[AccountEvent],
initial_cash: f64, initial_cash: f64,
risk_free_contract: Option<&RiskFreeRateContract>, risk_free_contract: Option<&RiskFreeRateContract>,
) -> Result<BacktestMetrics, String> {
compute_backtest_metrics_with_manual(equity_curve, fills, &[], daily_holdings, account_events, initial_cash, risk_free_contract)
}
pub fn compute_backtest_metrics_with_manual(
equity_curve: &[DailyEquityPoint], fills: &[FillEvent],
manual_executions: &[crate::manual_execution::ManualReplayApplication],
daily_holdings: &[HoldingSummary], account_events: &[AccountEvent], initial_cash: f64,
risk_free_contract: Option<&RiskFreeRateContract>,
) -> Result<BacktestMetrics, String> { ) -> Result<BacktestMetrics, String> {
let Some(first_point) = equity_curve.first() else { let Some(first_point) = equity_curve.first() else {
return Ok(BacktestMetrics { return Ok(BacktestMetrics {
@@ -229,12 +238,20 @@ pub fn compute_backtest_metrics(
); );
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR); let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
let turnover_by_date = fills let mut turnover_by_date = fills
.iter() .iter()
.fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| { .fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
*acc.entry(fill.date).or_default() += fill.gross_amount.abs(); *acc.entry(fill.date).or_default() += fill.gross_amount.abs();
acc acc
}); });
for execution in manual_executions {
use rust_decimal::prelude::ToPrimitive;
let gross = execution.ledger_gross_amount.parse::<rust_decimal::Decimal>()
.ok().and_then(|value| value.to_f64()).filter(|value| value.is_finite() && *value >= 0.)
.ok_or("manual turnover requires its validated ledger gross amount")?;
let date = execution.observed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
*turnover_by_date.entry(date).or_default() += gross;
}
let equity_by_date = equity_curve let equity_by_date = equity_curve
.iter() .iter()
.map(|point| (point.date, point.total_equity)) .map(|point| (point.date, point.total_equity))
+83 -7
View File
@@ -653,6 +653,8 @@ pub struct PlatformExprStrategyConfig {
pub exposure_expr: String, pub exposure_expr: String,
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>, pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
pub position_exposure_timeline: crate::position_exposure::PositionExposureTimeline, pub position_exposure_timeline: crate::position_exposure::PositionExposureTimeline,
pub runtime_position_exposure_timeline: crate::position_exposure::PositionExposureTimeline,
pub runtime_position_exposure_schedule: BTreeMap<NaiveDate, f64>,
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>, pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
pub portfolio_loss_control: Option<PortfolioLossConfig>, pub portfolio_loss_control: Option<PortfolioLossConfig>,
pub stop_loss_expr: String, pub stop_loss_expr: String,
@@ -742,7 +744,11 @@ impl PlatformExprStrategyConfig {
buy_scale_expr: "1.0".to_string(), buy_scale_expr: "1.0".to_string(),
exposure_expr: "1.0".to_string(), exposure_expr: "1.0".to_string(),
position_exposure_schedule: BTreeMap::new(), position_exposure_schedule: BTreeMap::new(),
position_exposure_timeline: crate::position_exposure::PositionExposureTimeline::default(), position_exposure_timeline: crate::position_exposure::PositionExposureTimeline::default(
),
runtime_position_exposure_timeline:
crate::position_exposure::PositionExposureTimeline::default(),
runtime_position_exposure_schedule: BTreeMap::new(),
portfolio_drawdown_control: None, portfolio_drawdown_control: None,
portfolio_loss_control: None, portfolio_loss_control: None,
stop_loss_expr: String::new(), stop_loss_expr: String::new(),
@@ -8656,13 +8662,28 @@ impl PlatformExprStrategy {
let strategy_exposure = self let strategy_exposure = self
.eval_float(ctx, &self.config.exposure_expr, day, None, None)? .eval_float(ctx, &self.config.exposure_expr, day, None, None)?
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
let risk_on_exposure = self.config.position_exposure_timeline.exposure_at( let risk_on_exposure = self
portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule, .config
.position_exposure_timeline
.exposure_at(
portfolio_loss_decision_at(ctx),
ctx.execution_date,
&self.config.position_exposure_schedule,
strategy_exposure, strategy_exposure,
) )
.unwrap_or(strategy_exposure) .unwrap_or(strategy_exposure)
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
let mut exposure = risk_on_exposure; let mut exposure = self
.config
.runtime_position_exposure_timeline
.exposure_at(
portfolio_loss_decision_at(ctx),
ctx.execution_date,
&self.config.runtime_position_exposure_schedule,
risk_on_exposure,
)
.unwrap_or(risk_on_exposure)
.clamp(0., 1.);
if let Some(controller) = self.portfolio_drawdown_controller.as_mut() { if let Some(controller) = self.portfolio_drawdown_controller.as_mut() {
exposure = controller.update(ctx.decision_date, day.total_value, exposure)?.target_exposure; exposure = controller.update(ctx.decision_date, day.total_value, exposure)?.target_exposure;
} }
@@ -9986,10 +10007,28 @@ impl PlatformExprStrategy {
} }
} }
} }
if let Some(bps)=self.config.position_exposure_timeline.scale_at(portfolio_loss_decision_at(ctx)) { for bps in [
self.config
.position_exposure_timeline
.scale_at(portfolio_loss_decision_at(ctx)),
self.config
.runtime_position_exposure_timeline
.scale_at(portfolio_loss_decision_at(ctx)),
]
.into_iter()
.flatten()
{
let before = intents.len(); let before = intents.len();
intents=intents.into_iter().map(|intent|crate::position_exposure::scale_explicit_intent(intent,bps,ctx.open_orders)) intents = intents
.collect::<Result<Vec<_>,_>>().map_err(BacktestError::Execution)?.into_iter().flatten().collect(); .into_iter()
.map(|intent| {
crate::position_exposure::scale_explicit_intent(intent, bps, ctx.open_orders)
})
.collect::<Result<Vec<_>, _>>()
.map_err(BacktestError::Execution)?
.into_iter()
.flatten()
.collect();
diagnostics.push(format!("position_override_scale requested_bps={bps} original_intents={before} emitted_intents={}",intents.len())); diagnostics.push(format!("position_override_scale requested_bps={bps} original_intents={before} emitted_intents={}",intents.len()));
} }
Ok((intents, diagnostics)) Ok((intents, diagnostics))
@@ -12396,6 +12435,43 @@ impl PlatformExprStrategy {
} }
impl Strategy for PlatformExprStrategy { impl Strategy for PlatformExprStrategy {
fn bind_runtime_position_configuration(
&mut self,
events: &[crate::position_exposure::PositionExposureEvent],
legacy: &BTreeMap<NaiveDate, i32>,
) -> Result<(), BacktestError> {
let timeline = crate::position_exposure::PositionExposureTimeline::from_events(events)
.map_err(BacktestError::Execution)?;
if legacy.values().any(|value| !(0..=10000).contains(value)) {
return Err(BacktestError::Execution(
"invalid runtime exposure schedule".into(),
));
}
self.config.runtime_position_exposure_timeline = timeline;
self.config.runtime_position_exposure_schedule = legacy
.iter()
.map(|(date, bps)| (*date, f64::from(*bps) / 10000.))
.collect();
Ok(())
}
fn on_observed_manual_execution(
&mut self,
execution: &crate::manual_execution::ManualReplayApplication,
) -> Result<(), BacktestError> {
let date = execution
.executed_at
.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap())
.date_naive();
let history = match execution.side {
OrderSide::Buy => &mut self.protection_last_buys,
OrderSide::Sell => &mut self.protection_last_sells,
};
history
.entry(execution.symbol.clone())
.and_modify(|previous| *previous = (*previous).max(date))
.or_insert(date);
Ok(())
}
fn name(&self) -> &str { fn name(&self) -> &str {
self.config.strategy_name.as_str() self.config.strategy_name.as_str()
} }
+32 -3
View File
@@ -182,6 +182,15 @@ impl PlatformExprStrategy {
scope.push(symbol) scope.push(symbol)
} }
} }
let allocation_weights = self
.config
.runtime_position_exposure_timeline
.allocation_weights_at(portfolio_loss_decision_at(ctx))
.or_else(|| {
self.config
.position_exposure_timeline
.allocation_weights_at(portfolio_loss_decision_at(ctx))
});
let members = scope let members = scope
.iter() .iter()
.enumerate() .enumerate()
@@ -198,15 +207,35 @@ impl PlatformExprStrategy {
take_profit: constraints.default_take_profit, take_profit: constraints.default_take_profit,
}); });
member.requested_order = index as i32; member.requested_order = index as i32;
if let Some(weights) = allocation_weights {
member.target_weight_bps = Some(*weights.get(symbol).unwrap_or(&0));
}
member member
}) })
.collect(); .collect();
let (base_ratio, reserve_cash) = let (base_ratio, reserve_cash) =
pool::stock_pool_funding_from_configuration(&program.allocation_policy) pool::stock_pool_funding_from_configuration(&program.allocation_policy)
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
let ratio = self.config.position_exposure_timeline let base_exposure = self
.exposure_at(portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule, .config
f64::from(base_ratio)/10000.) .position_exposure_timeline
.exposure_at(
portfolio_loss_decision_at(ctx),
ctx.execution_date,
&self.config.position_exposure_schedule,
f64::from(base_ratio) / 10000.,
)
.unwrap_or(f64::from(base_ratio) / 10000.);
let ratio = self
.config
.runtime_position_exposure_timeline
.exposure_at(
portfolio_loss_decision_at(ctx),
ctx.execution_date,
&self.config.runtime_position_exposure_schedule,
base_exposure,
)
.or(Some(base_exposure))
.map(|value| (value * 10000.).round() as i64) .map(|value| (value * 10000.).round() as i64)
.unwrap_or(i64::from(base_ratio)); .unwrap_or(i64::from(base_ratio));
let invest_ratio_bps = i32::try_from(ratio) let invest_ratio_bps = i32::try_from(ratio)
+129 -9
View File
@@ -138,18 +138,28 @@ impl Position {
if quantity == 0 { if quantity == 0 {
return; return;
} }
let gross_amount = fixed_money_or_panic(execution_price * quantity as f64, "position buy gross amount");
self.buy_with_fixed_gross(date,quantity,execution_price,mark_price,gross_amount);
}
fn buy_with_fixed_gross(
&mut self,
date: NaiveDate,
quantity: u32,
execution_price: f64,
mark_price: f64,
gross_amount: FixedMoney,
) {
let previous_quantity = self.quantity; let previous_quantity = self.quantity;
self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date))); self.last_buy_date = Some(
self.last_buy_date
.map_or(date, |previous| previous.max(date)),
);
if previous_quantity == 0 { if previous_quantity == 0 {
self.opened_date = Some(date); self.opened_date = Some(date);
} }
let previous_average_price = self.average_price; let previous_average_price = self.average_price;
let previous_average_cost = self.average_cost; let previous_average_cost = self.average_cost;
let gross_amount = fixed_money_or_panic(
execution_price * quantity as f64,
"position buy gross amount",
);
self.lots.push(PositionLot { self.lots.push(PositionLot {
acquired_date: date, acquired_date: date,
quantity, quantity,
@@ -200,6 +210,20 @@ impl Position {
quantity: u32, quantity: u32,
execution_price: f64, execution_price: f64,
mark_price: f64, mark_price: f64,
) -> Result<f64, String> {
if quantity > self.quantity {
return Err(format!("sell quantity {} exceeds current quantity {} for {}",quantity,self.quantity,self.symbol));
}
let total_proceeds = fixed_money(execution_price * quantity as f64,"position sell gross amount")?;
self.sell_with_fixed_gross(quantity,execution_price,mark_price,total_proceeds)
}
fn sell_with_fixed_gross(
&mut self,
quantity: u32,
execution_price: f64,
mark_price: f64,
total_proceeds: FixedMoney,
) -> Result<f64, String> { ) -> Result<f64, String> {
if quantity > self.quantity { if quantity > self.quantity {
return Err(format!( return Err(format!(
@@ -208,10 +232,6 @@ impl Position {
)); ));
} }
let total_proceeds = fixed_money(
execution_price * quantity as f64,
"position sell gross amount",
)?;
let mut remaining = quantity; let mut remaining = quantity;
let mut remaining_proceeds = total_proceeds; let mut remaining_proceeds = total_proceeds;
let mut realized = FixedMoney::ZERO; let mut realized = FixedMoney::ZERO;
@@ -796,6 +816,106 @@ impl PortfolioState {
Ok(()) Ok(())
} }
/// Apply one fully observed external fill atomically. Its money is already
/// quantized from the original decimal amounts, not from a float product.
pub(crate) fn apply_observed_manual_fill(
&mut self,
trade_date: NaiveDate,
symbol: &str,
side: crate::events::OrderSide,
quantity: u32,
price: f64,
mark_price: f64,
gross: FixedMoney,
fees: FixedMoney,
) -> Result<FixedMoney, String> {
use crate::events::OrderSide;
if symbol.trim().is_empty()
|| quantity == 0
|| quantity > i32::MAX as u32
|| !price.is_finite()
|| price <= 0.
|| !mark_price.is_finite()
|| mark_price <= 0.
|| gross <= FixedMoney::ZERO
|| fees < FixedMoney::ZERO
{
return Err("invalid observed manual fill".into());
}
let mut position = self
.positions
.get(symbol)
.cloned()
.unwrap_or_else(|| Position::new(symbol));
let delta = match side {
OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg),
OrderSide::Sell => gross.checked_sub(fees),
}
.ok_or("manual fill cash delta overflow")?;
let next_cash = self
.cash
.checked_add(delta)
.filter(|cash| *cash >= FixedMoney::ZERO)
.ok_or("manual fill disagrees with shadow available cash")?;
let next_cost = position
.day_trade_cost
.checked_add(fees)
.ok_or("manual trade cost overflow")?;
match side {
OrderSide::Buy => {
let total_quantity = position
.quantity
.checked_add(quantity)
.ok_or("manual position quantity overflow")?;
FixedMoney::from_f64(mark_price * f64::from(total_quantity))
.ok_or("manual marked position value overflow")?;
position
.day_buy_quantity
.checked_add(quantity)
.ok_or("manual daily buy quantity overflow")?;
position
.day_trade_quantity_delta
.checked_add(quantity as i32)
.ok_or("manual daily quantity delta overflow")?;
position
.day_buy_value
.checked_add(gross)
.ok_or("manual daily buy value overflow")?;
let total_basis = gross.checked_add(fees).ok_or("manual lot basis overflow")?;
position
.total_cost_basis()
.checked_add(total_basis)
.ok_or("manual aggregate position basis overflow")?;
position.buy_with_fixed_gross(trade_date, quantity, price, mark_price, gross);
position
.lots
.last_mut()
.ok_or("manual buy produced no lot")?
.cost_basis = total_basis;
position.average_cost += fees.to_f64() / f64::from(position.quantity);
}
OrderSide::Sell => {
if quantity > position.sellable_qty(trade_date) {
return Err("manual fill disagrees with shadow sellable holdings or T+1".into());
}
position
.day_sell_quantity
.checked_add(quantity)
.ok_or("manual daily sell quantity overflow")?;
position
.day_trade_quantity_delta
.checked_sub(quantity as i32)
.ok_or("manual daily quantity delta overflow")?;
position.sell_with_fixed_gross(quantity, price, mark_price, gross)?;
}
}
position.day_trade_cost = next_cost;
position.refresh_day_pnl();
self.positions.insert(symbol.to_string(), position);
self.cash = next_cash;
Ok(delta)
}
pub fn prune_flat_positions(&mut self) { pub fn prune_flat_positions(&mut self) {
let mut sold_symbols = Vec::new(); let mut sold_symbols = Vec::new();
self.positions.retain(|symbol, position| { self.positions.retain(|symbol, position| {
+114 -6
View File
@@ -25,13 +25,19 @@ pub struct PositionExposureEvent {
pub sequence: u64, pub sequence: u64,
#[serde(alias = "effective_at")] #[serde(alias = "effective_at")]
pub effective_at: DateTime<Utc>, pub effective_at: DateTime<Utc>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "allocation_weights_bps"
)]
pub allocation_weights_bps: Option<BTreeMap<String, i32>>,
#[serde(flatten)] #[serde(flatten)]
pub action: PositionExposureAction, pub action: PositionExposureAction,
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct PositionExposureTimeline { pub struct PositionExposureTimeline {
events: BTreeMap<(DateTime<Utc>, u64), PositionExposureAction>, events: BTreeMap<(DateTime<Utc>, u64), (PositionExposureAction, Option<BTreeMap<String, i32>>)>,
} }
impl PositionExposureTimeline { impl PositionExposureTimeline {
@@ -58,9 +64,24 @@ impl PositionExposureTimeline {
{ {
return Err("position exposure target must be between 0 and 10000 bps".into()); return Err("position exposure target must be between 0 and 10000 bps".into());
} }
result if let Some(weights) = &event.allocation_weights_bps {
.events let target = match event.action {
.insert((event.effective_at, event.sequence), event.action.clone()); PositionExposureAction::Set {
target_exposure_bps,
} => target_exposure_bps,
PositionExposureAction::Scale { requested_bps } => requested_bps,
PositionExposureAction::Restore => {
return Err(
"restoring strategy allocation cannot carry manual weights".into()
);
}
};
validate_allocation_weights(weights, target)?;
}
result.events.insert(
(event.effective_at, event.sequence),
(event.action.clone(), event.allocation_weights_bps.clone()),
);
} }
Ok(result) Ok(result)
} }
@@ -77,7 +98,7 @@ impl PositionExposureTimeline {
.events .events
.range(..=(at, u64::MAX)) .range(..=(at, u64::MAX))
.next_back() .next_back()
.map(|(_, action)| action) .map(|(_, (action, _))| action)
{ {
Some(PositionExposureAction::Scale { requested_bps }) => { Some(PositionExposureAction::Scale { requested_bps }) => {
Some(strategy_exposure * f64::from(*requested_bps) / 10000.) Some(strategy_exposure * f64::from(*requested_bps) / 10000.)
@@ -98,12 +119,47 @@ impl PositionExposureTimeline {
.events .events
.range(..=(at, u64::MAX)) .range(..=(at, u64::MAX))
.next_back() .next_back()
.map(|(_, action)| action) .map(|(_, (action, _))| action)
{ {
Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps), Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps),
_ => None, _ => None,
} }
} }
pub fn allocation_weights_at(&self, at: DateTime<Utc>) -> Option<&BTreeMap<String, i32>> {
self.events
.range(..=(at, u64::MAX))
.next_back()
.and_then(|(_, (_, weights))| weights.as_ref())
}
}
pub fn validate_allocation_weights(
weights: &BTreeMap<String, i32>,
exposure_bps: i32,
) -> Result<(), String> {
if !(0..=10000).contains(&exposure_bps) || weights.len() > 10000 {
return Err("invalid allocation exposure or weight count".into());
}
for (symbol, weight) in weights {
if !(0..=10000).contains(weight)
|| !symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
code.len() == 6
&& code.bytes().all(|byte| byte.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "BJ")
})
{
return Err(
"allocation weights require canonical stock/ETF symbols and 0..10000 bps".into(),
);
}
}
if (weights.is_empty() && exposure_bps != 0)
|| (!weights.is_empty() && weights.values().sum::<i32>() != 10000)
{
return Err("manual allocation weights must total 10000 bps; only a zero exposure may have no weights".into());
}
Ok(())
} }
/// Scale new buys and desired targets without weakening sell/reduction or /// Scale new buys and desired targets without weakening sell/reduction or
@@ -241,6 +297,7 @@ mod tests {
event_id: "scale".into(), event_id: "scale".into(),
sequence: 1, sequence: 1,
effective_at: at, effective_at: at,
allocation_weights_bps: None,
action: PositionExposureAction::Scale { action: PositionExposureAction::Scale {
requested_bps: 5000, requested_bps: 5000,
}, },
@@ -258,6 +315,7 @@ mod tests {
event_id: "restore".into(), event_id: "restore".into(),
sequence: 2, sequence: 2,
effective_at: at, effective_at: at,
allocation_weights_bps: None,
action: PositionExposureAction::Restore, action: PositionExposureAction::Restore,
}; };
let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap(); let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap();
@@ -274,6 +332,56 @@ mod tests {
); );
} }
#[test]
fn allocation_is_dated_and_any_later_scalar_or_restore_clears_it() {
let at = DateTime::parse_from_rfc3339("2026-09-14T10:00:00+08:00")
.unwrap()
.with_timezone(&Utc);
let weights = BTreeMap::from([("000001.SZ".into(), 3000), ("510300.SH".into(), 7000)]);
let event = PositionExposureEvent {
event_id: "allocation".into(),
sequence: 1,
effective_at: at,
action: PositionExposureAction::Set {
target_exposure_bps: 8000,
},
allocation_weights_bps: Some(weights.clone()),
};
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
assert!(
timeline
.allocation_weights_at(at - chrono::Duration::seconds(1))
.is_none()
);
assert_eq!(timeline.allocation_weights_at(at), Some(&weights));
for action in [
PositionExposureAction::Set {
target_exposure_bps: 5000,
},
PositionExposureAction::Restore,
] {
let next = PositionExposureEvent {
event_id: "new".into(),
sequence: 2,
effective_at: at + chrono::Duration::seconds(1),
action,
allocation_weights_bps: None,
};
assert!(
PositionExposureTimeline::from_events(&[event.clone(), next])
.unwrap()
.allocation_weights_at(at + chrono::Duration::seconds(1))
.is_none()
);
}
assert!(
validate_allocation_weights(&BTreeMap::from([("000001.SZ".into(), 9000)]), 5000)
.is_err()
);
assert!(validate_allocation_weights(&BTreeMap::new(), 1).is_err());
assert!(validate_allocation_weights(&BTreeMap::new(), 0).is_ok());
}
#[test] #[test]
fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() { fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() {
use crate::OrderIntent as I; use crate::OrderIntent as I;
+1 -1
View File
@@ -233,7 +233,7 @@ impl<'a> Scheduler<'a> {
pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> { pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> {
match stage { match stage {
ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")), ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")),
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")), ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 25, 0).expect("valid time")),
ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")), ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
ScheduleStage::Minute => None, ScheduleStage::Minute => None,
ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")), ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
+191
View File
@@ -0,0 +1,191 @@
use std::ops::Index;
use super::prefix_sums;
#[derive(Debug, Clone)]
pub(super) enum ReferenceMatchedValues {
Identical,
Owned(Vec<f64>),
}
impl ReferenceMatchedValues {
pub(super) fn push(&mut self, value: f64, reference: &[f64], capacity: usize) {
let previous_len = reference.len().checked_sub(1).expect("reference row is missing");
match self {
Self::Identical if value.to_bits() == reference[previous_len].to_bits() => {}
Self::Identical => {
let mut values = Vec::with_capacity(capacity);
values.extend_from_slice(&reference[..previous_len]);
values.push(value);
*self = Self::Owned(values);
}
Self::Owned(values) => {
debug_assert_eq!(values.len(), previous_len);
values.push(value);
}
}
}
pub(super) fn values<'a>(&'a self, reference: &'a [f64]) -> &'a [f64] {
match self {
Self::Identical => reference,
Self::Owned(values) => {
debug_assert_eq!(values.len(), reference.len());
values
}
}
}
pub(super) fn set(&mut self, index: usize, value: f64, reference: &[f64]) {
assert!(index < reference.len(), "series index out of bounds");
match self {
Self::Owned(values) => values[index] = value,
Self::Identical if value.to_bits() == reference[index].to_bits() => {}
Self::Identical => {
let mut values = reference.to_vec();
values[index] = value;
*self = Self::Owned(values);
}
}
}
pub(super) fn prefix(&self) -> Self {
match self {
Self::Identical => Self::Identical,
Self::Owned(values) => Self::Owned(prefix_sums(values)),
}
}
}
#[derive(Debug, Clone)]
pub(super) struct RepeatedValues<T> {
repeated: T,
values: Option<Vec<T>>,
len: usize,
}
impl<T: Default + Clone + Eq> RepeatedValues<T> {
pub(super) fn new() -> Self {
Self { repeated: T::default(), values: None, len: 0 }
}
pub(super) fn push(&mut self, value: &T, capacity: usize) {
if let Some(values) = &mut self.values {
values.push(value.clone());
} else if self.len == 0 {
self.repeated = value.clone();
} else if *value != self.repeated {
let mut values = Vec::with_capacity(capacity);
values.resize(self.len, std::mem::take(&mut self.repeated));
values.push(value.clone());
self.values = Some(values);
}
self.len += 1;
}
pub(super) fn set(&mut self, index: usize, value: T) {
assert!(index < self.len, "series index out of bounds");
if let Some(values) = &mut self.values {
values[index] = value;
} else if value != self.repeated {
let mut values = vec![std::mem::take(&mut self.repeated); self.len];
values[index] = value;
self.values = Some(values);
}
}
}
impl<T> Index<usize> for RepeatedValues<T> {
type Output = T;
fn index(&self, index: usize) -> &T {
assert!(index < self.len, "series index out of bounds");
match &self.values {
Some(values) => &values[index],
None => &self.repeated,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bits(values: &[f64]) -> Vec<u64> {
values.iter().map(|value| value.to_bits()).collect()
}
#[test]
fn identical_prices_share_only_after_exact_bit_comparison() {
let reference = [10., -0., f64::from_bits(0x7ff8_0000_0000_0042), f64::INFINITY];
let mut column = ReferenceMatchedValues::Identical;
for (index, value) in reference.iter().copied().enumerate() {
column.push(value, &reference[..=index], reference.len());
}
assert!(matches!(column, ReferenceMatchedValues::Identical));
assert_eq!(column.values(&reference).as_ptr(), reference.as_ptr());
let prefix = prefix_sums(&reference);
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix));
let original = column.clone();
column.set(1, 0., &reference);
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
assert_eq!(column.values(&reference)[1].to_bits(), 0_f64.to_bits());
assert_eq!(bits(original.values(&reference)), bits(&reference));
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix_sums(column.values(&reference))));
}
#[test]
fn differing_prices_preserve_zero_nan_payloads_and_prior_rows() {
let reference = [10., 11., f64::from_bits(0x7ff8_0000_0000_0042), 13.];
for actual in [
[10., 0., reference[2], 13.],
[10., 11., f64::from_bits(0x7ff8_0000_0000_0043), 13.],
] {
let mut column = ReferenceMatchedValues::Identical;
for (index, value) in actual.iter().copied().enumerate() {
column.push(value, &reference[..=index], actual.len());
}
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
assert_eq!(bits(column.values(&reference)), bits(&actual));
}
}
#[test]
fn repeated_values_preserve_nonzero_values_and_copy_on_change() {
let mut column = RepeatedValues::new();
for _ in 0..128 { column.push(&7_u64, 128); }
assert!(column.values.is_none());
assert_eq!(column[127], 7);
column.set(0, 7);
assert!(column.values.is_none());
let mut changed = column.clone();
changed.set(64, 9);
assert_eq!(changed[64], 9);
assert_eq!(changed[63], 7);
assert_eq!(column[64], 7);
}
#[test]
fn optional_values_keep_none_distinct_from_empty_and_repeated_text() {
for repeated in [None, Some(String::new()), Some("continuous".to_string())] {
let mut column = RepeatedValues::new();
for _ in 0..12 { column.push(&repeated, 16); }
assert!(column.values.is_none());
assert_eq!(column[0], repeated);
column.push(&Some("closing".to_string()), 16);
assert_eq!(column[11], repeated);
assert_eq!(column[12].as_deref(), Some("closing"));
column.set(5, None);
assert_eq!(column[5], None);
assert_eq!(column[4], repeated);
}
}
#[test]
#[should_panic(expected = "series index out of bounds")]
fn repeated_values_reject_out_of_range_access() {
let column = RepeatedValues::<u64>::new();
let _ = column[0];
}
}
+2 -2
View File
@@ -479,7 +479,7 @@ pub struct StockPoolSelection {
pub generation: Option<String>, pub generation: Option<String>,
} }
#[derive(Debug, Clone, Default, PartialEq)] #[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct StockPoolDecisionConstraints { pub struct StockPoolDecisionConstraints {
pub execution_date: Option<NaiveDate>, pub execution_date: Option<NaiveDate>,
pub frozen_positions: BTreeMap<String, FrozenStockPoolPosition>, pub frozen_positions: BTreeMap<String, FrozenStockPoolPosition>,
@@ -545,7 +545,7 @@ pub struct StockPoolPlan {
/// A signal-time contract. Only the broker/execution adapter supplies later /// A signal-time contract. Only the broker/execution adapter supplies later
/// prices, actual cash and holdings; strategy code never sees those inputs. /// prices, actual cash and holdings; strategy code never sees those inputs.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)]
pub struct FrozenStockPoolIntent { pub struct FrozenStockPoolIntent {
pub pool_id: String, pub pool_id: String,
pub signal_date: NaiveDate, pub signal_date: NaiveDate,
+23 -5
View File
@@ -20,6 +20,18 @@ use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSe
pub trait Strategy { pub trait Strategy {
fn name(&self) -> &str; fn name(&self) -> &str;
fn bind_runtime_position_configuration(
&mut self,
events: &[crate::position_exposure::PositionExposureEvent],
legacy: &BTreeMap<NaiveDate, i32>,
) -> Result<(), BacktestError> {
if !events.is_empty() || !legacy.is_empty() {
return Err(BacktestError::Execution(
"strategy does not implement runtime position configuration".into(),
));
}
Ok(())
}
fn initial_subscriptions(&self) -> BTreeSet<String> { fn initial_subscriptions(&self) -> BTreeSet<String> {
BTreeSet::new() BTreeSet::new()
} }
@@ -40,6 +52,12 @@ pub trait Strategy {
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
Ok(()) Ok(())
} }
/// External, already executed manual activity. It is not a new strategy
/// order and must not be run through order generation or transaction costs.
fn on_observed_manual_execution(
&mut self,
_execution: &crate::manual_execution::ManualReplayApplication,
) -> Result<(), BacktestError> { Ok(()) }
fn schedule_rules(&self) -> Vec<ScheduleRule> { fn schedule_rules(&self) -> Vec<ScheduleRule> {
Vec::new() Vec::new()
} }
@@ -977,7 +995,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
} }
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default, serde::Serialize)]
pub struct StrategyDecision { pub struct StrategyDecision {
pub buy_denials: BTreeMap<String, String>, pub buy_denials: BTreeMap<String, String>,
pub rebalance: bool, pub rebalance: bool,
@@ -1095,13 +1113,13 @@ mod decision_merge_tests {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum AlgoOrderStyle { pub enum AlgoOrderStyle {
Vwap, Vwap,
Twap, Twap,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum OrderTimeInForce { pub enum OrderTimeInForce {
Day, Day,
Ioc, Ioc,
@@ -1130,7 +1148,7 @@ impl OrderTimeInForce {
} }
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, serde::Serialize)]
pub enum TargetPortfolioOrderPricing { pub enum TargetPortfolioOrderPricing {
LimitPrices(BTreeMap<String, f64>), LimitPrices(BTreeMap<String, f64>),
AlgoOrder { AlgoOrder {
@@ -1140,7 +1158,7 @@ pub enum TargetPortfolioOrderPricing {
}, },
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, serde::Serialize)]
pub enum OrderIntent { pub enum OrderIntent {
StockPool { StockPool {
contract: Box<crate::stock_pool_execution::FrozenStockPoolIntent>, contract: Box<crate::stock_pool_execution::FrozenStockPoolIntent>,
@@ -770,6 +770,150 @@ fn pool_position_adjustments_use_execution_clock_and_restore_original_twenty_per
} }
} }
#[test]
fn runtime_allocation_is_separate_from_the_frozen_pool_and_restores_its_weights() {
let program = StockPoolProgram {
schema_version: 1,
pool_id: "manual-allocation".into(),
version_id: "v1".into(),
members: contract(day(2), 2, false).members,
exit_signals: vec![],
allocation_policy: serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":true}}),
timing_policy: serde_json::json!({"pricing_mode":"first_tick"}),
stop_take_policy: serde_json::json!({}),
out_of_pool_policy: "hold".into(),
};
let mut cfg = platform_expr_config_from_value(
"manual-allocation",
"000300.SH",
&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}}),
)
.unwrap();
cfg.market_cap_field = "close".into();
cfg.market_cap_lower_expr = "0".into();
cfg.market_cap_upper_expr = "1e12".into();
cfg.stock_filter_expr = "true".into();
cfg.selection_limit_expr = "2".into();
cfg.selection_candidate_limit_expr = "2".into();
cfg.rank_expr = "0".into();
cfg.matching_type = MatchingType::NextBarOpen;
let mut replay:fidc_core::manual_execution::ManualExecutionReplay=serde_json::from_value(serde_json::json!({
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
"observationCutoff":"2026-01-06T08:00:00Z","actions":[],"positionExposureEvents":[
{"eventId":"weights","sequence":1,"effectiveAt":"2026-01-05T09:30:00+08:00","action":"set","targetExposureBps":8000,"allocationWeightsBps":{"000001.SZ":3000,"000002.SZ":7000}},
{"eventId":"restore","sequence":2,"effectiveAt":"2026-01-06T09:30:00+08:00","action":"restore"}
]})).unwrap();
replay.content_sha256 = replay.content_digest().unwrap();
let result = BacktestEngine::new(
data(false),
PlatformExprStrategy::new(cfg),
broker(false),
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(6)),
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
},
)
.with_observed_manual_executions(replay)
.unwrap()
.run()
.unwrap();
let quantities = |date| {
result
.daily_holdings
.iter()
.filter(|row| row.date == date)
.map(|row| (row.symbol.clone(), row.quantity))
.collect::<BTreeMap<_, _>>()
};
assert_eq!(
quantities(day(5)),
BTreeMap::from([(code(1), 300), (code(2), 1600)]),
"{:?}",
result.fills
);
assert_eq!(
quantities(day(6)),
BTreeMap::from([(code(1), 700), (code(2), 1500)]),
"{:?}",
result.fills
);
assert!(
result.manual_executions.is_empty(),
"parameter events are not fabricated fills"
);
}
#[test]
fn outside_manual_holding_data_does_not_become_a_pool_candidate() {
let program = StockPoolProgram {
schema_version: 1,
pool_id: "manual-data-scope".into(),
version_id: "v1".into(),
members: vec![contract(day(2), 1, false).members.remove(0)],
exit_signals: vec![],
allocation_policy: serde_json::json!({"target_holding_count":1,"invest_ratio_bps":2000}),
timing_policy: serde_json::json!({"pricing_mode":"first_tick"}),
stop_take_policy: serde_json::json!({}),
out_of_pool_policy: "hold".into(),
};
let mut cfg = platform_expr_config_from_value(
"manual-data-scope",
"000300.SH",
&serde_json::json!({"stockPool":program,"universe":{"include":[code(1)]}}),
)
.unwrap();
cfg.market_cap_field = "close".into();
cfg.market_cap_lower_expr = "0".into();
cfg.market_cap_upper_expr = "1e12".into();
cfg.stock_filter_expr = "true".into();
cfg.selection_limit_expr = "1".into();
cfg.selection_candidate_limit_expr = "2".into();
cfg.rank_expr = "0".into();
cfg.matching_type = MatchingType::NextBarOpen;
let fill = serde_json::json!({"tradeId":"fill","observationEventId":"receipt","observationSequence":1,"tradeDate":"2026-01-05","executedAt":"2026-01-05T01:31:00Z","observedAt":"2026-01-05T01:31:01Z",
"feeObservationEventId":"receipt","feeObservationSequence":1,"feeObservedAt":"2026-01-05T01:31:01Z","timestampPrecision":"second","quantity":100,"price":"10","totalFee":"0"});
let order = serde_json::json!({"orderId":"external-order","sourceAdapter":"paper","symbol":code(2),"side":"Buy","quantity":100,"orderCreatedAt":"2026-01-05T01:30:00Z","terminalObservedAt":"2026-01-05T01:31:01Z","terminalStatus":"filled","fills":[fill]});
let mut replay:fidc_core::manual_execution::ManualExecutionReplay=serde_json::from_value(serde_json::json!({
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-01-06T08:00:00Z",
"actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],"confirmedAt":"2026-01-05T01:29:59Z","confirmationObservedAt":"2026-01-05T01:29:59Z","outcome":"orders_terminal","orders":[order]}]})).unwrap();
replay.content_sha256 = replay.content_digest().unwrap();
let result = BacktestEngine::new(
data(false),
PlatformExprStrategy::new(cfg),
broker(false),
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(6)),
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
},
)
.with_observed_manual_executions(replay)
.unwrap()
.run()
.unwrap();
assert!(
result.fills.iter().all(|fill| fill.symbol != code(2)),
"extra data cannot authorize an extra candidate"
);
assert_eq!(result.manual_executions.len(), 1);
assert_eq!(
result
.holdings_summary
.iter()
.find(|row| row.symbol == code(2))
.unwrap()
.quantity,
100
);
}
#[test] #[test]
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() { fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
for (ordinary, risk, quote, sold) in [ for (ordinary, risk, quote, sold) in [
@@ -1,6 +1,6 @@
# 回报上下文、盘前意图与尚未提交的目标 # 回报上下文、盘前意图与尚未提交的目标
2026-09-14。本轮为v2026.9.14.4之后的候选,当前只有本机验证,尚未发布;完整股票池Goal继续。 2026-09-14。本轮已配套发布177annotated tag `v2026.9.14.5`。Engine81acc54 / Service e81bf47 / Trading94f99d2;完整股票池Goal继续,不据本阶段关闭
## 已复现问题 ## 已复现问题
@@ -28,7 +28,27 @@
- 恢复顺序:开启正常旧恢复的单点负向对照确实多买3000股;恢复BeforeStrategy阶段后,只有原股票同一卖单的25+75股成交,无新增买入,最终持仓为空。 - 恢复顺序:开启正常旧恢复的单点负向对照确实多买3000股;恢复BeforeStrategy阶段后,只有原股票同一卖单的25+75股成交,无新增买入,最终持仓为空。
- 本机Core834项通过(9项原有ignore),Trading613、最新main Runner446/API119通过。外部数据库及平台ignore不当作通过。 - 本机Core834项通过(9项原有ignore),Trading613、最新main Runner446/API119通过。外部数据库及平台ignore不当作通过。
当前代码尚需精确Linux构建、真实历史合同回放和配套发布;不得把本机验证当生产或真实券商成交验收 精确只读快照在Linux通过Core834及Trading613。旧二进制先独立归档,构建保持1GiB磁盘余量;本轮未再次删除缓存或业务文件
## 发布与真实历史复验
已推送annotated tag `v2026.9.14.5`对应Engine `81acc5422878abc855fca72b35766ffad6159200`、Service `e81bf47806f5ac4ae4798bb5f5955a56638f754c`、Trading `94f99d20f49f6cd1810996706cb94f610c302385`。回测API/Runner于06:15:53 CST切换,五交易单元06:21:09切换,06:22实际运行文件和业务事实复核通过。
三组冻结合同共六次独立原生A/B,完整Canonical及equity/orders/trades/holdings逐行一致;再通过生产HTTP各提交一次,结果分别匹配原生候选,旧记录未改写:
| 案例 | 生产回测ID | 成交 / 持仓 | 期末权益 |
| --- | --- | --- | ---: |
| 手选优先四证券 | btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20 | 10 / 4 | 9706248.648662 |
| 自动优先四证券 | btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237 | 10 / 4 | 9706248.648662 |
| 许总24只原v3 | btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4 | 51 / 21 | 9685563.876924999 |
重复目标委托0。三条新记录各5个交易日事件落库,持久事件27/18/32条,唯一键计数分别相同;旧流式样本仍27条/5日。仍为原合同下的日终容量审计,不外推实时盘口成交能力;首次Source准备和后续快速返回也不作为性能优化证明。
API SHA `dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc`Runner SHA `b1d93215deb275fbec6217c6b9afbf717d5649600716bf4f3a1bf5d1cfa69731`,运行实现身份 `fed10e9fa61836aa271921f5d58490054210d83da935cad5de11cfacab45c13e`。API发布目录`/srv/fidc/canonical/run/backtest-api/releases/callback-81acc54-7w1zx9fb`,回退目录`/srv/fidc/canonical/run/build/callback-rollback-j7oje2tz`;交易回退`holding-protection-rollback-czuric4r`
六服务实际SHA与manifest吻合,新增ERROR0。3Paper/0Live、配置、旧活动单、3个未确认Paper预览、迁移、影子配置0及disabled未变;发布后Paper/Live新订单0,未发送真实通知、委托或撤单。Source d5/PID1700096与UI6a2/PID3089476未重启,研究/信号暂停不变。177维护中的Engine9a54156完整保留,实际构建使用81acc54/e81bf47及81acc54/94f99d2的只读Git快照。
原始回放/HTTP证据`/srv/fidc/canonical/run/research/stock-pool-callback-20260914/`;发布和最终审计`/tmp/fidc-callback-{candidate,api-release,trading-release,final-audit}-20260914.json`;非敏感汇总在`docs/evidence/callback-target-20260914/acceptance.json`
## 继续范围 ## 继续范围
@@ -0,0 +1,191 @@
{
"verified_at": "2026-09-13T22:22:38.597836+00:00",
"tag": "v2026.9.14.5",
"processes": {
"fidc-backtest-service-highmem177.service": {
"pid": 3692551,
"sha256": "dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc",
"journal_since": "2026-09-13T22:15:53.225719+00:00",
"journal_lines": 54,
"error_lines": 0
},
"fidc-trading-control-highmem177.service": {
"pid": 3697497,
"sha256": "a8f62ba74caf7ce2f5ba9cc6f67f41c844dee3747852611051c8dfb7b36295a3",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-market-data-highmem177.service": {
"pid": 3697498,
"sha256": "89395ab4c9e11274f171f4386f88949ce616fe45d15aae9a829216d53ed11db7",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-strategy-runtime-highmem177.service": {
"pid": 3697688,
"sha256": "041a0103d2c46c55221d169965ece9fdacee3905abc0d46edd9c8a2a86f6cd54",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-paper-trading-highmem177.service": {
"pid": 3697792,
"sha256": "8460008f712810f7d3876b9f2274aef88f82d46cd33e1593dbd0361f6d158b75",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 6,
"error_lines": 0
},
"fidc-live-trading-highmem177.service": {
"pid": 3697762,
"sha256": "e89d6ba68655a5d4a164793ad67a9003cf2c49733063018497339930feafaac3",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 6,
"error_lines": 0
}
},
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"loaded_at": "2026-09-12T03:57:06.665536+00:00",
"source_stale": false,
"loaded_server_sha256": "ef827ce6b95e0ea63047a0068af2677633716e0a5d63cf350de6c91a3413e352"
},
"source_checkouts": {
"fidc-backtest-engine": {
"head": "9a54156df94cfbf11a1e6335ec6ef5449bd6ac17",
"runtime_commit": "81acc5422878abc855fca72b35766ffad6159200",
"tracked_dirty": false
},
"fidc-backtest-service": {
"head": "5ec8dc86d99736a0c0140440bd039d11e118c1c6",
"runtime_commit": "e81bf47806f5ac4ae4798bb5f5955a56638f754c",
"tracked_dirty": false
},
"fidc-trading-platform": {
"head": "94f99d20f49f6cd1810996706cb94f610c302385",
"runtime_commit": "94f99d20f49f6cd1810996706cb94f610c302385",
"tracked_dirty": false
},
"omniquant": {
"head": "6a2b2604b40505fa754453307c517fef60743426",
"runtime_commit": "6a2b2604b40505fa754453307c517fef60743426",
"tracked_dirty": false
}
},
"ui_unchanged": {
"commit": "6a2b2604b40505fa754453307c517fef60743426",
"pid": 3089476
},
"http_cases": [
{
"name": "manual_first",
"run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20",
"status": "succeeded",
"canonical_sha256": "0830216850b64d6e83291e072b31a9989f179915ee3341a75a77c73d1f9081a3",
"trade_count": 10,
"holding_count": 4,
"final_equity": 9706248.648662,
"old_result_unchanged": true
},
{
"name": "automatic_first",
"run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237",
"status": "succeeded",
"canonical_sha256": "c75cabcc03760f415bb664d20060e81c620d7a0201dd348ea71f75c932571de7",
"trade_count": 10,
"holding_count": 4,
"final_equity": 9706248.648662,
"old_result_unchanged": true
},
{
"name": "stock24",
"run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4",
"status": "succeeded",
"canonical_sha256": "270b403542ab41290c3d6e027b89cdab24dd41a8e2851d8786b33daa51e0051f",
"trade_count": 51,
"holding_count": 21,
"final_equity": 9685563.876924999,
"old_result_unchanged": true
}
],
"durable_events": [
{
"run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20",
"count": 27,
"unique_keys": 27,
"days": 5
},
{
"run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237",
"count": 18,
"unique_keys": 18,
"days": 5
},
{
"run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4",
"count": 32,
"unique_keys": 32,
"days": 5
},
{
"run_id": "btr_req_a3c3dfe5cd81e27e565064a65665f561c60adebdc6c9c9b4",
"count": 27,
"unique_keys": 27,
"days": 5
}
],
"trading_state": {
"paper": {
"configuration": {
"count": 3,
"hash": "93f3224edef59c381164e0236529dacc"
},
"active": {
"claims": 0,
"orders": 0
}
},
"live": {
"configuration": {
"count": 0,
"hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"active": {
"claims": 0,
"orders": 1,
"today_orders": 0,
"orders_hash": "d4b56fbf3a541a41a383ad4e48891bb8",
"route_mode": "disabled"
}
}
},
"manual_facts_unchanged": {
"paper": {
"shadow_configurations": 0,
"shadow_runs": 0,
"manual_count": 3,
"manual_hash": "82572901ac0b5fdb4d8b984f71e1763d",
"migrations_hash": "21d711b2ee52d2d66a8be4e99b179190",
"new_orders": 0
},
"live": {
"shadow_configurations": 0,
"shadow_runs": 0,
"manual_count": 0,
"manual_hash": "d41d8cd98f00b204e9800998ecf8427e",
"migrations_hash": "610528d4f350309379c9398c4ea43f66",
"new_orders": 0
}
},
"broker_submission": false,
"linux_core_tests": {
"passed": 834,
"failed": 0,
"ignored": 9,
"log": "/srv/fidc/canonical/run/fidc-private/evidence/callback-candidate-6qumwky7/linux-core-tests.log"
},
"scope": "Callback and pending-target release verification; historical simulation only. Full manual shadow replay remains incomplete.",
"native_replays": 6
}
@@ -0,0 +1,648 @@
{
"schema": "fidc.series-column-storage-acceptance/v1",
"rows": [
{
"name": "control-1",
"receiptSha256": "fa8f2265f1d3ed2cdeef02f62840fec00a266ace3cf9399cb81d33110f3b115c",
"wallSeconds": 23.878584733000025,
"engineSeconds": 6.612,
"dataSeconds": 5.213,
"datasetConstructSeconds": 1.901,
"loopSeconds": 1.734,
"validationSeconds": 10.871,
"resultSeconds": 1.007,
"maxRssKiB": 7137676,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "candidate-1",
"receiptSha256": "edd7e575013932ae58603e9fa810626573e1b0eaa00d01426d736cb4740aaf86",
"wallSeconds": 24.12557172914967,
"engineSeconds": 6.674,
"dataSeconds": 4.818,
"datasetConstructSeconds": 1.589,
"loopSeconds": 1.646,
"validationSeconds": 11.47,
"resultSeconds": 1.011,
"maxRssKiB": 6463660,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "rotation-control-2",
"receiptSha256": "6a5b37c2881c7a4177ce69cef2440c3dd7a0846c6ca9a4811c68f173b16f7183",
"wallSeconds": 18.18023332185112,
"engineSeconds": 9.509,
"dataSeconds": 7.272,
"datasetConstructSeconds": 2.824,
"loopSeconds": 2.822,
"validationSeconds": 0.005,
"resultSeconds": 1.206,
"maxRssKiB": 7138628,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "rotation-candidate-2",
"receiptSha256": "f50ca6af6496213d558af646862e5a86334077bb9080e4e7c6c9602a0cabd789",
"wallSeconds": 15.078223099000752,
"engineSeconds": 8.171,
"dataSeconds": 5.543,
"datasetConstructSeconds": 2.094,
"loopSeconds": 2.066,
"validationSeconds": 0.004,
"resultSeconds": 1.177,
"maxRssKiB": 6454624,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "candidate-3",
"receiptSha256": "c8bce9a8eeda769f00e72118c9ea955d323fdad902dc271590d6e377b5a99196",
"wallSeconds": 13.124768079956993,
"engineSeconds": 6.649,
"dataSeconds": 5.103,
"datasetConstructSeconds": 1.853,
"loopSeconds": 1.676,
"validationSeconds": 0.208,
"resultSeconds": 1.017,
"maxRssKiB": 6457728,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "control-3",
"receiptSha256": "1f999a89aadab1baf4a70cda96a314ab237302722cdce895bb5fe26dea4306ec",
"wallSeconds": 12.725315875839442,
"engineSeconds": 6.583,
"dataSeconds": 4.955,
"datasetConstructSeconds": 1.713,
"loopSeconds": 1.675,
"validationSeconds": 0.004,
"resultSeconds": 1.013,
"maxRssKiB": 7140772,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "trend-40-control",
"receiptSha256": "55b1976d15ba531477ff92f07f953a0a8a9b7cab96f01fb0ce5546f9aeda7406",
"wallSeconds": 14.779385674046353,
"engineSeconds": 7.867,
"dataSeconds": 5.418,
"datasetConstructSeconds": 1.921,
"loopSeconds": 1.952,
"validationSeconds": 0.005,
"resultSeconds": 1.35,
"maxRssKiB": 7157844,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "trend-40-candidate",
"receiptSha256": "86a3f452168d059f034e91f7317890af49d9763b75745dd3d39a948b00c1d72d",
"wallSeconds": 15.011793529149145,
"engineSeconds": 8.011,
"dataSeconds": 5.168,
"datasetConstructSeconds": 1.851,
"loopSeconds": 1.739,
"validationSeconds": 0.376,
"resultSeconds": 1.315,
"maxRssKiB": 6470768,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "pullback-40-control",
"receiptSha256": "f5e6c9cb90db0dc7095e388b4ee939d4c6609824f3c065e0a08144a80f3d9395",
"wallSeconds": 14.010676869889721,
"engineSeconds": 7.298,
"dataSeconds": 5.172,
"datasetConstructSeconds": 1.893,
"loopSeconds": 1.7,
"validationSeconds": 0.005,
"resultSeconds": 1.388,
"maxRssKiB": 7167408,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "pullback-40-candidate",
"receiptSha256": "f8caf67a6010d882a064678cf5c57f48c53c85b7c82dde708c029ee52e9e56b2",
"wallSeconds": 13.87669027899392,
"engineSeconds": 7.219,
"dataSeconds": 5.117,
"datasetConstructSeconds": 1.837,
"loopSeconds": 1.691,
"validationSeconds": 0.005,
"resultSeconds": 1.362,
"maxRssKiB": 6478492,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "volume-momentum-80-control",
"receiptSha256": "0f589d11fec4a37f80635446fa445b7c7a9ae58e202e5b0c2533aa257fd94c81",
"wallSeconds": 18.577259425073862,
"engineSeconds": 10.96,
"dataSeconds": 5.163,
"datasetConstructSeconds": 1.891,
"loopSeconds": 1.698,
"validationSeconds": 0.004,
"resultSeconds": 2.282,
"maxRssKiB": 7210220,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "volume-momentum-80-candidate",
"receiptSha256": "5ae74a1b311479a39c9863c2fba487951631353f9ae2a523d919f2d0a8f592e7",
"wallSeconds": 18.476340716006234,
"engineSeconds": 10.989,
"dataSeconds": 5.051,
"datasetConstructSeconds": 1.844,
"loopSeconds": 1.645,
"validationSeconds": 0.005,
"resultSeconds": 2.284,
"maxRssKiB": 6527236,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
}
],
"sharedInputFiles": 9257,
"sharedInputBytes": 12596608049,
"sharedInputInventorySha256": "1a4818aaab906e77b750e28601d3d405ad9e14e0553f7937cc60b68be0c9b71d",
"verifiedFactBlocks": 3506,
"status": "candidate-not-deployed",
"scope": "exact in-memory column reuse; separate DayOpen correctness fix included in both control and candidate",
"controlRunnerSha256": "8859459f54389f12af1ab7d4e36802c01aff63fb10fbb679243ccdd54d013e2d",
"candidateRunnerSha256": "40bcf65c1977dbd93ab8bc80e3ff04d0db5e27b61fce1afdce99cf1b5e58eb43",
"candidateApiSha256": "c54be3a8196c32051520c709f793bcb974d869467bb12700d846efaad8c2180e",
"engineCommit": "996b909608589fb1987f33c0cfb4c62099f69617",
"serviceCommit": "443ed421c2c9c854a01fab69ce58957690504570",
"boundaries": [
"The DayOpen prefix correction is present in both storage A/B binaries.",
"No file format, cache schema, input values or execution policy changed for the storage comparison.",
"Original shared inputs were hashed and remained unchanged; results were recalculated into private artifacts.",
"The last pair ran candidate before control. It did not establish a general latency improvement.",
"Source remains frozen and paused research/signal tasks were not resumed.",
"The independent same-day intraday clock counterexample remains unresolved."
]
}
+51
View File
@@ -0,0 +1,51 @@
# 手工观察主时钟接入候选
2026-09-14,未发布,完整Goal不关闭。不是生产手工影子回放验收。
## 本阶段已实现
- BacktestEngine可显式绑定严格v2手工观察输入。原始回报时刻驱动账本;同一时刻按真实观察序号逐笔原子应用,回调能看到100、200而不是第一笔就看到两笔总量。
- 默认盘前、开盘、盘中、收盘/结算及当日晚到回报纳入处理;跨会话观察先于下一会话公司行为,不生成行情行;结束后仍未覆盖的观察明确失败,不截断成成功。
- 手工成交写独立来源及应用明细,不冒充模拟策略FillEvent。账户变化不计作出入金;手续费只扣一次,最终费用来源/时间仍单独保留。
- 股数及现金改变后通知策略,真实买卖日期更新持有保护和卖后禁买证据;券商模拟器的当日卖后禁买规则同样接收手工卖出,不把手工绕过自动条件理解为抹掉真实成交历史。
- 分钟时钟不必依赖策略订阅或同一时刻市场报价,手工价格也不会伪造为市场行情。已有挂单/待执行目标冲突仍明确拒绝,不替用户撤单或重建目标。
- 流式数量、原始观察明细及换手率纳入手工应用;纯无成交的来源不会改变自然策略时钟。
## 已复现并修正的问题
旧默认OpenAuction回调在09:31,接着却可能执行09:30日内步骤。手工09:27观察会由此先进入09:31再倒退到09:30。已把默认开盘阶段放在09:25,并保留显式调度时间。
盘前08:50/09:10规则原来在同一状态上顺序计算,不能正确看到夹在两者之间的08:55回报。现按实际到期时间交错处理回报、调度、资金等指令和撤改控制;盘前阶段若跨越开盘阶段,明确报告冲突,不把晚时点状态带回早时点。
## 当前验证
Core872通过(9项原ignore不计通过),交易工作区619普通测试通过;不是实际券商行情验收。此前默认阶段样例最终600股、现金3991、权益9991、出入金0,原四个基础用例保留。
本轮新增8项回归,不重复把基础样例当新验收:
- OpenAuction 09:20/09:26、AfterTrading 15:15/16:00、Settlement 16:10与09:22/15:30/16:05手工观察交错。原候选09:20提前读到100股,16:00/16:10仍只读到100股;修复后依次为0/100/100/200/300股。
- 盘后16:00的100股与结算16:10的200股显式指令,下一交易日各执行一次、共300股,信号日价格10不冒充执行日价格12。lag0/lag1保留原信号日、意图创建日和实际成交日;测试还抓到立即成交记录曾被统一注释为新信号日,已按批次原始日期记录。
- 多个完整目标在进入待执行队列时就只保留最新一份;次日新的0%完整目标不会先执行旧买入。显式股数指令不作为完整目标覆盖。
- 结束日期的两笔显式意图没有生成委托/成交,完整原指令留在terminalAudit;完整目标只留最新一份。NaN/Inf不能在JSON中被悄悄变成null。
- 存在真实行情/风控但没有新因子选股快照的下一交易日,仍执行已有指令,不等到后日再运行。声明盘后阶段的策略使用完整市场日历,外部指标可用同一`backtest_execution_dates_with_rules`对齐;当前Runner的Platform策略只暴露OpenAuction/OnDay/Minute,不宣称已支持配置盘后阶段。
- 显式开盘调度越过已配置执行窗口被拒绝;盘后GTC撤单立即作为控制执行,不变成次日新委托。
盘后处理使用正常账本/报价/风控入口,不创建模拟外部Fill,不越过结束日期。旧DAY订单仍按到期失效,下一日处理的是尚未提交的策略意图,并非延长旧订单有效期。无新信号的报价时钟复用有序迭代器,不复制整日Tick列表。
结果协议和API/Runner的候选接入见fidc-backtest-service/docs/manual-execution-run-contract-20260914.md。当前正常记录/费用原始精度不改;所有新代码尚未发布,影子调用仍没有解除四类纯比例拒绝门禁。
## 必须继续
1. 本轮已覆盖上述显式阶段与跨日用例;仍需补完整混合时钟矩阵,特别是显式开盘晚于盘中报价/ETF开盘、无新信号日同时有ETF待执行目标、公司行为和跨日保护组合。不得修改market_open已有09:31语义或把这些未验组合静默跳过以让测试通过。
2. 完成影子调度调用、所需历史证券范围、来源权限/归属、实际HTTP和Linux验收;不以独立输入/结果单测冒充端到端。
3. 结果委托/成交分页接口与统一UI仍须合并展示外部手工来源,保留未知组件和完整原始ID,不把仅落库视为呈现已完成。
4. 核对GT正式总费用来源、整仓关键日志严格持久化及完整参数矩阵后再配套发布。
本轮未重启生产或发送委托。同期其他维护已将Backtest发布为Engine665653c/Service501f6d0;这不包含本文件所述主时钟候选。交易仍166998d/v2026.9.14.6Source d5/PID1700096冻结与研究暂停不改。
## 2026-09-14 运行级仓位配置补充
v3 手工输入独立携带审计仓位/权重时间线与旧日级前缀,不覆盖原策略或股票池。仅已成交证券产生独立行情需求;补充范围不会成为选股候选。恢复跟随回到原规则,未来事件不能被伪称为截止时刻前已观察事实。Core 878 项本机通过,尚未部署;PG、期间隔离、权限与剩余联合验收见 `../../fidc-trading-platform/docs/shadow-manual-input-20260914.md`。本节不替代前述时钟证据,也不宣称全部矩阵完成。
## 逐日手工交付补充
手工观察输入可通过Arc与进度投影共享;默认紧凑进度保留当日手工应用及独立累计计数,原生明细开关不改。新增可失败进度回调,投影来源/计数错误会终止本次回测,不忽略错误后返回成功。Core879本机通过,当前完整版本Linux及发布验收未完成;共享最终/逐日投影与真实本机WebSocket证据见Service `docs/manual-stream-projection-20260914.md`
+45
View File
@@ -0,0 +1,45 @@
# 手工成交观察回放:基础合同与当前断点
2026-09-14。当前候选已升级v2并与交易端权威读取配套,仍未接入Runner/API或引擎主时钟、未发布。交易最近发布是166998d/v2026.9.14.6,回测仍81acc54/e81;完整Goal和手工影子回放均未完成。
## v2读取合同补充
默认主时钟、盘前交错与独立结果来源已开始配套接入,当前阶段/真实缺口改由docs/manual-execution-clock-20260914.md维护。本基础模块通过不等于完整阶段日历或生产影子已启用。
总费用必须来自权威事实,佣金/印花税/过户费等组件可以未知,不能反过来用已知组件推定费用完整。保留组件原精度、总费用和微元账本费用;未知组件不写成0。新增费用来源事件/序号/可见时刻,原FillReceived继续决定股数变化时刻,后补费用不推迟成交、也不重复入账。历史采用最终费用回放口径,不能声称费用明细当时已经可见。
分别表达订单创建、确认登记、成交、原始观察、费用观察与终态核对,不伪装GT实际发送时间。无订单区分NoOrdersNeeded与NotExecuted;无成交且无券商身份时允许适配器未知,不造名称。确认登记之前的成交、证据跨交易复用、费用少于已知组件及越截止点均拒绝。
最新main a29c434的DayOpen和列存变更已按ff-only保留合入;组合Core860通过,其中本模块18项。交易端读取四类来源及验证范围见fidc-trading-platform/docs/manual-replay-capture-20260914.md。未将整仓无订单、Paper一例与Live一例外推完整参数/时钟/券商验收,不据此解除门禁。
## 已实现
`manual_execution`提供`fidc.observed-manual-executions/v2`严格合同及`ManualReplayCursor`。这是将已确认的手工成交事实作为外部输入,不是让回测券商独立重演其真实成交。下面保留初版阶段的实现说明,费用和时间字段以本节v2补充为准。
- 保留确认、提交、成交、观察和终态时间,声明秒/毫秒/微秒/纳秒精度;同秒报告只允许在其真实精度区间内与提交时间对应,不伪造纳秒。
- 手工动作、审计事件、订单、券商订单、成交和`FillReceived`观察事件/序号均有唯一性与完整性校验。账户/运行身份及源合同摘要进入完整内容SHA;改价格、费用、身份或时间会使旧摘要失效。
- 明确区分无须生成订单与有终态订单,拒绝不完整、未知、超量、状态不一致、超截止日期的数据。不将空订单列表直接当成功。
- 金额输入使用十进制字符串,不先经过JSON浮点数。保留原价、原费用、原成交额;账本沿用既有微元精度,真实十进制金额在入口统一量化,并分开返回原值和账本值。
- 游标按真实观察时间和已持久化事件序号前进,重入同一时点不会重复入账,时间倒退或越过证据截止时间会失败。
- 资金、持仓及游标在一次advance中原子变更。资金不足、T+1、生命周期冲突或活动影子订单冲突不借股、不借款、不取消原订单,也不留下半笔状态。
- 人工交易不是出入金,不更改现金流中性单位或初始资金;原始买卖账本入口继续使用原有计算,仅抽出可传固定金额的内部函数。
本机Core849项通过(9项原有ignore),其中15项新专项覆盖精度/摘要/关联/时间/顺序/无订单/部分撤单/原子失败/不重复和跨日出售。此结果不代表服务、完整影子请求或生产成交验收。
## 已核对的持久化入口
Paper `paper_manual_position_actions`保存确认、执行合同SHA、计划与order_ids`paper_fills``paper_event_log.FillReceived`可以提供真实成交及观察事件序号。Live单证券动作在`live_manual_trade_intents`,逐笔事实在`live_broker_trade_facts`,对应`live_event_log.FillReceived`提供recorded_at和序号。事件序号表示持久化观察顺序,不冒充交易所执行顺序。
Live整仓的历史审计原来只有confirmation_hash,执行ID在另一个开始事件中;当前候选已将服务端生成的execution_id和所选account_id写入同一仓位审计详情,并校验非空ID和账户范围。旧历史仍只能依据原始审计/事件做唯一关联,不能猜测或重写。
费用仍需在读取层核对实际适配器合同:当前Paper账本收取commission+stamp_taxLive事实的complete也按这两个已声明字段判定。不能仅凭complete名字断言其他费用不存在,不能以默认0补缺失。
## 必须继续,不能把本阶段当完成
1. 实现全部四类来源的权威PG读取、审计/动作/订单/成交/事件绑定与一致快照;未知/活动状态等待,不能变成空成功。
2. 在API/Runner传递完整受控合同和源范围,补齐手工证券的历史资料/行情需求。当前没有任何运行入口调用此游标。
3. 把观察事件与盘前、集合竞价、日度、分钟、收盘/结算阶段按完整时钟合并;跨交易日/会话外观察不可简单塞进on_minute或提前应用。
4. 输出须区分外部人工成交与策略模拟成交,保留原始执行时间、观察时间、费用和实际投影时间线,不能宣称人工成交被独立验证。
5. 完成两套隔离PG、真实引擎、完整HTTP和发布验证后,才可解除四类手工来源的纯比例影子拒绝门禁。
下一轮直接进行上述读取/引擎/结果链,不能重复15项基础用例或v2026.9.14.5固定三组回放替代集成。Source冻结、研究/信号暂停、现有3Paper/0Live与disabled不变;本轮无生产写入、真实订单或通知。
@@ -2,16 +2,19 @@
## Status ## Status
Candidate tested, not deployed. The change removes selection calls that have Published to Backtest in the combined 665653c/501f6d0 release described below.
The change removes selection calls that have
no possible effect under the current frozen policy. It does not disable any no possible effect under the current frozen policy. It does not disable any
configured rule, execution-day check or strategy expression. Engine time falls configured rule, execution-day check or strategy expression. Engine time falls
slightly in the measured cases; this is not the solution to the main remaining slightly in the measured cases; this is not the solution to the main remaining
data construction cost and is not a general whole-backtest speedup claim. data construction cost and is not a general whole-backtest speedup claim.
The independent [intraday-clock counterexample](intraday-clock-causality-20260914.md) The independent [intraday-clock counterexample](intraday-clock-causality-20260914.md)
remains open. This work does not remove that test or its evidence, change the was subsequently resolved by business-main work and published in the clock and
execution clock, or turn day-level parity into full framework acceptance. 81acc54 callback releases. That correction is not attributed to this candidate.
The published service stays at e81bf47/c98bcc3. Source d5b682c6 remains frozen; Current published Backtest uses 501f6d0/665653c; the measurements below retain
their earlier c98 baseline. Complete manual-replay integration remains open.
Source d5b682c6 remains frozen;
research and signal work stay paused. No trading operation was submitted. research and signal work stay paused. No trading operation was submitted.
## Evidence Leading to the Change ## Evidence Leading to the Change
@@ -110,9 +113,10 @@ Prioritize direct typed-column reuse during daily snapshot and DataSet
construction; approximately five seconds of preparation remain in these warm construction; approximately five seconds of preparation remain in these warm
cases. Do not skip normalization, NULL, adjustment or date/uniqueness checks. cases. Do not skip normalization, NULL, adjustment or date/uniqueness checks.
Source cold-query and contract-validation latency remain separate tasks under Source cold-query and contract-validation latency remain separate tasks under
the Source freeze. The earlier cache-boundary candidate still needs its missing the Source freeze. The cache-boundary candidate later passed its conditional
cold/same-window acceptance, and this combined candidate has no HTTP publication cross-window/full-input gate and the combination passed daily HTTP publication;
gate yet. Financial PIT, minute-clock behavior, signal lifecycle and UI factor neither establishes cold or universal performance. Financial PIT, broader minute
behavior, signal lifecycle and UI factor
condition acceptance are not claimed complete. condition acceptance are not claimed complete.
- Candidate engine: d2aa16a2f0064297d0d8c931060646d66422e9d4. - Candidate engine: d2aa16a2f0064297d0d8c931060646d66422e9d4.
@@ -124,3 +128,19 @@ condition acceptance are not claimed complete.
- HTTP diagnostic: /srv/fidc/canonical/run/research/http-phase-profile-20260914. - HTTP diagnostic: /srv/fidc/canonical/run/research/http-phase-profile-20260914.
[Verified acceptance summary](evidence/selection-risk-plan-20260914/acceptance.json). [Verified acceptance summary](evidence/selection-risk-plan-20260914/acceptance.json).
## Combined Release
After merging engine 665653c, 860 core / 448 runner / 119 API tests and six
additional new-process replays passed. The guarded official workflow deployed
Backtest only, then nine HTTP runs matched their respective canonical/store
baselines. A multi-strategy sequence proved actual immutable DataSet hit counts
0/1/2/3/4 with distinct strategy results and repeatable trend results. Default
90-day cleared-DataSet HTTP mean 13.646 before versus 13.740 seconds after does
not demonstrate a general latency gain.
The active root /srv/fidc/canonical/build/factor-reserve-20260913 is protected
from reuse/cleanup. Source, paused research, trading services and all execution
permissions remain unchanged. This does not activate the manual-replay module.
Actual identities, timings and evidence are maintained in
`/Users/boris/WorkSpace/fidc-backtest-service/docs/cache-boundary-planning-performance-20260914.md`.
@@ -0,0 +1,147 @@
# Exact Series Column Storage
## Status
The subsequent business-main merge includes the separately published 81acc54
clock/callback fixes and 5e11f3d manual-replay foundation. The combined version
passed 857 core, 448 runner and 119 API tests, six long reference replays and
three additional strategy replays;
see `/Users/boris/WorkSpace/fidc-backtest-service/docs/arrow-factor-scratch-rejection-20260914.md`.
The scratch candidate from that experiment was removed. Series storage is
now published to Backtest only in the combined 665653c/501f6d0 release below;
existing measurements retain their original versions.
The original twelve real long replays preserve their independent
business baselines and reduce peak RSS by about 9.5%. Construction latency is
mixed, including a reversed pair where the control is faster. This is accepted
as evidence of a smaller working set, not as a proved general speedup or closure
of the main performance objective. Original results are retained unchanged.
The Source implementation remains d5b682c6d09704ff23d725a8dd8b155db3eb6967.
Research/signal work remains paused. The initial experiments ran while e81bf47/c98
was published; later business work published e81bf47/81acc54. This performance
task did not restart Source, trading or another user's process. The original
clock counterexample was resolved by that business work; complete manual-replay
integration remains open and is not proved by these performance tests.
## Separate DayOpen Correction
Code inspection found that PriceField::DayOpen selected the Open prefix sums,
although direct history access returned day_open. For day_open values 10/12
and open values 20/24, that path computes 22 instead of the expected 11.
The correction adds its own day-open prefix and a regression checking both
fields plus empty/insufficient windows. No price field is substituted.
This correction was built and tested independently before the storage change:
806 core unit/integration tests, 448 runner tests and 119 API tests passed.
The resulting control runner is
8859459f54389f12af1ab7d4e36802c01aff63fb10fbb679243ccdd54d013e2d.
It also preserves the real rotation baseline. Both subsequent A/B variants
include the fix, so corrected calculation semantics are not counted as speedup.
## Storage Design
SymbolPriceSeries previously allocated separate vectors for last/bid/ask,
their prefix, timestamps, trading phases and three quote-volume fields, even
when actual data repeated or exactly matched the existing close series.
- ReferenceMatchedValues aliases the existing column only after every consumed
value matches by f64::to_bits. A mismatch materializes the exact preceding
values and continues as an owned vector. No missing/invalid price is replaced
by close; signed zero and NaN payload differences prevent sharing.
- RepeatedValues retains the actual first value and logical length. It avoids
expanding equal values, including nonzero volumes and Some strings. The
first difference materializes the exact prior values. None is distinct from
an empty string; no value is inferred from the backtest frequency.
- Intraday updates materialize only changed columns. Cloned views retain their
original values and immutable daily base. Last-price prefix sums use the same
accumulation order and actual values as before. History cutoffs are unchanged.
There is no new dependency, unsafe code, strategy-specific branch, disk schema,
source-data rewrite or account/result sharing. Construction and data validation
remain in the existing paths. The overlay comment now accurately states that
quote fields affect Last history while daily OHLC remains unchanged.
The full candidate passes 813 core unit/integration tests (9 ignored), 448 runner
tests (9 ignored) and 119 API tests (5 ignored). New tests cover exact bit
identity, distinct zero/NaN values, repeated nonzero/string values, mutation
isolation, unknown dates, full snapshot equality and history-date cutoffs.
These tests do not prove the separately known same-day execution-clock issue.
## Real A/B
All cases execute 2021-08-23 through 2025-11-17 with their unchanged frozen
strategy/runtime/bundle and 10,000,000 initial cash. This is not five complete
execution years. Every run is a new process with private result artifacts and
the same verified shared inputs: 9,257 files / 12,596,608,049 bytes. No original
input changed and no Arrow/bin input was newly created. Hashing is outside the
runner timer; no result is reused. Source/OS caches are not cold.
| Case | Wall s | Data s | DataSet construction s | Engine s | RSS KiB |
|---|---:|---:|---:|---:|---:|
| Rotation control 1 | 23.879 | 5.213 | 1.901 | 6.612 | 7,137,676 |
| Rotation candidate 1 | 24.126 | 4.818 | 1.589 | 6.674 | 6,463,660 |
| Rotation control 2 | 18.180 | 7.272 | 2.824 | 9.509 | 7,138,628 |
| Rotation candidate 2 | 15.078 | 5.543 | 2.094 | 8.171 | 6,454,624 |
| Rotation candidate 3 | 13.125 | 5.103 | 1.853 | 6.649 | 6,457,728 |
| Rotation control 3 | 12.725 | 4.955 | 1.713 | 6.583 | 7,140,772 |
| Trend 40 control | 14.779 | 5.418 | 1.921 | 7.867 | 7,157,844 |
| Trend 40 candidate | 15.012 | 5.168 | 1.851 | 8.011 | 6,470,768 |
| Pullback 40 control | 14.011 | 5.172 | 1.893 | 7.298 | 7,167,408 |
| Pullback 40 candidate | 13.877 | 5.117 | 1.837 | 7.219 | 6,478,492 |
| Volume 80 control | 18.577 | 5.163 | 1.891 | 10.960 | 7,210,220 |
| Volume 80 candidate | 18.476 | 5.051 | 1.844 | 10.989 | 6,527,236 |
The final rotation pair deliberately ran candidate before control. Rotation
RSS medians are 7,138,628 versus 6,457,728 KiB, about 665 MiB / 9.5% lower.
Other strategy pairs save about 670-673 MiB. These are measured process peaks,
not estimates obtained by adding cgroup limits or counting mmap as private RAM.
Construction medians are 1.901 versus 1.853 seconds for rotation. The first pair
has a larger reduction, but other samples and the reversed pair do not support
a universal 16% construction or total-latency claim. Source validation waits and
independent phase variation remain in the full evidence. A read-only host sample
showed load near 49 and thermal readings 53/58/69 C; it does not prove the cause
of timing variation. No host policy or another user's workload was changed.
All six canonical sections and result-store SHA match the appropriate existing
baselines: 21,393 / 29,776 / 31,862 / 51,300 fills. Result and request evidence,
physical manifests and all 3,506 fact blocks were verified. No earlier failed
or successful receipt was rewritten. Complete receipts remain on 177; only the
compact verified summary is stored here to avoid duplicating input inventories.
## Remaining Work
Do not publish this as the main performance fix. Next, target the remaining
daily snapshot/factor construction and direct typed-column reuse, avoiding
new per-access branches or post-hoc compression passes. Cold-query acceptance,
real minute-mode acceptance remain outstanding for this storage change. The
combined version subsequently passed daily HTTP publication below. The original
clock issue was fixed by subsequent business
work, not this experiment. Signal lifecycle, financial PIT and UI factor conditions
remain outside this completed storage experiment.
- Engine candidate: 996b909608589fb1987f33c0cfb4c62099f69617.
- Service source: 443ed421c2c9c854a01fab69ce58957690504570.
- Candidate runner: 40bcf65c1977dbd93ab8bc80e3ff04d0db5e27b61fce1afdce99cf1b5e58eb43.
- Candidate API: c54be3a8196c32051520c709f793bcb974d869467bb12700d846efaad8c2180e.
- Evidence: /srv/fidc/canonical/run/research/series-column-storage-20260914.
[Verified summary](evidence/series-column-storage-20260914/acceptance.json).
## Combined Release
Engine 665653c / service 501f6d0 passed 860 core, 448 runner and 119 API tests,
six additional independent-process replays and nine post-publication HTTP runs.
Complete canonical/store results remain equal to each strategy's own baseline.
An adjacent original/new rotation pair measures 15.279/14.579 seconds and
6,923,640/6,369,900 KiB peak RSS, but the default-window HTTP means are essentially
unchanged (13.646/13.740 seconds). Reduced memory and conditional cross-window
reuse are not promoted to a universal latency improvement.
The official Backtest-only publication preserves Source d5, paused research,
trading services and execution permissions. Its active build root
/srv/fidc/canonical/build/factor-reserve-20260913 must not be overwritten or
reused. Shared DataSet acceptance proves input reuse while distinct strategies
execute independently; no results are cached. Identities and original receipts:
`/Users/boris/WorkSpace/fidc-backtest-service/docs/cache-boundary-planning-performance-20260914.md`.