fix(stock-pool): reconcile completed entry quantities before repricing
This commit is contained in:
@@ -48,6 +48,17 @@ fn plan(
|
||||
positions: &[Position],
|
||||
cash: i64,
|
||||
outside: &str,
|
||||
) -> StockPoolPlan {
|
||||
plan_at_price(state, at, members, positions, cash, outside, 10)
|
||||
}
|
||||
fn plan_at_price(
|
||||
state: &StockPoolExecutionState,
|
||||
at: NaiveDate,
|
||||
members: &[StockPoolMemberSpec],
|
||||
positions: &[Position],
|
||||
cash: i64,
|
||||
outside: &str,
|
||||
price: i64,
|
||||
) -> StockPoolPlan {
|
||||
let symbols = members
|
||||
.iter()
|
||||
@@ -71,6 +82,10 @@ fn plan(
|
||||
.unwrap();
|
||||
constraints.pending_entry_symbols = state.pending_symbols();
|
||||
constraints.next_day_outside_exit_symbols = state.next_day_exit_symbols(at);
|
||||
let mut market = quote();
|
||||
market.last_price = price.into();
|
||||
market.bid_price_1 = Some(price.into());
|
||||
market.ask_price_1 = Some(price.into());
|
||||
build_stock_pool_target_plan_with_constraints(
|
||||
&selection,
|
||||
members,
|
||||
@@ -81,7 +96,7 @@ fn plan(
|
||||
frozen_cash: Decimal::ZERO,
|
||||
},
|
||||
positions,
|
||||
&[quote()],
|
||||
&[market],
|
||||
10000,
|
||||
Decimal::ZERO,
|
||||
outside,
|
||||
@@ -95,6 +110,54 @@ fn plan(
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_filled_entry_is_not_reopened_when_price_falls_before_next_observation() {
|
||||
let members = vec![member()];
|
||||
let calendar = vec![day(11), day(14)];
|
||||
let initial = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
|
||||
let first_plan = plan(&initial, day(11), &members, &[], 10000, "hold");
|
||||
let state = initial.record_plan(day(11), "first-entry", &first_plan).unwrap();
|
||||
assert_eq!(state.entries["000001.SZ"].completion_quantity, Some(1000.into()));
|
||||
let state: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
|
||||
let observed = state.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)]).unwrap();
|
||||
assert!(!observed.pending_symbols().contains("000001.SZ"));
|
||||
let next = plan_at_price(&observed, day(14), &members, &[held(1000, 1000)], 1000, "hold", 8);
|
||||
assert_eq!(next.rows[0].target_quantity, 1000.into());
|
||||
assert_eq!(next.rows[0].delta_quantity, Decimal::ZERO);
|
||||
assert_eq!(next.rows[0].status, "PRESERVED_EXISTING_POSITION");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_clipped_or_rejected_entry_does_not_claim_full_completion() {
|
||||
let members = vec![member()];
|
||||
let calendar = vec![day(11), day(14)];
|
||||
let initial = StockPoolExecutionState::default()
|
||||
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
|
||||
let first_plan = plan(&initial, day(11), &members, &[], 5000, "hold");
|
||||
assert_eq!(first_plan.rows[0].status, "REDUCE_TO_ALLOWED_QUANTITY");
|
||||
let state = initial.record_plan(day(11), "limited-entry", &first_plan).unwrap();
|
||||
assert_eq!(state.entries["000001.SZ"].completion_quantity, None);
|
||||
for quantity in [0, 500] {
|
||||
let positions = if quantity == 0 { vec![] } else { vec![held(quantity, quantity)] };
|
||||
let observed = state.observe(day(14), day(14), &calendar, &members, &positions).unwrap();
|
||||
assert!(observed.pending_symbols().contains("000001.SZ"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_state_without_quantity_keeps_its_serialized_identity() {
|
||||
let original = json!({
|
||||
"schema_version":1,"last_execution_date":"2026-09-11",
|
||||
"entries":{"000001.SZ":{"pending":true,"observed_holding":false,
|
||||
"first_decision_date":"2026-09-11","latest_generation":"legacy",
|
||||
"latest_target_value":"10000"}},"last_target_weights":{},"removed_since":{}
|
||||
});
|
||||
let state: StockPoolExecutionState = serde_json::from_value(original.clone()).unwrap();
|
||||
state.validate().unwrap();
|
||||
assert_eq!(serde_json::to_value(state).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
|
||||
let members = vec![member()];
|
||||
@@ -120,7 +183,7 @@ fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved()
|
||||
.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)])
|
||||
.unwrap();
|
||||
let satisfied = plan(&filled, day(14), &members, &[held(1000, 1000)], 0, "hold");
|
||||
assert_eq!(satisfied.rows[0].status, "ENTRY_TARGET_ALREADY_SATISFIED");
|
||||
assert_eq!(satisfied.rows[0].status, "PRESERVED_EXISTING_POSITION");
|
||||
let completed = filled.record_plan(day(14), "new-day", &satisfied).unwrap();
|
||||
assert!(!completed.entries["000001.SZ"].pending);
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user