Some checks failed
p2p-intel / engine (push) Has been cancelled
p2p-intel / coverage (push) Has been cancelled
p2p-intel / makepad-app (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
A new nested workspace under crates/apps/p2p-intel: six engine crates, a CLI, and a Makepad dashboard that builds for desktop, Android and iOS. It reads public P2P adverts, measures the spread that is actually fillable, alerts when one is worth acting on, and tracks what the float really cost. It never places an order. Binance publishes no P2P trading API, and automating an escrow release is how a merchant loses their float to chargeback fraud. This is the intelligence layer; execution stays manual. The design came from a live capture rather than a sketch, and the capture contradicted the sketch three times. All three are now pinned by tests against checked-in real payloads. **The best price is routinely the least fillable one.** In the KES book the top sell advert was 134.60 from a merchant with three completed trades, implying a 3.6% spread; the next was 130.30. Another advert showed a 0% completion rate. A best-price scan with no quality floor does not find opportunities, it finds outliers, and outliers on a P2P book are bait or a merchant about to run dry. QualityFilter defaults to 95% completion and 50 orders, and analyse() reports every exclusion with its reason rather than dropping it silently. The honest consequence is recorded in the integration suite: at those defaults **not one sell-side advert in the captured KES book qualified**. There was no fillable arbitrage. A tool that reported the raw best-price number would have sent its user after a trade that does not exist, so the test asserts best_sell is None and net_bps is None rather than asserting a comfortable number. **tradeType is inverted between request and response.** Asking the endpoint for tradeType "BUY" returns adverts whose own adv.tradeType reads "SELL". Both are correct: the request parameter is what you want to do, the response field is what the advertiser is doing. Conflating them inverts every spread and the result still looks plausible, which makes it the most expensive mistake available here. Side keeps the two apart with request_trade_type()/advert_trade_type(), and a test asserts they are never equal. **An empty market answers HTTP 200 with success: true.** NGN returned zero adverts. "No ads" and "no answer" need opposite responses, so is_empty_market() is a named predicate and ScanError separates Malformed (Binance changed the payload; retrying makes it worse) from Network (transient). basis_points_above returns None against a zero base rather than an infinity, so an empty book cannot read as an infinite opportunity at 3am. Money is never a float, following the rule in nigig-pay-domain/src/money.rs. IEEE 754 cannot represent 0.1 and a spread is a difference of two nearly equal numbers, which is exactly where binary floating point loses the digits that matter. Binance sends prices as decimal strings, so Price parses them straight into scaled i128 integers and never passes through f64. i128 rather than i64 because the intermediate in a bps calculation overflows, not the result. Excess precision is refused rather than rounded and a thousands separator is refused rather than dropped: "1,299.92" read as 129992 is a 1000x error that still looks like a price. Tests pin 0.1 + 0.2 == 0.3 and rotate 100 round trips at one price asserting exactly zero P&L. Alerting is mostly restraint. At a 30-second poll one wide spread would fire 120 identical messages an hour, and a channel that cries wolf gets muted, at which point the tool has negative value because the user believes they are covered. AlertGate suppresses repeats inside a cooldown and re-alerts early only when the spread improves materially -- a collapsing spread is not worth waking someone for. Telegram MarkdownV2 escaping is tested against a real merchant name from the capture, Twin_traders00, whose underscore would otherwise make Telegram reject the message with a 400 and deliver nothing. Writing the dashboard view model found a bug in my own comparator: sorting descending by swapping the tuple to (b, a) also silently swaps the meaning of the None arms, which put dead markets at the top of the opportunity list. The test that caught it was written first and named for the behaviour, not the implementation. Networking is behind a non-default `live` feature, so an ordinary cargo test cannot make a request and CI never depends on Binance being reachable. A CI step asserts reqwest is absent from the default dependency graph so this cannot regress quietly. A live scan was run once to confirm the fixtures match reality; it reported a negative spread for KES and an empty NGN book, which is the tool working correctly. Conventions follow the repo rather than the generic layout in the request: .forgejo/workflows/p2p-intel.yml rather than .github, and no Dockerfile, since the stack is pure Rust and nothing else here is containerised. error_set is used instead of anyhow, matching nigig-core. The root Cargo.toml excludes the nested workspace by name, as it already does for makepad_table, so the isolation is intentional rather than dependent on a table inside someone else's manifest. 106 tests, coverage 96.86% with per-file floors enforced by tools/test-p2p-coverage.sh. Both the total and per-file gates were verified to actually fail by running them with impossible floors; a gate that cannot fail is decoration. Two files are excluded and only because they were first emptied of decisions: the Makepad widget, which needs a GPU and a windowing backend this repo has no headless backend for, and the CLI main, which is argument parsing and println. Every rule the widget renders lives in view_model.rs, measured at 97%. That split is deliberate -- spreadsheet-ui/grid.rs once hid 36 pure functions behind a file-level exclusion, and excluding a file you have not emptied of logic is how that happens. The Makepad desktop binary was built and linked in the sandbox to prove the app half is real and not just a compiling stub.
189 lines
6.8 KiB
Rust
189 lines
6.8 KiB
Rust
//! End-to-end: a real captured payload becomes a decision and a ledger entry.
|
|
//!
|
|
//! These tests deliberately use the *checked-in captures* rather than
|
|
//! synthetic data. A unit test proves the arithmetic; only real payloads
|
|
//! prove the field names, the camelCase, and the inverted `tradeType` are
|
|
//! all handled — and those are the things that actually broke.
|
|
|
|
use p2p_analyzer::analyse;
|
|
use p2p_core::{Price, QualityFilter, Side};
|
|
use p2p_scanner::{parse_side, snapshot};
|
|
use p2p_tracker::{Ledger, Trade};
|
|
|
|
fn fixture(name: &str) -> String {
|
|
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/fixtures/");
|
|
std::fs::read_to_string(format!("{path}{name}")).expect("fixture should exist")
|
|
}
|
|
|
|
fn p(s: &str) -> Price {
|
|
Price::parse(s).unwrap()
|
|
}
|
|
|
|
/// The whole pipeline over the real KES capture.
|
|
#[test]
|
|
fn a_real_capture_flows_from_json_to_a_spread_decision() {
|
|
let buys = parse_side(&fixture("kes_buy_side.json"), Side::WeBuy, "t").unwrap();
|
|
let sells = parse_side(&fixture("kes_sell_side.json"), Side::WeSell, "t").unwrap();
|
|
let snap = snapshot("KES", "USDT", buys, sells, 0);
|
|
|
|
assert!(!snap.is_empty_market());
|
|
|
|
let report = analyse(&snap, &QualityFilter::default(), 0);
|
|
|
|
// The honest result, and the whole point of the exercise: at the default
|
|
// quality floor (95% completion, 50+ orders) **not one sell-side advert
|
|
// in the live KES book qualified**. Their order counts were 3, 48, 25, 2
|
|
// and 0. The buy side was full of merchants with thousands of trades.
|
|
//
|
|
// So there was no fillable arbitrage in this capture. A tool that
|
|
// reported the raw best-price spread would have said 3.6%.
|
|
assert!(
|
|
report.best_buy.is_some(),
|
|
"the buy side had well-established merchants"
|
|
);
|
|
assert!(
|
|
report.best_sell.is_none(),
|
|
"no sell-side advert cleared the quality floor"
|
|
);
|
|
assert_eq!(
|
|
report.net_bps, None,
|
|
"no qualifying counterparty means no spread, not a spread of zero"
|
|
);
|
|
assert!(!report.is_actionable(0));
|
|
assert_eq!(
|
|
report.rejected.len(),
|
|
5,
|
|
"every sell-side advert should be reported as excluded, with a reason"
|
|
);
|
|
}
|
|
|
|
/// The finding that motivated the quality filter, asserted end to end.
|
|
#[test]
|
|
fn the_unfiltered_spread_is_much_wider_than_the_fillable_one() {
|
|
let buys = parse_side(&fixture("kes_buy_side.json"), Side::WeBuy, "t").unwrap();
|
|
let sells = parse_side(&fixture("kes_sell_side.json"), Side::WeSell, "t").unwrap();
|
|
let snap = snapshot("KES", "USDT", buys, sells, 0);
|
|
|
|
let lenient = QualityFilter {
|
|
min_finish_rate_bps: 0,
|
|
min_order_count: 0,
|
|
min_tradable_fiat: None,
|
|
};
|
|
let naive = analyse(&snap, &lenient, 0);
|
|
let naive_bps = naive.gross_bps.expect("a naive scan always finds a number");
|
|
|
|
// Unfiltered, this book reads as a 3.6% opportunity — the kind of number
|
|
// that gets someone to move real money.
|
|
assert!(naive_bps > 300, "naive reads as a >3% opportunity");
|
|
assert_eq!(naive.best_sell.unwrap().advertiser, "tiero");
|
|
|
|
// Filtered, it disappears entirely, because the advert offering 134.60
|
|
// had completed three trades.
|
|
let strict = analyse(&snap, &QualityFilter::default(), 0);
|
|
assert_eq!(
|
|
strict.gross_bps, None,
|
|
"the apparent opportunity was entirely made of unqualified counterparties"
|
|
);
|
|
|
|
// A moderate floor keeps the book's one semi-established seller (48
|
|
// orders, 87% completion) and reports a realistic 29 bps instead.
|
|
let moderate = QualityFilter {
|
|
min_finish_rate_bps: 8000,
|
|
min_order_count: 40,
|
|
min_tradable_fiat: None,
|
|
};
|
|
assert_eq!(analyse(&snap, &moderate, 0).gross_bps, Some(29));
|
|
}
|
|
|
|
/// An empty market must be reported as empty, not as an error or a spread.
|
|
#[test]
|
|
fn an_empty_market_produces_no_spread_and_no_panic() {
|
|
let ads = parse_side(&fixture("ngn_empty_market.json"), Side::WeBuy, "t").unwrap();
|
|
assert!(ads.is_empty());
|
|
|
|
let snap = snapshot("NGN", "USDT", ads, vec![], 0);
|
|
assert!(snap.is_empty_market());
|
|
|
|
let report = analyse(&snap, &QualityFilter::default(), 0);
|
|
assert_eq!(report.net_bps, None);
|
|
assert!(!report.is_actionable(0));
|
|
assert!(report.empty_market);
|
|
}
|
|
|
|
/// Buying and selling at the observed prices books the observed margin.
|
|
#[test]
|
|
fn a_round_trip_at_captured_prices_books_the_expected_profit() {
|
|
let mut ledger = Ledger::default();
|
|
ledger.record(Trade {
|
|
id: "buy-1".into(),
|
|
fiat: "KES".into(),
|
|
side: Side::WeBuy,
|
|
units: p("1000"),
|
|
price: p("129.92"),
|
|
counterparty: "BennyBoss".into(),
|
|
method: "MpesaPaybill".into(),
|
|
executed_at_ms: 1,
|
|
});
|
|
ledger.record(Trade {
|
|
id: "sell-1".into(),
|
|
fiat: "KES".into(),
|
|
side: Side::WeSell,
|
|
units: p("1000"),
|
|
price: p("130.30"),
|
|
counterparty: "Twin_traders00".into(),
|
|
method: "BANK".into(),
|
|
executed_at_ms: 2,
|
|
});
|
|
|
|
let inv = ledger.replay("KES").unwrap();
|
|
// 1000 * (130.30 - 129.92) = 380 KES
|
|
assert_eq!(inv.realised_pnl, p("380"));
|
|
assert_eq!(inv.units, Price::ZERO);
|
|
assert_eq!(inv.average_cost(), None);
|
|
}
|
|
|
|
/// The ledger survives a save/load cycle with exact values.
|
|
#[test]
|
|
fn the_ledger_round_trips_through_disk_without_drift() {
|
|
let mut ledger = Ledger::default();
|
|
for i in 0..50 {
|
|
ledger.record(Trade {
|
|
id: format!("t{i}"),
|
|
fiat: "KES".into(),
|
|
side: if i % 2 == 0 {
|
|
Side::WeBuy
|
|
} else {
|
|
Side::WeSell
|
|
},
|
|
units: p("10"),
|
|
price: if i % 2 == 0 { p("129.92") } else { p("130.30") },
|
|
counterparty: "someone".into(),
|
|
method: "BANK".into(),
|
|
executed_at_ms: i as i64,
|
|
});
|
|
}
|
|
let before = ledger.replay("KES").unwrap();
|
|
let reloaded = Ledger::from_json(&ledger.to_json(), "mem").unwrap();
|
|
let after = reloaded.replay("KES").unwrap();
|
|
|
|
assert_eq!(before, after, "serialisation must not perturb the P&L");
|
|
// 25 round trips of 10 USDT at a 0.38 margin = 95 KES exactly.
|
|
assert_eq!(after.realised_pnl, p("95"));
|
|
}
|
|
|
|
/// The direction guard, end to end: our buy side must be cheaper.
|
|
#[test]
|
|
fn the_two_sides_of_the_capture_are_not_the_same_data() {
|
|
let buys = parse_side(&fixture("kes_buy_side.json"), Side::WeBuy, "t").unwrap();
|
|
let sells = parse_side(&fixture("kes_sell_side.json"), Side::WeSell, "t").unwrap();
|
|
|
|
let cheapest_buy = buys.iter().map(|a| a.price).min().unwrap();
|
|
let dearest_sell = sells.iter().map(|a| a.price).max().unwrap();
|
|
|
|
assert!(
|
|
dearest_sell > cheapest_buy,
|
|
"a book where you cannot buy below the best bid is a parsing bug"
|
|
);
|
|
assert!(buys.iter().all(|a| a.side == Side::WeBuy));
|
|
assert!(sells.iter().all(|a| a.side == Side::WeSell));
|
|
}
|