Improve jq microcap execution semantics
This commit is contained in:
@@ -2,12 +2,8 @@ use chrono::NaiveDate;
|
||||
use fidc_core::cost::CostModel;
|
||||
use fidc_core::rules::EquityRuleHooks;
|
||||
use fidc_core::{
|
||||
CandidateEligibility,
|
||||
ChinaAShareCostModel,
|
||||
ChinaEquityRuleHooks,
|
||||
DailyMarketSnapshot,
|
||||
OrderSide,
|
||||
Position,
|
||||
CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot,
|
||||
OrderSide, Position, PriceField,
|
||||
};
|
||||
|
||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
@@ -32,15 +28,25 @@ fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapsho
|
||||
DailyMarketSnapshot {
|
||||
date: d(2024, 1, 3),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2024-01-03 10:18:00".to_string()),
|
||||
day_open: open,
|
||||
open,
|
||||
high: open,
|
||||
low: open,
|
||||
close: open,
|
||||
last_price: open,
|
||||
bid1: open,
|
||||
ask1: open,
|
||||
prev_close: 10.0,
|
||||
volume: 1_000_000,
|
||||
tick_volume: 100_000,
|
||||
bid1_volume: 50_000,
|
||||
ask1_volume: 50_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit,
|
||||
lower_limit,
|
||||
price_tick: 0.01,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,14 +75,11 @@ fn china_rule_hooks_block_same_day_sell_under_t_plus_one() {
|
||||
&snapshot(10.1, 11.0, 9.0),
|
||||
&candidate(),
|
||||
&position,
|
||||
PriceField::Open,
|
||||
);
|
||||
|
||||
assert!(!check.allowed);
|
||||
assert!(check
|
||||
.reason
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("t+1"));
|
||||
assert!(check.reason.as_deref().unwrap_or_default().contains("t+1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -86,20 +89,62 @@ fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() {
|
||||
let mut position = Position::new("000001.SZ");
|
||||
position.buy(d(2024, 1, 2), 1_000, 10.0);
|
||||
|
||||
let buy_check = hooks.can_buy(d(2024, 1, 3), &snapshot(11.0, 11.0, 9.0), &candidate);
|
||||
let buy_check = hooks.can_buy(
|
||||
d(2024, 1, 3),
|
||||
&snapshot(11.0, 11.0, 9.0),
|
||||
&candidate,
|
||||
PriceField::Open,
|
||||
);
|
||||
assert!(!buy_check.allowed);
|
||||
assert!(buy_check
|
||||
.reason
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("upper limit"));
|
||||
assert!(
|
||||
buy_check
|
||||
.reason
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("upper limit")
|
||||
);
|
||||
|
||||
let sell_check =
|
||||
hooks.can_sell(d(2024, 1, 3), &snapshot(9.0, 11.0, 9.0), &candidate, &position);
|
||||
let sell_check = hooks.can_sell(
|
||||
d(2024, 1, 3),
|
||||
&snapshot(9.0, 11.0, 9.0),
|
||||
&candidate,
|
||||
&position,
|
||||
PriceField::Open,
|
||||
);
|
||||
assert!(!sell_check.allowed);
|
||||
assert!(
|
||||
sell_check
|
||||
.reason
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("lower limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn china_rule_hooks_use_tick_size_tolerance_for_price_limits() {
|
||||
let hooks = ChinaEquityRuleHooks;
|
||||
let candidate = candidate();
|
||||
|
||||
let near_upper = DailyMarketSnapshot {
|
||||
price_tick: 0.001,
|
||||
..snapshot(10.9995, 11.0, 9.0)
|
||||
};
|
||||
let buy_check = hooks.can_buy(d(2024, 1, 3), &near_upper, &candidate, PriceField::Open);
|
||||
assert!(!buy_check.allowed);
|
||||
|
||||
let near_lower = DailyMarketSnapshot {
|
||||
price_tick: 0.001,
|
||||
..snapshot(9.0005, 11.0, 9.0)
|
||||
};
|
||||
let mut position = Position::new("000001.SZ");
|
||||
position.buy(d(2024, 1, 2), 1_000, 10.0);
|
||||
let sell_check = hooks.can_sell(
|
||||
d(2024, 1, 3),
|
||||
&near_lower,
|
||||
&candidate,
|
||||
&position,
|
||||
PriceField::Open,
|
||||
);
|
||||
assert!(!sell_check.allowed);
|
||||
assert!(sell_check
|
||||
.reason
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("lower limit"));
|
||||
}
|
||||
|
||||
54
crates/fidc-core/tests/corporate_actions.rs
Normal file
54
crates/fidc-core/tests/corporate_actions.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use chrono::NaiveDate;
|
||||
use fidc_core::{CashReceivable, PortfolioState, Position};
|
||||
|
||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_dividend_adjusts_cost_basis_and_returns_cash_delta() {
|
||||
let mut position = Position::new("000001.SZ");
|
||||
position.buy(d(2025, 1, 2), 1_000, 10.0);
|
||||
|
||||
let cash_delta = position.apply_cash_dividend(0.5);
|
||||
|
||||
assert!((cash_delta - 500.0).abs() < 1e-9);
|
||||
assert_eq!(position.quantity, 1_000);
|
||||
assert!((position.average_cost - 9.5).abs() < 1e-9);
|
||||
assert!((position.last_price - 9.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_split_scales_lots_quantity_and_average_cost() {
|
||||
let mut position = Position::new("000001.SZ");
|
||||
position.buy(d(2025, 1, 2), 1_000, 10.0);
|
||||
|
||||
let delta_quantity = position.apply_split_ratio(1.2);
|
||||
|
||||
assert_eq!(delta_quantity, 200);
|
||||
assert_eq!(position.quantity, 1_200);
|
||||
assert!((position.average_cost - (10.0 / 1.2)).abs() < 1e-9);
|
||||
assert!((position.last_price - (10.0 / 1.2)).abs() < 1e-9);
|
||||
assert_eq!(position.sellable_qty(d(2025, 1, 3)), 1_200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portfolio_settles_cash_receivable_on_payable_date() {
|
||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||
portfolio.add_cash_receivable(CashReceivable {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
ex_date: d(2025, 1, 2),
|
||||
payable_date: d(2025, 1, 5),
|
||||
amount: 500.0,
|
||||
reason: "cash_dividend 0.5".to_string(),
|
||||
});
|
||||
|
||||
let settled_early = portfolio.settle_cash_receivables(d(2025, 1, 4));
|
||||
assert!(settled_early.is_empty());
|
||||
assert!((portfolio.cash() - 1_000_000.0).abs() < 1e-9);
|
||||
|
||||
let settled = portfolio.settle_cash_receivables(d(2025, 1, 5));
|
||||
assert_eq!(settled.len(), 1);
|
||||
assert!((portfolio.cash() - 1_000_500.0).abs() < 1e-9);
|
||||
assert!(portfolio.cash_receivables().is_empty());
|
||||
}
|
||||
249
crates/fidc-core/tests/delisting.rs
Normal file
249
crates/fidc-core/tests/delisting.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
use chrono::NaiveDate;
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||
Instrument, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
||||
}
|
||||
|
||||
struct BuyThenHoldStrategy;
|
||||
|
||||
impl Strategy for BuyThenHoldStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"buy-then-hold"
|
||||
}
|
||||
|
||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
|
||||
return Ok(StrategyDecision {
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: vec![OrderIntent::Value {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
value: 10_000.0,
|
||||
reason: "seed_position".to_string(),
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(StrategyDecision::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() {
|
||||
let date1 = d(2025, 1, 2);
|
||||
let date2 = d(2025, 1, 3);
|
||||
let data = DataSet::from_components(
|
||||
vec![
|
||||
Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "Delisted".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: Some(date1),
|
||||
status: "delisted".to_string(),
|
||||
},
|
||||
Instrument {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
name: "Anchor".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: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.0,
|
||||
low: 10.0,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: Some("2025-01-02 10:18:00".to_string()),
|
||||
day_open: 5.0,
|
||||
open: 5.0,
|
||||
high: 5.1,
|
||||
low: 4.9,
|
||||
close: 5.0,
|
||||
last_price: 5.0,
|
||||
bid1: 4.99,
|
||||
ask1: 5.01,
|
||||
prev_close: 5.0,
|
||||
volume: 100_000,
|
||||
tick_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 5.5,
|
||||
lower_limit: 4.5,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
DailyMarketSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: Some("2025-01-03 10:18:00".to_string()),
|
||||
day_open: 5.1,
|
||||
open: 5.1,
|
||||
high: 5.2,
|
||||
low: 5.0,
|
||||
close: 5.1,
|
||||
last_price: 5.1,
|
||||
bid1: 5.09,
|
||||
ask1: 5.11,
|
||||
prev_close: 5.0,
|
||||
volume: 120_000,
|
||||
tick_volume: 120_000,
|
||||
bid1_volume: 120_000,
|
||||
ask1_volume: 120_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 5.5,
|
||||
lower_limit: 4.5,
|
||||
price_tick: 0.01,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
market_cap_bn: 20.0,
|
||||
free_float_cap_bn: 18.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
market_cap_bn: 30.0,
|
||||
free_float_cap_bn: 28.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
market_cap_bn: 31.0,
|
||||
free_float_cap_bn: 29.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date1,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
},
|
||||
CandidateEligibility {
|
||||
date: date2,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
BenchmarkSnapshot {
|
||||
date: date1,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
},
|
||||
BenchmarkSnapshot {
|
||||
date: date2,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 101.0,
|
||||
close: 101.0,
|
||||
prev_close: 100.0,
|
||||
volume: 1_100_000,
|
||||
},
|
||||
],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
BuyThenHoldStrategy,
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 100_000.0,
|
||||
benchmark_code: "000300.SH".to_string(),
|
||||
start_date: Some(date1),
|
||||
end_date: Some(date2),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
);
|
||||
|
||||
let result = engine.run().expect("backtest succeeds");
|
||||
assert_eq!(result.fills.len(), 2);
|
||||
assert!(result
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| fill.reason.contains("delisted_cash_settlement") && fill.symbol == "000001.SZ"));
|
||||
assert!(result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.all(|holding| holding.symbol != "000001.SZ"));
|
||||
}
|
||||
338
crates/fidc-core/tests/explicit_order_flow.rs
Normal file
338
crates/fidc-core/tests/explicit_order_flow.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
use chrono::NaiveDate;
|
||||
use fidc_core::{
|
||||
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
|
||||
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument,
|
||||
OrderIntent, PortfolioState, PriceField, StrategyDecision,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[test]
|
||||
fn broker_executes_explicit_order_value_buy() {
|
||||
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),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
|
||||
let report = broker
|
||||
.execute(
|
||||
date,
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: vec![OrderIntent::Value {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
value: 100_000.0,
|
||||
reason: "test_order_value".to_string(),
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("broker execution");
|
||||
|
||||
assert!(!report.fill_events.is_empty());
|
||||
assert_eq!(report.fill_events[0].symbol, "000002.SZ");
|
||||
assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Buy);
|
||||
assert!(portfolio.position("000002.SZ").is_some());
|
||||
assert!(portfolio.cash() < 1_000_000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_uses_instrument_round_lot_for_buy_sizing() {
|
||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: "688001.SH".to_string(),
|
||||
name: "KSH".to_string(),
|
||||
board: "KSH".to_string(),
|
||||
round_lot: 200,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: "688001.SH".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: "688001.SH".to_string(),
|
||||
market_cap_bn: 50.0,
|
||||
free_float_cap_bn: 45.0,
|
||||
pe_ttm: 20.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: "688001.SH".to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
let mut portfolio = PortfolioState::new(10_500.0);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
|
||||
let report = broker
|
||||
.execute(
|
||||
date,
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: vec![OrderIntent::Value {
|
||||
symbol: "688001.SH".to_string(),
|
||||
value: 10_500.0,
|
||||
reason: "round_lot".to_string(),
|
||||
}],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("broker execution");
|
||||
|
||||
assert_eq!(report.fill_events.len(), 1);
|
||||
assert_eq!(report.fill_events[0].quantity, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_day_sell_then_rebuy_reinserts_position_at_end() {
|
||||
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
|
||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||
let instruments = symbols
|
||||
.iter()
|
||||
.map(|symbol| Instrument {
|
||||
symbol: (*symbol).to_string(),
|
||||
name: (*symbol).to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let market = symbols
|
||||
.iter()
|
||||
.map(|symbol| DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: (*symbol).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,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let factors = symbols
|
||||
.iter()
|
||||
.map(|symbol| DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: (*symbol).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),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let candidates = symbols
|
||||
.iter()
|
||||
.map(|symbol| CandidateEligibility {
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let data = DataSet::from_components(
|
||||
instruments,
|
||||
market,
|
||||
factors,
|
||||
candidates,
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000300.SH".to_string(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 99.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("dataset");
|
||||
|
||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||
portfolio.position_mut("000001.SZ").buy(prev_date, 100, 10.0);
|
||||
portfolio.position_mut("000002.SZ").buy(prev_date, 100, 10.0);
|
||||
portfolio.position_mut("000003.SZ").buy(prev_date, 100, 10.0);
|
||||
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
|
||||
broker
|
||||
.execute(
|
||||
date,
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: vec![
|
||||
OrderIntent::TargetValue {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
target_value: 0.0,
|
||||
reason: "sell_then_rebuy".to_string(),
|
||||
},
|
||||
OrderIntent::Value {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
value: 10_000.0,
|
||||
reason: "sell_then_rebuy".to_string(),
|
||||
},
|
||||
],
|
||||
notes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("broker execution");
|
||||
|
||||
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
symbols,
|
||||
vec![
|
||||
"000001.SZ".to_string(),
|
||||
"000003.SZ".to_string(),
|
||||
"000002.SZ".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -20,10 +20,11 @@ fn can_load_partitioned_snapshot_dir() {
|
||||
fs::create_dir_all(dir.join("market/2024/01")).unwrap();
|
||||
fs::create_dir_all(dir.join("factors/2024/01")).unwrap();
|
||||
fs::create_dir_all(dir.join("candidates/2024/01")).unwrap();
|
||||
fs::create_dir_all(dir.join("corporate_actions/2024/01")).unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("instruments.csv"),
|
||||
"symbol,name,exchange,lot_size\n000001.SZ,PingAn,SZ,100\n",
|
||||
"symbol,name,board,round_lot,listed_at,delisted_at,status\n000001.SZ,PingAn,SZ,100,2020-01-01,,active\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
@@ -33,7 +34,7 @@ fn can_load_partitioned_snapshot_dir() {
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("market/2024/01/2024-01-02.csv"),
|
||||
"date,symbol,open,high,low,close,prev_close,volume,paused,upper_limit,lower_limit\n2024-01-02,000001.SZ,10,10.5,9.9,10.2,10,100000,false,11,9\n",
|
||||
"date,symbol,open,high,low,close,prev_close,volume,paused,upper_limit,lower_limit,day_open,last_price,bid1,ask1,price_tick\n2024-01-02,000001.SZ,10,10.5,9.9,10.2,10,100000,false,11,9,10.1,10.15,10.14,10.16,0.01\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
@@ -46,10 +47,48 @@ fn can_load_partitioned_snapshot_dir() {
|
||||
"date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan\n2024-01-02,000001.SZ,false,false,false,true,true,false,false\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("corporate_actions/2024/01/2024-01-02.csv"),
|
||||
"date,symbol,payable_date,share_cash,share_bonus,share_gift,issue_quantity,issue_price,reform,adjust_factor\n2024-01-02,000001.SZ,2024-01-05,0.5,0.1,0.0,0,0,false,1.05\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let data = DataSet::from_partitioned_dir(&dir).expect("partitioned dataset");
|
||||
assert_eq!(data.benchmark_code(), "CSI300.DEMO");
|
||||
assert!(data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()).len() == 1);
|
||||
assert!(
|
||||
data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap())
|
||||
.len()
|
||||
== 1
|
||||
);
|
||||
let market_rows = data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
|
||||
let snapshot = market_rows
|
||||
.first()
|
||||
.expect("market snapshot");
|
||||
assert_eq!(snapshot.day_open, 10.1);
|
||||
assert_eq!(snapshot.last_price, 10.15);
|
||||
assert_eq!(snapshot.price_tick, 0.01);
|
||||
assert_eq!(
|
||||
data.instruments()
|
||||
.get("000001.SZ")
|
||||
.expect("instrument")
|
||||
.round_lot,
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
data.instruments()
|
||||
.get("000001.SZ")
|
||||
.expect("instrument")
|
||||
.listed_at,
|
||||
Some(chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap())
|
||||
);
|
||||
let actions = data.corporate_actions_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert_eq!(
|
||||
actions[0].payable_date,
|
||||
Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 5).unwrap())
|
||||
);
|
||||
assert!((actions[0].share_cash - 0.5).abs() < 1e-9);
|
||||
assert!((actions[0].split_ratio() - 1.1).abs() < 1e-9);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use chrono::NaiveDate;
|
||||
use fidc_core::{CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, Strategy, StrategyContext, PortfolioState};
|
||||
use fidc_core::{
|
||||
CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, JqMicroCapConfig,
|
||||
JqMicroCapStrategy, PortfolioState, Strategy, StrategyContext,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
@@ -28,9 +31,51 @@ fn strategy_emits_target_weights_and_diagnostics() {
|
||||
assert!(decision.rebalance);
|
||||
assert!(decision.rebalance);
|
||||
assert!(!decision.diagnostics.is_empty());
|
||||
assert!(decision
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|line| line.contains("signal_symbol=")));
|
||||
assert!(
|
||||
decision
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|line| line.contains("signal_symbol="))
|
||||
);
|
||||
assert_eq!(strategy.name(), "cn-dyn-smallcap-band");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jq_strategy_emits_same_day_decision() {
|
||||
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data/demo");
|
||||
let data = DataSet::from_csv_dir(&data_dir).expect("demo data");
|
||||
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
let mut cfg = JqMicroCapConfig::jq_microcap();
|
||||
cfg.benchmark_signal_symbol = "000001.SZ".to_string();
|
||||
cfg.benchmark_short_ma_days = 3;
|
||||
cfg.benchmark_long_ma_days = 5;
|
||||
cfg.stock_short_ma_days = 3;
|
||||
cfg.stock_mid_ma_days = 4;
|
||||
cfg.stock_long_ma_days = 5;
|
||||
let mut strategy = JqMicroCapStrategy::new(cfg);
|
||||
|
||||
let decision = strategy
|
||||
.on_day(&StrategyContext {
|
||||
execution_date,
|
||||
decision_date: execution_date,
|
||||
decision_index: 0,
|
||||
data: &data,
|
||||
portfolio: &portfolio,
|
||||
})
|
||||
.expect("jq decision");
|
||||
|
||||
assert!(!decision.rebalance);
|
||||
assert!(
|
||||
decision
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|line| line.contains("jq_microcap signal="))
|
||||
);
|
||||
assert!(
|
||||
decision
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|line| line.contains("selected="))
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user