fix(stock-pool): separate exit ownership and ordinary quote dependencies
This commit is contained in:
@@ -866,10 +866,17 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
let mut quote_sell_exits = BTreeSet::new();
|
let mut quote_sell_exits = BTreeSet::new();
|
||||||
let mut sell_condition_denials = BTreeSet::new();
|
let mut sell_condition_denials = BTreeSet::new();
|
||||||
if rule.sell_trigger_mode == POOL_SELL_CONDITION {
|
if rule.sell_trigger_mode == POOL_SELL_CONDITION {
|
||||||
|
// Ordinary sell predicates only depend on positions participating in
|
||||||
|
// that stage. Independent stops/expiry and protected holdings were
|
||||||
|
// already decided above; unrelated quote fields must not block them.
|
||||||
let held = current
|
let held = current
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(symbol, row)| {
|
.filter(|(symbol, row)| {
|
||||||
row.0 > Decimal::ZERO && !constraints.frozen_positions.contains_key(*symbol)
|
row.0 > Decimal::ZERO
|
||||||
|
&& !protected_positions.contains(*symbol)
|
||||||
|
&& !global_stop_hits.contains(*symbol)
|
||||||
|
&& !constraints.automatic_permissions.get(*symbol)
|
||||||
|
.is_some_and(|permission| permission.max_holding_exit)
|
||||||
})
|
})
|
||||||
.map(|(symbol, _)| symbol.clone())
|
.map(|(symbol, _)| symbol.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -880,14 +887,6 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
"e_map,
|
"e_map,
|
||||||
)?;
|
)?;
|
||||||
for symbol in held {
|
for symbol in held {
|
||||||
if global_stop_hits.contains(&symbol)
|
|
||||||
|| constraints
|
|
||||||
.automatic_permissions
|
|
||||||
.get(&symbol)
|
|
||||||
.is_some_and(|permission| permission.max_holding_exit)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let permitted = qualified.get(&symbol) == Some(&true)
|
let permitted = qualified.get(&symbol) == Some(&true)
|
||||||
&& (!rule.secondary_sell_condition
|
&& (!rule.secondary_sell_condition
|
||||||
|| constraints.position_target_bps.contains_key(&symbol));
|
|| constraints.position_target_bps.contains_key(&symbol));
|
||||||
@@ -1031,7 +1030,12 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
}
|
}
|
||||||
let mut planning_symbols = active_symbols;
|
let mut planning_symbols = active_symbols;
|
||||||
for symbol in &original_final_symbols {
|
for symbol in &original_final_symbols {
|
||||||
if rebuy_exclusions.contains(symbol) && !planning_symbols.contains(symbol) {
|
// An explicit quote/expiry position action owns its single target row.
|
||||||
|
// Keep it excluded from entry sizing without adding a second stop row.
|
||||||
|
if rebuy_exclusions.contains(symbol)
|
||||||
|
&& !factor_position_target_bps.contains_key(symbol)
|
||||||
|
&& !planning_symbols.contains(symbol)
|
||||||
|
{
|
||||||
planning_symbols.push(symbol.clone());
|
planning_symbols.push(symbol.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1688,6 +1692,14 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Verify disjoint planning ownership before an index cap can address rows
|
||||||
|
// by symbol. Never deduplicate emitted intentions or count proceeds twice.
|
||||||
|
let mut owners = BTreeSet::new();
|
||||||
|
for row in &rows {
|
||||||
|
if !owners.insert(row.symbol.as_str()) {
|
||||||
|
return Err(format!("stock_pool_target_owner_conflict:{}", row.symbol));
|
||||||
|
}
|
||||||
|
}
|
||||||
if market_timing.is_some() {
|
if market_timing.is_some() {
|
||||||
let caps = index_cap::remaining_index_targets(
|
let caps = index_cap::remaining_index_targets(
|
||||||
¤t,
|
¤t,
|
||||||
|
|||||||
@@ -509,6 +509,16 @@ fn condition_plan(
|
|||||||
quotes: &[MarketSnapshot],
|
quotes: &[MarketSnapshot],
|
||||||
constraints: &StockPoolDecisionConstraints,
|
constraints: &StockPoolDecisionConstraints,
|
||||||
) -> StockPoolPlan {
|
) -> StockPoolPlan {
|
||||||
|
condition_plan_result(selection, rule, positions, quotes, constraints).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn condition_plan_result(
|
||||||
|
selection: &StockPoolSelection,
|
||||||
|
rule: &StockPoolExecutionRule,
|
||||||
|
positions: &[Position],
|
||||||
|
quotes: &[MarketSnapshot],
|
||||||
|
constraints: &StockPoolDecisionConstraints,
|
||||||
|
) -> Result<StockPoolPlan, String> {
|
||||||
let held_value = positions
|
let held_value = positions
|
||||||
.iter()
|
.iter()
|
||||||
.map(|position| {
|
.map(|position| {
|
||||||
@@ -541,7 +551,6 @@ fn condition_plan(
|
|||||||
Decimal::ZERO,
|
Decimal::ZERO,
|
||||||
Decimal::ZERO,
|
Decimal::ZERO,
|
||||||
)
|
)
|
||||||
.unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -704,6 +713,115 @@ fn native_sell_and_quote_conditions_are_and_but_stop_and_protection_remain_indep
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn independent_stop_does_not_require_unused_ordinary_sell_quote_facts() {
|
||||||
|
let mut market = quotes(1);
|
||||||
|
market[0].last_price = 9.into();
|
||||||
|
market[0].volume = None;
|
||||||
|
let rule = normalize_stock_pool_execution_rule(
|
||||||
|
Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
).unwrap();
|
||||||
|
let constraints = StockPoolDecisionConstraints {
|
||||||
|
default_stop_loss: Some(Decimal::new(5, 2)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let plan = condition_plan(&selection(1, 1), &rule, &[position(1)], &market, &constraints);
|
||||||
|
let exit = plan.rows.iter().find(|row| row.symbol == symbol(1)).unwrap();
|
||||||
|
assert_eq!(exit.side, Some(OrderSide::Sell), "{plan:?}");
|
||||||
|
assert_eq!(exit.target_quantity, Decimal::ZERO, "{plan:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_sell_scope_excludes_independent_exits_and_protected_positions() {
|
||||||
|
for scope in [QuoteConditionScope::PerSymbol, QuoteConditionScope::AllTargets, QuoteConditionScope::AnyTarget] {
|
||||||
|
for cause in ["stop_loss", "take_profit", "maximum_holding_exit", "automatic_trade_locked", "buy_fill_protection"] {
|
||||||
|
let mut market = quotes(2);
|
||||||
|
market[0].volume = None;
|
||||||
|
let mut constraints = StockPoolDecisionConstraints::default();
|
||||||
|
match cause {
|
||||||
|
"stop_loss" => { market[0].last_price = 9.into(); constraints.default_stop_loss = Some(Decimal::new(5, 2)); },
|
||||||
|
"take_profit" => { market[0].last_price = 12.into(); constraints.default_take_profit = Some(Decimal::new(10, 2)); },
|
||||||
|
"maximum_holding_exit" => { constraints.automatic_permissions.insert(symbol(1), crate::holding_policy::AutomaticTradePermission { max_holding_exit: true, ..Default::default() }); },
|
||||||
|
_ => { constraints.automatic_permissions.insert(symbol(1), crate::holding_policy::AutomaticTradePermission { sell_denial: Some(cause), buy_denial: Some(cause), ..Default::default() }); },
|
||||||
|
}
|
||||||
|
let rule = normalize_stock_pool_execution_rule(
|
||||||
|
Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000","sell_condition_scope":scope})),
|
||||||
|
false, false,
|
||||||
|
).unwrap();
|
||||||
|
let plan = condition_plan(&selection(2, 2), &rule, &[position(1), position(2)], &market, &constraints);
|
||||||
|
let protected = plan.rows.iter().find(|row| row.symbol == symbol(1)).unwrap();
|
||||||
|
let normal = plan.rows.iter().find(|row| row.symbol == symbol(2)).unwrap();
|
||||||
|
assert_eq!(normal.side, Some(OrderSide::Sell), "{scope:?}/{cause}: {plan:?}");
|
||||||
|
if cause == "automatic_trade_locked" || cause == "buy_fill_protection" {
|
||||||
|
assert_eq!(protected.side, None, "{scope:?}/{cause}: {plan:?}");
|
||||||
|
assert_eq!(protected.target_quantity, 1000.into(), "{scope:?}/{cause}: {plan:?}");
|
||||||
|
assert_eq!(protected.status, "AUTOMATIC_TRADE_PROTECTED", "{scope:?}/{cause}: {plan:?}");
|
||||||
|
} else {
|
||||||
|
assert_eq!(protected.side, Some(OrderSide::Sell), "{scope:?}/{cause}: {plan:?}");
|
||||||
|
assert_eq!(protected.target_quantity, Decimal::ZERO, "{scope:?}/{cause}: {plan:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn independent_exit_quote_priority_does_not_bypass_t_plus_one_or_price_validation() {
|
||||||
|
let rule = normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})), false, false).unwrap();
|
||||||
|
let mut market = quotes(1);
|
||||||
|
market[0].last_price = 9.into(); market[0].volume = None;
|
||||||
|
let constraints = StockPoolDecisionConstraints { default_stop_loss: Some(Decimal::new(5,2)), ..Default::default() };
|
||||||
|
for closable in [0, 400, 1000] {
|
||||||
|
let mut held = position(1); held.closable_quantity = Decimal::from(closable);
|
||||||
|
let plan = condition_plan(&selection(1,1), &rule, &[held], &market, &constraints);
|
||||||
|
let row = plan.rows.iter().find(|row|row.symbol==symbol(1)).unwrap();
|
||||||
|
assert_eq!(row.delta_quantity, -Decimal::from(closable), "{plan:?}");
|
||||||
|
assert_eq!(row.target_quantity, Decimal::from(1000-closable), "{plan:?}");
|
||||||
|
}
|
||||||
|
market[0].last_price = Decimal::ZERO;
|
||||||
|
assert!(condition_plan_result(&selection(1,1), &rule, &[position(1)], &market, &constraints).unwrap_err().contains("execution quote is invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_sell_keeps_required_quote_failures_and_zero_stop_is_not_an_exit() {
|
||||||
|
let rule = normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})), false, false).unwrap();
|
||||||
|
let mut market = quotes(1); market[0].last_price = 9.into(); market[0].volume = None;
|
||||||
|
for stop in [None, Some(Decimal::ZERO)] {
|
||||||
|
let constraints = StockPoolDecisionConstraints { default_stop_loss: stop, ..Default::default() };
|
||||||
|
assert_eq!(condition_plan_result(&selection(1,1), &rule, &[position(1)], &market, &constraints).unwrap_err(), "condition requires volume");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quote_field_operator_side_and_scope_matrix_matches_the_configured_predicate() {
|
||||||
|
let market = quotes(2);
|
||||||
|
for (field, threshold) in [("price", "10"), ("last", "10"), ("change_pct", "0"), ("volume", "1000000"), ("amount", "10000000"), ("bid1", "10"), ("ask1", "10")] {
|
||||||
|
for (operator, matched) in [(">",false), (">=",true), ("<",false), ("<=",true), ("==",true), ("!=",false)] {
|
||||||
|
for scope in [QuoteConditionScope::PerSymbol, QuoteConditionScope::AllTargets, QuoteConditionScope::AnyTarget] {
|
||||||
|
for side in [OrderSide::Buy, OrderSide::Sell] {
|
||||||
|
let condition = format!("{field}{operator}{threshold}");
|
||||||
|
let config = if side == OrderSide::Buy {
|
||||||
|
json!({"trigger_mode":"condition","buy_condition":condition,"buy_condition_scope":scope})
|
||||||
|
} else {
|
||||||
|
json!({"sell_trigger_mode":"condition","sell_condition":condition,"sell_condition_scope":scope})
|
||||||
|
};
|
||||||
|
let rule = normalize_stock_pool_execution_rule(Some(&config), false, false).unwrap();
|
||||||
|
let held = if side == OrderSide::Sell { vec![position(1),position(2)] } else { vec![] };
|
||||||
|
let plan = condition_plan(&selection(2,2), &rule, &held, &market, &StockPoolDecisionConstraints::default());
|
||||||
|
assert_eq!(plan.rows.iter().map(|row| &row.symbol).collect::<BTreeSet<_>>().len(), plan.rows.len(), "duplicate target ownership: {plan:?}");
|
||||||
|
let orders = plan.rows.iter().filter(|row|row.side==Some(side)).count();
|
||||||
|
assert_eq!(orders, if matched {2} else {0}, "{side:?}/{scope:?}/{condition}: {plan:?}");
|
||||||
|
if side == OrderSide::Sell && matched {
|
||||||
|
assert_eq!(plan.estimated_sell_amount, Decimal::from(20000), "{plan:?}");
|
||||||
|
assert_eq!(plan.estimated_cash_after, Decimal::from(40000), "{plan:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() {
|
fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() {
|
||||||
let mut constraints = StockPoolDecisionConstraints::default();
|
let mut constraints = StockPoolDecisionConstraints::default();
|
||||||
|
|||||||
@@ -445,6 +445,31 @@ fn actual_fill_protection_is_evaluated_on_execution_date() {
|
|||||||
assert_eq!(account.position(&code(1)).unwrap().quantity, 1000);
|
assert_eq!(account.position(&code(1)).unwrap().quantity, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_sell_has_one_order_owner_before_broker_execution() {
|
||||||
|
let data = data(false);
|
||||||
|
let broker = broker(false);
|
||||||
|
let mut account = PortfolioState::new(20_000.);
|
||||||
|
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
|
||||||
|
let mut intent = contract(day(2), 1, false);
|
||||||
|
intent.rule.sell_trigger_mode = POOL_SELL_CONDITION.into();
|
||||||
|
intent.rule.sell_condition = "price>0".into();
|
||||||
|
let report = broker.execute_with_event_dates(
|
||||||
|
day(5), day(2), day(2), &mut account, &data, &decision(intent),
|
||||||
|
).unwrap();
|
||||||
|
let sells=report.fill_events.iter().filter(|row|row.symbol==code(1)).collect::<Vec<_>>();
|
||||||
|
assert_eq!(sells.len(),1,"{report:?}");
|
||||||
|
assert_eq!(sells[0].quantity,1000,"{report:?}");
|
||||||
|
let owners=report.order_events.iter().filter(|row|row.symbol==code(1)).map(|row|row.order_id).collect::<BTreeSet<_>>();
|
||||||
|
assert_eq!(owners.len(),1,"{report:?}");
|
||||||
|
assert_eq!(account.position(&code(1)).map(|row|row.quantity).unwrap_or(0),0);
|
||||||
|
// The replacement may enter only after the single sell has settled.
|
||||||
|
let replacement=report.fill_events.iter().find(|row|row.symbol==code(2)).unwrap();
|
||||||
|
assert_eq!(replacement.quantity,3000,"{report:?}");
|
||||||
|
assert_eq!(report.account_events[0].cash_after,40000.);
|
||||||
|
assert_eq!(report.account_events[1].cash_before,40000.);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
|
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
|
||||||
let intent = contract(day(2), 1, false);
|
let intent = contract(day(2), 1, false);
|
||||||
|
|||||||
Reference in New Issue
Block a user