修正FiRisk强平执行时间口径

This commit is contained in:
boris
2026-06-20 22:46:43 +08:00
parent 816fc48077
commit cdca7984ed
4 changed files with 262 additions and 5 deletions
+217
View File
@@ -908,6 +908,29 @@ where
commission_state, commission_state,
report, report,
), ),
OrderIntent::TimedTargetValue {
symbol,
target_value,
style,
start_time,
end_time,
reason,
} => self.process_timed_target_value(
date,
portfolio,
data,
symbol,
*target_value,
*style,
*start_time,
*end_time,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
),
OrderIntent::LimitTargetValue { OrderIntent::LimitTargetValue {
symbol, symbol,
target_value, target_value,
@@ -3011,6 +3034,118 @@ where
Ok(()) Ok(())
} }
#[allow(clippy::too_many_arguments)]
fn process_timed_target_value(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
target_value: f64,
style: AlgoOrderStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
reason: &str,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let snapshot = data
.market(date, symbol)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.to_string(),
field: price_field_name(self.execution_price_field),
})?;
let current_qty = portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let algo_request = AlgoExecutionRequest {
style: match style {
AlgoOrderStyle::Vwap => AlgoExecutionStyle::Vwap,
AlgoOrderStyle::Twap => AlgoExecutionStyle::Twap,
},
start_time,
end_time,
};
if target_value <= f64::EPSILON {
if current_qty == 0 {
report.order_events.push(OrderEvent {
date,
order_id: None,
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: 0,
filled_quantity: 0,
status: OrderStatus::Filled,
reason: format!("{reason}: already at target value"),
});
return Ok(());
}
self.process_sell(
date,
portfolio,
data,
symbol,
current_qty,
self.reserve_order_id(),
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
None,
false,
true,
Some(&algo_request),
report,
)?;
return Ok(());
}
let valuation_price = self.target_value_valuation_price(date, data, symbol, snapshot);
let current_value = valuation_price * current_qty as f64;
let cash_delta = target_value.max(0.0) - current_value;
if cash_delta.abs() > f64::EPSILON {
self.process_algo_value(
date,
portfolio,
data,
symbol,
cash_delta,
style,
start_time,
end_time,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)?;
} else {
report.order_events.push(OrderEvent {
date,
order_id: None,
symbol: symbol.to_string(),
side: if current_qty > 0 {
OrderSide::Sell
} else {
OrderSide::Buy
},
requested_quantity: 0,
filled_quantity: 0,
status: OrderStatus::Filled,
reason: format!("{reason}: already at target value"),
});
}
Ok(())
}
fn process_target_shares( fn process_target_shares(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -5265,6 +5400,7 @@ mod tests {
use crate::instrument::Instrument; use crate::instrument::Instrument;
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
use crate::rules::ChinaEquityRuleHooks; use crate::rules::ChinaEquityRuleHooks;
use crate::strategy::AlgoOrderStyle;
fn limit_test_snapshot() -> DailyMarketSnapshot { fn limit_test_snapshot() -> DailyMarketSnapshot {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
@@ -5410,6 +5546,87 @@ mod tests {
); );
} }
#[test]
fn timed_target_value_zero_sells_full_position_at_requested_time_quote() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date");
let symbol = "000001.SZ";
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_intraday_execution_start_time(date.and_hms_opt(10, 18, 0).unwrap().time())
.with_matching_type(MatchingType::NextTickLast)
.with_slippage_model(SlippageModel::PriceRatio(0.001))
.with_volume_limit(false)
.with_liquidity_limit(false);
let mut snapshot = limit_test_snapshot();
snapshot.symbol = symbol.to_string();
snapshot.last_price = 4.21;
snapshot.close = 4.21;
snapshot.bid1 = 4.20;
snapshot.ask1 = 4.22;
snapshot.prev_close = 4.15;
snapshot.upper_limit = 4.98;
snapshot.lower_limit = 3.32;
let mut quote_0931 = limit_test_quote(4.11, 4.10, 4.12);
quote_0931.symbol = symbol.to_string();
quote_0931.timestamp = date.and_hms_opt(9, 31, 0).expect("valid timestamp");
let mut quote_1018 = limit_test_quote(4.21, 4.20, 4.22);
quote_1018.symbol = symbol.to_string();
quote_1018.timestamp = date.and_hms_opt(10, 18, 0).expect("valid timestamp");
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![snapshot],
Vec::new(),
vec![limit_test_candidate(true, true)],
vec![limit_test_benchmark()],
Vec::new(),
vec![quote_0931, quote_1018],
)
.expect("valid dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio.position_mut(symbol).buy(prev_date, 72_600, 4.0);
portfolio.apply_cash_delta(-290_400.0);
let mut report = BrokerExecutionReport::default();
broker
.process_timed_target_value(
date,
&mut portfolio,
&data,
symbol,
0.0,
AlgoOrderStyle::Twap,
Some(date.and_hms_opt(9, 31, 0).unwrap().time()),
Some(date.and_hms_opt(9, 31, 0).unwrap().time()),
"risk_forced_exit",
&mut BTreeMap::new(),
&mut BTreeMap::new(),
&mut None,
&mut BTreeMap::new(),
&mut report,
)
.expect("timed target value");
assert!(
!report.fill_events.is_empty(),
"order_events={:?}",
report.order_events
);
let fill = report.fill_events.last().expect("fill event");
assert_eq!(fill.quantity, 72_600);
assert!((fill.price - 4.10589).abs() < 1e-6, "{fill:?}");
assert_eq!(
portfolio
.position(symbol)
.map(|position| position.quantity)
.unwrap_or(0),
0
);
}
#[test] #[test]
fn aiquant_target_value_delta_uses_scheduled_mark_price() { fn aiquant_target_value_delta_uses_scheduled_mark_price() {
let date = chrono::NaiveDate::from_ymd_opt(2023, 5, 8).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2023, 5, 8).expect("valid date");
+8
View File
@@ -3354,6 +3354,7 @@ fn decision_has_algo_execution(decision: &StrategyDecision) -> bool {
intent, intent,
OrderIntent::AlgoValue { .. } OrderIntent::AlgoValue { .. }
| OrderIntent::AlgoPercent { .. } | OrderIntent::AlgoPercent { .. }
| OrderIntent::TimedTargetValue { .. }
| OrderIntent::TargetPortfolioSmart { | OrderIntent::TargetPortfolioSmart {
order_prices: Some(TargetPortfolioOrderPricing::AlgoOrder { .. }), order_prices: Some(TargetPortfolioOrderPricing::AlgoOrder { .. }),
.. ..
@@ -3386,6 +3387,7 @@ fn execution_quote_symbols_for_decision(
| OrderIntent::TargetShares { symbol, .. } | OrderIntent::TargetShares { symbol, .. }
| OrderIntent::LimitTargetShares { symbol, .. } | OrderIntent::LimitTargetShares { symbol, .. }
| OrderIntent::TargetValue { symbol, .. } | OrderIntent::TargetValue { symbol, .. }
| OrderIntent::TimedTargetValue { symbol, .. }
| OrderIntent::LimitTargetValue { symbol, .. } | OrderIntent::LimitTargetValue { symbol, .. }
| OrderIntent::Value { symbol, .. } | OrderIntent::Value { symbol, .. }
| OrderIntent::LimitValue { symbol, .. } | OrderIntent::LimitValue { symbol, .. }
@@ -3438,6 +3440,12 @@ fn algo_execution_quote_windows_for_decision(
start_time, start_time,
end_time, end_time,
.. ..
}
| OrderIntent::TimedTargetValue {
symbol,
start_time,
end_time,
..
} => { } => {
if start_time.is_some() || end_time.is_some() { if start_time.is_some() || end_time.is_some() {
groups groups
+29 -5
View File
@@ -907,6 +907,15 @@ impl PlatformExprStrategy {
.unwrap_or_else(|| NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18")) .unwrap_or_else(|| NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18"))
} }
fn firisk_forced_exit_time(&self) -> NaiveTime {
if self.config.delayed_limit_open_exit_enabled
&& let Some(time) = self.config.delayed_limit_open_exit_time
{
return time;
}
self.intraday_execution_start_time()
}
fn buy_commission(&self, gross_amount: f64) -> f64 { fn buy_commission(&self, gross_amount: f64) -> f64 {
self.cost_model().commission_for(gross_amount) self.cost_model().commission_for(gross_amount)
} }
@@ -6834,6 +6843,7 @@ impl Strategy for PlatformExprStrategy {
.cloned() .cloned()
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
let mut pending_full_close_symbols = BTreeSet::<String>::new(); let mut pending_full_close_symbols = BTreeSet::<String>::new();
let firisk_forced_exit_time = self.firisk_forced_exit_time();
if self.config.aiquant_transaction_cost { if self.config.aiquant_transaction_cost {
for position in ctx.portfolio.positions().values() { for position in ctx.portfolio.positions().values() {
if position.quantity == 0 if position.quantity == 0
@@ -6854,7 +6864,7 @@ impl Strategy for PlatformExprStrategy {
ctx, ctx,
execution_date, execution_date,
&position.symbol, &position.symbol,
self.intraday_execution_start_time(), firisk_forced_exit_time,
)? { )? {
pending_full_close_symbols.insert(position.symbol.clone()); pending_full_close_symbols.insert(position.symbol.clone());
pending_firisk_forced_exit_symbols.insert(position.symbol.clone()); pending_firisk_forced_exit_symbols.insert(position.symbol.clone());
@@ -6899,18 +6909,22 @@ impl Strategy for PlatformExprStrategy {
continue; continue;
} }
exit_symbols.insert(symbol.clone()); exit_symbols.insert(symbol.clone());
order_intents.push(OrderIntent::TargetValue { order_intents.push(OrderIntent::TimedTargetValue {
symbol: symbol.clone(), symbol: symbol.clone(),
target_value: 0.0, target_value: 0.0,
style: AlgoOrderStyle::Twap,
start_time: Some(firisk_forced_exit_time),
end_time: Some(firisk_forced_exit_time),
reason: "risk_forced_exit".to_string(), reason: "risk_forced_exit".to_string(),
}); });
if self if self
.project_target_zero( .project_target_zero_at_time(
ctx, ctx,
&mut projected, &mut projected,
execution_date, execution_date,
&symbol, &symbol,
&mut projected_execution_state, &mut projected_execution_state,
Some(firisk_forced_exit_time),
) )
.is_some() .is_some()
&& Self::projected_position_is_flat(&projected, &symbol) && Self::projected_position_is_flat(&projected, &symbol)
@@ -10951,17 +10965,27 @@ mod tests {
cfg.stock_filter_expr = "true".to_string(); cfg.stock_filter_expr = "true".to_string();
cfg.stop_loss_expr.clear(); cfg.stop_loss_expr.clear();
cfg.take_profit_expr.clear(); cfg.take_profit_expr.clear();
cfg.delayed_limit_open_exit_enabled = true;
cfg.delayed_limit_open_exit_time = Some(NaiveTime::from_hms_opt(9, 31, 0).expect("time"));
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 18, 0).expect("time"));
let mut strategy = PlatformExprStrategy::new(cfg); let mut strategy = PlatformExprStrategy::new(cfg);
let decision = strategy.on_day(&ctx).expect("platform decision"); let decision = strategy.on_day(&ctx).expect("platform decision");
assert!(decision.order_intents.iter().any(|intent| matches!( assert!(decision.order_intents.iter().any(|intent| matches!(
intent, intent,
OrderIntent::TargetValue { OrderIntent::TimedTargetValue {
symbol: intent_symbol, symbol: intent_symbol,
target_value, target_value,
start_time,
end_time,
reason, reason,
} if intent_symbol == symbol && *target_value == 0.0 && reason == "risk_forced_exit" ..
} if intent_symbol == symbol
&& *target_value == 0.0
&& *start_time == Some(NaiveTime::from_hms_opt(9, 31, 0).expect("time"))
&& *end_time == Some(NaiveTime::from_hms_opt(9, 31, 0).expect("time"))
&& reason == "risk_forced_exit"
))); )));
assert!(!decision.order_intents.iter().any(|intent| matches!( assert!(!decision.order_intents.iter().any(|intent| matches!(
intent, intent,
+8
View File
@@ -989,6 +989,14 @@ pub enum OrderIntent {
target_value: f64, target_value: f64,
reason: String, reason: String,
}, },
TimedTargetValue {
symbol: String,
target_value: f64,
style: AlgoOrderStyle,
start_time: Option<NaiveTime>,
end_time: Option<NaiveTime>,
reason: String,
},
LimitTargetValue { LimitTargetValue {
symbol: String, symbol: String,
target_value: f64, target_value: f64,