186 lines
5.0 KiB
Rust
186 lines
5.0 KiB
Rust
use chrono::NaiveDate;
|
|
use fidc_core::cost::CostModel;
|
|
use fidc_core::rules::EquityRuleHooks;
|
|
use fidc_core::{
|
|
CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot,
|
|
OrderSide, Position, PriceField,
|
|
};
|
|
|
|
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
|
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
|
}
|
|
|
|
fn candidate() -> CandidateEligibility {
|
|
CandidateEligibility {
|
|
date: d(2024, 1, 3),
|
|
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,
|
|
}
|
|
}
|
|
|
|
fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapshot {
|
|
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,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn china_cost_model_applies_minimum_commission_and_stamp_tax() {
|
|
let model = ChinaAShareCostModel::default();
|
|
|
|
let buy = model.calculate(OrderSide::Buy, 1_000.0);
|
|
assert!((buy.commission - 5.0).abs() < 1e-9);
|
|
assert_eq!(buy.stamp_tax, 0.0);
|
|
|
|
let sell = model.calculate(OrderSide::Sell, 100_000.0);
|
|
assert!((sell.commission - 30.0).abs() < 1e-9);
|
|
assert!((sell.stamp_tax - 100.0).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn china_rule_hooks_block_same_day_sell_under_t_plus_one() {
|
|
let hooks = ChinaEquityRuleHooks;
|
|
let mut position = Position::new("000001.SZ");
|
|
let trade_date = d(2024, 1, 3);
|
|
position.buy(trade_date, 1_000, 10.0);
|
|
|
|
let check = hooks.can_sell(
|
|
trade_date,
|
|
&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"));
|
|
}
|
|
|
|
#[test]
|
|
fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() {
|
|
let hooks = ChinaEquityRuleHooks;
|
|
let candidate = candidate();
|
|
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,
|
|
PriceField::Open,
|
|
);
|
|
assert!(!buy_check.allowed);
|
|
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,
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
|
|
let hooks = ChinaEquityRuleHooks;
|
|
let candidate = candidate();
|
|
let mut position = Position::new("000001.SZ");
|
|
position.buy(d(2024, 4, 3), 1_000, 2.89);
|
|
|
|
let snapshot = DailyMarketSnapshot {
|
|
date: d(2024, 4, 7),
|
|
symbol: "000001.SZ".to_string(),
|
|
timestamp: Some("2024-04-07 10:18:00".to_string()),
|
|
day_open: 2.53,
|
|
open: 2.53,
|
|
high: 2.53,
|
|
low: 2.52,
|
|
close: 2.53,
|
|
last_price: 2.53,
|
|
bid1: 2.52,
|
|
ask1: 2.53,
|
|
prev_close: 2.80,
|
|
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: 3.08,
|
|
lower_limit: 2.52,
|
|
price_tick: 0.01,
|
|
};
|
|
|
|
let sell_check = hooks.can_sell(d(2024, 4, 7), &snapshot, &candidate, &position, PriceField::Last);
|
|
assert!(sell_check.allowed, "sell should be allowed when snapshot last is above lower limit");
|
|
}
|