rspace-online/modules/rexchange/exchange-solver.ts

222 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* P2P Exchange Matching Engine — bipartite intent matching.
*
* Matches buy intents (want crypto, have fiat) with sell intents (have crypto, want fiat).
*
* Edge criteria (buy B ↔ sell S):
* 1. Same tokenId + fiatCurrency
* 2. Rate overlap (for market_plus_bps, evaluate against cached CoinGecko rate)
* 3. Amount overlap: min(B.max, S.max) >= max(B.min, S.min)
* 4. Reputation VPs satisfied both ways
*
* Scoring: 0.4×rateMutualness + 0.3×amountBalance + 0.2×avgReputation + 0.1×lpPriority
*/
import type {
ExchangeIntent, ExchangeIntentsDoc,
ExchangeReputationDoc, ExchangeTrade,
TokenId, FiatCurrency,
} from './schemas';
import { getReputation } from './exchange-reputation';
import { getExchangeRate } from './exchange-rates';
// ── Config ──
const TOP_K = 20;
const W_RATE = 0.4;
const W_AMOUNT = 0.3;
const W_REPUTATION = 0.2;
const W_LP = 0.1;
// ── Types ──
interface Match {
buyIntent: ExchangeIntent;
sellIntent: ExchangeIntent;
agreedAmount: number; // token base units
agreedRate: number; // fiat per token
fiatAmount: number;
paymentMethod: string;
score: number;
}
// ── Rate resolution ──
/** Resolve the effective rate (fiat per token) for an intent, given the market rate. */
function resolveRate(intent: ExchangeIntent, marketRate: number): number {
if (intent.rateType === 'fixed' && intent.rateFixed != null) {
return intent.rateFixed;
}
if (intent.rateType === 'market_plus_bps' && intent.rateMarketBps != null) {
const bps = intent.rateMarketBps;
const direction = intent.side === 'sell' ? 1 : -1; // seller adds spread, buyer subtracts
return marketRate * (1 + direction * bps / 10000);
}
return marketRate;
}
// ── Solver ──
/**
* Run the matching engine on active intents.
* Returns proposed matches sorted by score.
*/
export async function solveExchange(
intentsDoc: ExchangeIntentsDoc,
reputationDoc: ExchangeReputationDoc,
): Promise<Omit<ExchangeTrade, 'id' | 'createdAt' | 'chatMessages'>[]> {
const intents = Object.values(intentsDoc.intents).filter(i => i.status === 'active');
const buys = intents.filter(i => i.side === 'buy');
const sells = intents.filter(i => i.side === 'sell');
if (buys.length === 0 || sells.length === 0) return [];
// Group by tokenId+fiatCurrency for efficiency
const pairGroups = new Map<string, { buys: ExchangeIntent[]; sells: ExchangeIntent[] }>();
for (const b of buys) {
const key = `${b.tokenId}:${b.fiatCurrency}`;
if (!pairGroups.has(key)) pairGroups.set(key, { buys: [], sells: [] });
pairGroups.get(key)!.buys.push(b);
}
for (const s of sells) {
const key = `${s.tokenId}:${s.fiatCurrency}`;
if (!pairGroups.has(key)) pairGroups.set(key, { buys: [], sells: [] });
pairGroups.get(key)!.sells.push(s);
}
const allMatches: Match[] = [];
for (const [pairKey, group] of pairGroups) {
if (group.buys.length === 0 || group.sells.length === 0) continue;
const [tokenId, fiatCurrency] = pairKey.split(':') as [TokenId, FiatCurrency];
const marketRate = await getExchangeRate(tokenId, fiatCurrency);
for (const buy of group.buys) {
for (const sell of group.sells) {
// Don't match same user
if (buy.creatorDid === sell.creatorDid) continue;
const match = evaluateMatch(buy, sell, marketRate, reputationDoc);
if (match) allMatches.push(match);
}
}
}
// Sort by score descending, take top K
allMatches.sort((a, b) => b.score - a.score);
// Deduplicate: each intent can appear in at most one match (greedy)
const usedIntents = new Set<string>();
const results: Omit<ExchangeTrade, 'id' | 'createdAt' | 'chatMessages'>[] = [];
for (const match of allMatches) {
if (results.length >= TOP_K) break;
if (usedIntents.has(match.buyIntent.id) || usedIntents.has(match.sellIntent.id)) continue;
usedIntents.add(match.buyIntent.id);
usedIntents.add(match.sellIntent.id);
results.push({
buyIntentId: match.buyIntent.id,
sellIntentId: match.sellIntent.id,
buyerDid: match.buyIntent.creatorDid,
buyerName: match.buyIntent.creatorName,
sellerDid: match.sellIntent.creatorDid,
sellerName: match.sellIntent.creatorName,
tokenId: match.buyIntent.tokenId,
tokenAmount: match.agreedAmount,
fiatCurrency: match.buyIntent.fiatCurrency,
fiatAmount: match.fiatAmount,
agreedRate: match.agreedRate,
paymentMethod: match.paymentMethod,
status: 'proposed',
acceptances: {
[match.buyIntent.creatorDid]: match.buyIntent.autoAccept,
[match.sellIntent.creatorDid]: match.sellIntent.autoAccept,
},
});
}
return results;
}
/**
* Evaluate a single buy/sell pair. Returns a Match if compatible, null otherwise.
*/
function evaluateMatch(
buy: ExchangeIntent,
sell: ExchangeIntent,
marketRate: number,
reputationDoc: ExchangeReputationDoc,
): Match | null {
// 1. Rate overlap
const buyRate = resolveRate(buy, marketRate); // max rate buyer will pay
const sellRate = resolveRate(sell, marketRate); // min rate seller will accept
// Buyer's rate is the ceiling, seller's rate is the floor
// For a match, buyer must be willing to pay >= seller's ask
if (buyRate < sellRate) return null;
const agreedRate = (buyRate + sellRate) / 2; // midpoint
// 2. Amount overlap
const overlapMin = Math.max(buy.tokenAmountMin, sell.tokenAmountMin);
const overlapMax = Math.min(buy.tokenAmountMax, sell.tokenAmountMax);
if (overlapMax < overlapMin) return null;
const agreedAmount = overlapMax; // fill as much as possible
// 3. Reputation VPs
const buyerRep = getReputation(buy.creatorDid, reputationDoc);
const sellerRep = getReputation(sell.creatorDid, reputationDoc);
if (sell.minCounterpartyReputation != null && buyerRep.score < sell.minCounterpartyReputation) return null;
if (buy.minCounterpartyReputation != null && sellerRep.score < buy.minCounterpartyReputation) return null;
// Preferred counterparties (if set, counterparty must be in list)
if (buy.preferredCounterparties?.length && !buy.preferredCounterparties.includes(sell.creatorDid)) return null;
if (sell.preferredCounterparties?.length && !sell.preferredCounterparties.includes(buy.creatorDid)) return null;
// 4. Payment method overlap
const commonMethods = buy.paymentMethods.filter(m => sell.paymentMethods.includes(m));
if (commonMethods.length === 0) return null;
// 5. Scoring
// Rate mutualness: how much slack between buyer's max and seller's min (0 = barely, 1 = generous)
const rateSlack = marketRate > 0
? Math.min(1, (buyRate - sellRate) / (marketRate * 0.05)) // normalize to 5% of market
: 0.5;
// Amount balance: how well the agreed amount fills both sides
const buyFill = agreedAmount / buy.tokenAmountMax;
const sellFill = agreedAmount / sell.tokenAmountMax;
const amountBalance = (buyFill + sellFill) / 2;
// Reputation average (0-1)
const avgRep = ((buyerRep.score + sellerRep.score) / 2) / 100;
// LP priority: standing orders get a boost
const lpBoost = (buy.isStandingOrder || sell.isStandingOrder) ? 1.0 : 0.0;
const score = Number((
W_RATE * rateSlack +
W_AMOUNT * amountBalance +
W_REPUTATION * avgRep +
W_LP * lpBoost
).toFixed(4));
const fiatAmount = (agreedAmount / 1_000_000) * agreedRate;
return {
buyIntent: buy,
sellIntent: sell,
agreedAmount,
agreedRate,
fiatAmount,
paymentMethod: commonMethods[0],
score,
};
}