first ruggedy draft
This commit is contained in:
8
packages/matching/mousehold_matching/__init__.py
Normal file
8
packages/matching/mousehold_matching/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .fingerprints import compute_normalized_transaction_fingerprint, compute_raw_observation_hash
|
||||
from .scoring import score_event_match
|
||||
|
||||
__all__ = [
|
||||
"compute_normalized_transaction_fingerprint",
|
||||
"compute_raw_observation_hash",
|
||||
"score_event_match",
|
||||
]
|
||||
64
packages/matching/mousehold_matching/fingerprints.py
Normal file
64
packages/matching/mousehold_matching/fingerprints.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _stable_default(value: Any) -> str:
|
||||
if isinstance(value, datetime | date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _stable_json(value: Any) -> str:
|
||||
return json.dumps(
|
||||
value, default=_stable_default, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
|
||||
|
||||
def compute_raw_observation_hash(
|
||||
*,
|
||||
source_type: str,
|
||||
source_reference: str | None = None,
|
||||
raw_text: str | None = None,
|
||||
raw_payload: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
payload = {
|
||||
"source_reference": source_reference,
|
||||
"source_type": source_type,
|
||||
"raw_payload": raw_payload or {},
|
||||
"raw_text": raw_text or "",
|
||||
}
|
||||
return hashlib.sha256(_stable_json(payload).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_counterparty(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
compact = re.sub(r"[^a-z0-9]+", " ", value.lower())
|
||||
return re.sub(r"\s+", " ", compact).strip()
|
||||
|
||||
|
||||
def compute_normalized_transaction_fingerprint(
|
||||
*,
|
||||
amount: Decimal | str | int | float,
|
||||
currency: str,
|
||||
event_date: date | str,
|
||||
merchant: str | None = None,
|
||||
counterparty: str | None = None,
|
||||
external_id: str | None = None,
|
||||
) -> str:
|
||||
normalized = {
|
||||
"amount": str(Decimal(str(amount)).quantize(Decimal("0.01"))),
|
||||
"currency": currency.upper(),
|
||||
"event_date": event_date.isoformat() if isinstance(event_date, date) else event_date,
|
||||
"party": normalize_counterparty(merchant or counterparty),
|
||||
"external_id": external_id or "",
|
||||
}
|
||||
return hashlib.sha256(_stable_json(normalized).encode("utf-8")).hexdigest()
|
||||
40
packages/matching/mousehold_matching/scoring.py
Normal file
40
packages/matching/mousehold_matching/scoring.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from .fingerprints import normalize_counterparty
|
||||
|
||||
|
||||
def _date_distance(a: date, b: date) -> int:
|
||||
return abs((a - b).days)
|
||||
|
||||
|
||||
def score_event_match(
|
||||
*,
|
||||
amount_a: Decimal,
|
||||
amount_b: Decimal,
|
||||
currency_a: str,
|
||||
currency_b: str,
|
||||
date_a: date,
|
||||
date_b: date,
|
||||
party_a: str | None,
|
||||
party_b: str | None,
|
||||
) -> float:
|
||||
if currency_a.upper() != currency_b.upper():
|
||||
return 0.0
|
||||
|
||||
amount_score = (
|
||||
1.0
|
||||
if amount_a == amount_b
|
||||
else max(0.0, 1.0 - (abs(amount_a - amount_b) / max(abs(amount_a), Decimal("1"))))
|
||||
)
|
||||
days = _date_distance(date_a, date_b)
|
||||
date_score = 1.0 if days == 0 else max(0.0, 1.0 - (days / 14))
|
||||
party_score = SequenceMatcher(
|
||||
None,
|
||||
normalize_counterparty(party_a),
|
||||
normalize_counterparty(party_b),
|
||||
).ratio()
|
||||
return round((amount_score * 0.45) + (date_score * 0.30) + (party_score * 0.25), 4)
|
||||
Reference in New Issue
Block a user