fix(core): symmetric bandar thresholds; bandar needs all investors

- Basis points truncate toward zero instead of flooring, so a reading of
  -4.9952% stays neutral like its mirror image and -14.9952% stays
  distribution; tests cover both signs at both thresholds through
  bandar_tier. Goldens move by the rounding (BBCA top 5: -2426 -> -2425).
- With one investor side selected the summary has no total to weigh
  brokers against, so bandar and analyze_bandar reject it (INVALIDINPUT)
  instead of reading every tier as missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-18 05:39:05 +00:00
commit 4c2f0acff3
13 changed files with 73 additions and 18 deletions

View file

@ -135,10 +135,12 @@ pub fn check_top(top: u8) -> Result<u8, IdxError> {
}
}
/// `part` in basis points of `whole`; `None` without a positive whole.
/// `part` in basis points of `whole`, truncated toward zero so a negative
/// reading never reaches a threshold its magnitude has not; `None` without
/// a positive whole.
fn bps(part: i64, whole: Option<i64>) -> Option<i64> {
let whole = whole.filter(|whole| *whole > 0)?;
i64::try_from((i128::from(part) * 10_000).div_euclid(i128::from(whole))).ok()
i64::try_from(i128::from(part) * 10_000 / i128::from(whole)).ok()
}
/// Sum of `field` over `flows`; `None` when any flow lacks it.
@ -326,6 +328,37 @@ mod tests {
assert_eq!(report.method, METHOD);
}
#[test]
fn test_bandar_tier_thresholds_are_symmetric_for_sign() {
let reading = |buy: i64, sell: i64| {
let s = summary(
vec![flow("AA", Some(buy), 100)],
vec![flow("BB", Some(sell), 100)],
2_100_000_000,
);
let tier = bandar_tier(&s, 1);
(tier.imbalance_bps, tier.signal)
};
// -4.9952% and -14.9952% stay short of the thresholds, like their
// mirror images.
assert_eq!(
reading(504_300_000, 609_200_000),
(Some(-499), Some(BandarSignal::Neutral))
);
assert_eq!(
reading(609_200_000, 504_300_000),
(Some(499), Some(BandarSignal::Neutral))
);
assert_eq!(
reading(504_300_000, 819_200_000),
(Some(-1499), Some(BandarSignal::Distribution))
);
assert_eq!(
reading(819_200_000, 504_300_000),
(Some(1499), Some(BandarSignal::Accumulation))
);
}
#[test]
fn test_analyze_bandar_never_counts_a_missing_figure_as_zero() {
let s = summary(