修正FiRisk强平执行时间口径
This commit is contained in:
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user