@accounter/server
Version:
Accounter GraphQL server
114 lines • 5.19 kB
JavaScript
import { dateToTimelessDateString } from '../../../shared/helpers/misc.js';
/**
* `accounter_schema.poalim_securities_transactions` carries no link to
* `accounter_schema.transactions` — the scrape has no per-execution id and the bank never
* cross-references the cash leg. Pairing is therefore derived, on three axes:
*
* 1. the security key the transaction description carries (the caller already grouped by it),
* 2. the Poalim account tuple (bank/branch/account) behind the transaction's `account_id`,
* 3. a date within a few days of the transaction, **and** a matching amount.
*
* The amount is what makes this safe: a security can be traded several times on the same day,
* so date alone would attach every one of them to every cash movement. A candidate that falls
* in the window but matches no amount is dropped rather than shown as a maybe.
*/
/** How far apart an execution date and a transaction date may be and still pair up. */
export const DEFAULT_DATE_WINDOW_DAYS = 5;
/**
* Absolute tolerance on the amount compare. The values come out of Postgres `numeric` as
* decimal strings, so this only absorbs the bank's own rounding, not float drift.
*/
export const DEFAULT_AMOUNT_TOLERANCE = 0.01;
const MS_PER_DAY = 86_400_000;
/**
* Both sides are calendar dates stored as `DATE` and parsed back to *local* midnight, so
* subtracting the raw timestamps drifts by an hour across a DST boundary. Going through the
* timeless string and re-reading it as UTC keeps the day count exact.
*/
function toDayNumber(date) {
return Date.parse(`${dateToTimelessDateString(date)}T00:00:00Z`) / MS_PER_DAY;
}
function withinDays(a, b, windowDays) {
return Math.abs(toDayNumber(a) - toDayNumber(b)) <= windowDays;
}
/** Sign is direction, which the two sources express differently; compare magnitudes. */
function amountsMatch(a, b, tolerance) {
const left = Math.abs(Number(a));
const right = Math.abs(Number(b));
if (Number.isNaN(left) || Number.isNaN(right)) {
return false;
}
return Math.abs(left - right) <= tolerance;
}
function transactionDates(transaction) {
const effectiveDebitDate = transaction.debit_date_override ?? transaction.debit_date;
return effectiveDebitDate
? [transaction.event_date, effectiveDebitDate]
: [transaction.event_date];
}
function executionDates(execution) {
return [
execution.trade_date,
execution.value_date,
execution.settlement_date,
execution.payment_date,
].filter((date) => date != null);
}
/**
* The cash leg can be booked in the trade, settlement or reporting currency depending on the
* action, so all three net values are candidates for the amount compare.
*/
function executionAmounts(execution) {
return [
execution.net_value_trade_currency,
execution.net_value_settlement_currency,
execution.net_value_nis,
].filter((amount) => amount != null);
}
function matchesTransaction(execution, transaction, accountTuple, dateWindowDays, amountTolerance) {
if (execution.bank_number !== accountTuple.bankNumber ||
execution.branch_number !== accountTuple.branchNumber ||
execution.account_number !== accountTuple.accountNumber) {
return false;
}
const dateMatches = executionDates(execution).some(executionDate => transactionDates(transaction).some(transactionDate => withinDays(executionDate, transactionDate, dateWindowDays)));
if (!dateMatches) {
return false;
}
return executionAmounts(execution).some(amount => amountsMatch(amount, transaction.amount, amountTolerance));
}
/**
* Pairs the charge's transactions with the ingested executions and returns the matched
* executions grouped by security key.
*
* `accountTuples` maps a transaction's `account_id` to its Poalim identity; transactions whose
* account is missing from the map (not a bank account, or not yet backfilled) are skipped —
* without the tuple there is no way to tell one portfolio from another.
*/
export function matchSecurityExecutions(transactions, executions, accountTuples, options = {}) {
const dateWindowDays = options.dateWindowDays ?? DEFAULT_DATE_WINDOW_DAYS;
const amountTolerance = options.amountTolerance ?? DEFAULT_AMOUNT_TOLERANCE;
const matchedBySecurity = new Map();
for (const execution of executions) {
const matched = transactions.some(transaction => {
const accountTuple = accountTuples.get(transaction.account_id);
return (accountTuple != null &&
matchesTransaction(execution, transaction, accountTuple, dateWindowDays, amountTolerance));
});
if (!matched) {
continue;
}
const group = matchedBySecurity.get(execution.security);
if (group) {
group.push(execution);
}
else {
matchedBySecurity.set(execution.security, [execution]);
}
}
for (const group of matchedBySecurity.values()) {
group.sort((a, b) => a.trade_date.getTime() - b.trade_date.getTime());
}
return matchedBySecurity;
}
//# sourceMappingURL=match-security-executions.helper.js.map