Improve jq microcap execution semantics

This commit is contained in:
boris
2026-04-18 18:02:50 +08:00
parent 9f4165e689
commit 0e2c25e4c4
26 changed files with 5058 additions and 362 deletions

View File

@@ -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"));
}

View 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());
}

View 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"));
}

View 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(),
]
);
}

View File

@@ -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);
}

View File

@@ -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="))
);
}