Compare commits
13 Commits
v2026.9.14.5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 232e9ae154 | |||
| f8955bfb18 | |||
| fb8192a286 | |||
| 7f0c6a008a | |||
| 93de28d369 | |||
| 665653c3fe | |||
| a29c434be9 | |||
| 4c96d0c31f | |||
| 5e11f3da22 | |||
| 8e7ae69b0b | |||
| 9a54156df9 | |||
| 996b909608 | |||
| c62ae1206f |
@@ -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
@@ -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(
|
||||||
|
|||||||
+879
-213
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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::{
|
||||||
|
|||||||
@@ -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('-'));
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
strategy_exposure,
|
.position_exposure_timeline
|
||||||
)
|
.exposure_at(
|
||||||
.unwrap_or(strategy_exposure)
|
portfolio_loss_decision_at(ctx),
|
||||||
.clamp(0.0, 1.0);
|
ctx.execution_date,
|
||||||
let mut exposure = risk_on_exposure;
|
&self.config.position_exposure_schedule,
|
||||||
|
strategy_exposure,
|
||||||
|
)
|
||||||
|
.unwrap_or(strategy_exposure)
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
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 [
|
||||||
let before=intents.len();
|
self.config
|
||||||
intents=intents.into_iter().map(|intent|crate::position_exposure::scale_explicit_intent(intent,bps,ctx.open_orders))
|
.position_exposure_timeline
|
||||||
.collect::<Result<Vec<_>,_>>().map_err(BacktestError::Execution)?.into_iter().flatten().collect();
|
.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();
|
||||||
|
intents = intents
|
||||||
|
.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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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。本轮已配套发布177,annotated 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."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.6,Source 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`。
|
||||||
@@ -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_tax;Live事实的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`.
|
||||||
Reference in New Issue
Block a user