Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 848c1a514a |
@@ -866,10 +866,17 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
let mut quote_sell_exits = BTreeSet::new();
|
||||
let mut sell_condition_denials = BTreeSet::new();
|
||||
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
|
||||
.iter()
|
||||
.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())
|
||||
.collect::<Vec<_>>();
|
||||
@@ -880,14 +887,6 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
"e_map,
|
||||
)?;
|
||||
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)
|
||||
&& (!rule.secondary_sell_condition
|
||||
|| 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;
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
let caps = index_cap::remaining_index_targets(
|
||||
¤t,
|
||||
|
||||
@@ -509,6 +509,16 @@ fn condition_plan(
|
||||
quotes: &[MarketSnapshot],
|
||||
constraints: &StockPoolDecisionConstraints,
|
||||
) -> 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
|
||||
.iter()
|
||||
.map(|position| {
|
||||
@@ -541,7 +551,6 @@ fn condition_plan(
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() {
|
||||
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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
|
||||
let intent = contract(day(2), 1, false);
|
||||
|
||||
Reference in New Issue
Block a user