Add target-shares parity and rqalpha roadmap
This commit is contained in:
@@ -576,6 +576,42 @@ where
|
||||
commission_state,
|
||||
report,
|
||||
),
|
||||
OrderIntent::TargetShares {
|
||||
symbol,
|
||||
target_quantity,
|
||||
reason,
|
||||
} => self.process_target_shares(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
*target_quantity,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
report,
|
||||
),
|
||||
OrderIntent::LimitTargetShares {
|
||||
symbol,
|
||||
target_quantity,
|
||||
limit_price,
|
||||
reason,
|
||||
} => self.process_limit_target_shares(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
*target_quantity,
|
||||
*limit_price,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
report,
|
||||
),
|
||||
OrderIntent::TargetValue {
|
||||
symbol,
|
||||
target_value,
|
||||
@@ -2153,6 +2189,101 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_target_shares(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
symbol: &str,
|
||||
target_quantity: i32,
|
||||
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 current_qty = portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.quantity)
|
||||
.unwrap_or(0);
|
||||
let target_qty = target_quantity.max(0) as u32;
|
||||
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
|
||||
let order_step_size = self.order_step_size(data, symbol);
|
||||
|
||||
if current_qty > target_qty {
|
||||
let raw_sell_qty = current_qty - target_qty;
|
||||
let sell_qty = if target_qty == 0 {
|
||||
current_qty
|
||||
} else {
|
||||
self.round_buy_quantity(raw_sell_qty, minimum_order_quantity, order_step_size)
|
||||
.min(current_qty)
|
||||
};
|
||||
if sell_qty > 0 {
|
||||
self.process_sell(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
sell_qty,
|
||||
self.reserve_order_id(),
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
report,
|
||||
)?;
|
||||
}
|
||||
} else if target_qty > current_qty {
|
||||
let buy_qty = self.round_buy_quantity(
|
||||
target_qty - current_qty,
|
||||
minimum_order_quantity,
|
||||
order_step_size,
|
||||
);
|
||||
if buy_qty > 0 {
|
||||
self.process_buy(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
buy_qty,
|
||||
self.reserve_order_id(),
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
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 shares"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_limit_target_value(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -2228,6 +2359,87 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_limit_target_shares(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
symbol: &str,
|
||||
target_quantity: i32,
|
||||
limit_price: f64,
|
||||
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 current_qty = portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.quantity)
|
||||
.unwrap_or(0);
|
||||
let target_qty = target_quantity.max(0) as u32;
|
||||
let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
|
||||
let order_step_size = self.order_step_size(data, symbol);
|
||||
|
||||
if current_qty > target_qty {
|
||||
let raw_sell_qty = current_qty - target_qty;
|
||||
let sell_qty = if target_qty == 0 {
|
||||
current_qty
|
||||
} else {
|
||||
self.round_buy_quantity(raw_sell_qty, minimum_order_quantity, order_step_size)
|
||||
.min(current_qty)
|
||||
};
|
||||
if sell_qty > 0 {
|
||||
self.process_sell(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
sell_qty,
|
||||
self.reserve_order_id(),
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
Some(limit_price),
|
||||
true,
|
||||
true,
|
||||
report,
|
||||
)?;
|
||||
}
|
||||
} else if target_qty > current_qty {
|
||||
let buy_qty = self.round_buy_quantity(
|
||||
target_qty - current_qty,
|
||||
minimum_order_quantity,
|
||||
order_step_size,
|
||||
);
|
||||
if buy_qty > 0 {
|
||||
self.process_buy(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
buy_qty,
|
||||
self.reserve_order_id(),
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
None,
|
||||
Some(limit_price),
|
||||
true,
|
||||
true,
|
||||
report,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_target_percent(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
|
||||
@@ -79,6 +79,8 @@ pub enum PlatformExplicitOrderKind {
|
||||
LimitShares,
|
||||
Lots,
|
||||
LimitLots,
|
||||
TargetShares,
|
||||
LimitTargetShares,
|
||||
Value,
|
||||
LimitValue,
|
||||
Percent,
|
||||
@@ -2261,6 +2263,32 @@ impl PlatformExprStrategy {
|
||||
reason: reason.clone(),
|
||||
});
|
||||
}
|
||||
PlatformExplicitOrderKind::TargetShares => {
|
||||
let target_quantity =
|
||||
self.eval_i32(ctx, amount_expr, day, stock_state.as_ref(), None)?;
|
||||
intents.push(OrderIntent::TargetShares {
|
||||
symbol: symbol.clone(),
|
||||
target_quantity,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
}
|
||||
PlatformExplicitOrderKind::LimitTargetShares => {
|
||||
let target_quantity =
|
||||
self.eval_i32(ctx, amount_expr, day, stock_state.as_ref(), None)?;
|
||||
let limit_price = self.eval_float(
|
||||
ctx,
|
||||
limit_price_expr.as_deref().unwrap_or_default(),
|
||||
day,
|
||||
stock_state.as_ref(),
|
||||
None,
|
||||
)?;
|
||||
intents.push(OrderIntent::LimitTargetShares {
|
||||
symbol: symbol.clone(),
|
||||
target_quantity,
|
||||
limit_price,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
}
|
||||
PlatformExplicitOrderKind::Value => {
|
||||
let value =
|
||||
self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?;
|
||||
@@ -3250,6 +3278,116 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_strategy_emits_target_shares_explicit_action() {
|
||||
let date = d(2025, 2, 3);
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "Ping An Bank".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2025-02-03 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.4,
|
||||
low: 9.8,
|
||||
close: 10.2,
|
||||
last_price: 10.2,
|
||||
bid1: 10.18,
|
||||
ask1: 10.22,
|
||||
prev_close: 9.9,
|
||||
volume: 100_000,
|
||||
tick_volume: 5_000,
|
||||
bid1_volume: 2_500,
|
||||
ask1_volume: 2_500,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 10.89,
|
||||
lower_limit: 8.91,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 50.0,
|
||||
free_float_cap_bn: 45.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.2),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_kcb: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_one_yuan: false,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1001.0,
|
||||
prev_close: 999.0,
|
||||
volume: 100_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
let ctx = StrategyContext {
|
||||
execution_date: date,
|
||||
decision_date: date,
|
||||
decision_index: 0,
|
||||
data: &data,
|
||||
portfolio: &portfolio,
|
||||
open_orders: &[],
|
||||
process_events: &[],
|
||||
active_process_event: None,
|
||||
};
|
||||
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
|
||||
cfg.signal_symbol = "000001.SZ".to_string();
|
||||
cfg.rotation_enabled = false;
|
||||
cfg.benchmark_short_ma_days = 1;
|
||||
cfg.benchmark_long_ma_days = 1;
|
||||
cfg.explicit_actions = vec![PlatformTradeAction::Order {
|
||||
kind: PlatformExplicitOrderKind::TargetShares,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
amount_expr: "2000".to_string(),
|
||||
limit_price_expr: None,
|
||||
when_expr: Some("allow_buy".to_string()),
|
||||
reason: "platform_target_shares".to_string(),
|
||||
}];
|
||||
let mut strategy = PlatformExprStrategy::new(cfg);
|
||||
|
||||
let decision = strategy.on_day(&ctx).expect("platform decision");
|
||||
|
||||
assert_eq!(decision.order_intents.len(), 1);
|
||||
match &decision.order_intents[0] {
|
||||
crate::strategy::OrderIntent::TargetShares {
|
||||
symbol,
|
||||
target_quantity,
|
||||
reason,
|
||||
} => {
|
||||
assert_eq!(symbol, "000001.SZ");
|
||||
assert_eq!(*target_quantity, 2000);
|
||||
assert_eq!(reason, "platform_target_shares");
|
||||
}
|
||||
other => panic!("unexpected explicit target shares intent: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_strategy_emits_explicit_actions_in_open_auction_stage() {
|
||||
let date = d(2025, 2, 3);
|
||||
|
||||
@@ -309,6 +309,17 @@ pub enum OrderIntent {
|
||||
limit_price: f64,
|
||||
reason: String,
|
||||
},
|
||||
TargetShares {
|
||||
symbol: String,
|
||||
target_quantity: i32,
|
||||
reason: String,
|
||||
},
|
||||
LimitTargetShares {
|
||||
symbol: String,
|
||||
target_quantity: i32,
|
||||
limit_price: f64,
|
||||
reason: String,
|
||||
},
|
||||
TargetValue {
|
||||
symbol: String,
|
||||
target_value: f64,
|
||||
|
||||
@@ -120,7 +120,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
},
|
||||
ManualSection {
|
||||
title: "trading.rotation / order.* / cancel.*".to_string(),
|
||||
detail: "支持显式下单和撤单。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily() / trading.schedule.weekly(weekday=5) / trading.schedule.weekly(tradingday=-1) / trading.schedule.monthly(tradingday=1) 指定触发频率,然后写 order.shares(\"600000.SH\", 1000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()。symbol 使用标准证券代码;数量、金额、仓位、限价和 order_id 都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||
detail: "支持显式下单和撤单。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily() / trading.schedule.weekly(weekday=5) / trading.schedule.weekly(tradingday=-1) / trading.schedule.monthly(tradingday=1) 指定触发频率,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()。其中 order.target_shares(...) 对应 rqalpha 的 order_to 语义。symbol 使用标准证券代码;数量、金额、仓位、限价和 order_id 都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "when / unless / else".to_string(),
|
||||
|
||||
@@ -219,6 +219,110 @@ fn broker_executes_order_shares_and_order_lots() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_executes_target_shares_like_order_to() {
|
||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
name: "Test".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: Some("2024-01-10 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.1,
|
||||
low: 9.9,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 9.99,
|
||||
ask1: 10.01,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
bid1_volume: 80_000,
|
||||
ask1_volume: 80_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
market_cap_bn: 50.0,
|
||||
free_float_cap_bn: 45.0,
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_kcb: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_one_yuan: false,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1000.0,
|
||||
close: 1002.0,
|
||||
prev_close: 998.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||
portfolio
|
||||
.position_mut("000002.SZ")
|
||||
.buy(date.pred_opt().expect("previous day"), 300, 9.0);
|
||||
|
||||
let broker = BrokerSimulator::new(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
);
|
||||
|
||||
let report = broker
|
||||
.execute(
|
||||
date,
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: vec![OrderIntent::TargetShares {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
target_quantity: 150,
|
||||
reason: "test_target_shares".to_string(),
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("broker execution");
|
||||
|
||||
assert_eq!(portfolio.position("000002.SZ").map(|pos| pos.quantity), Some(200));
|
||||
assert_eq!(report.fill_events.len(), 1);
|
||||
assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Sell);
|
||||
assert_eq!(report.fill_events[0].quantity, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_executes_order_percent_and_target_percent() {
|
||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user