@1aboveio/skills 0.20.2 → 0.20.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +220 -25
- package/runtime/skills/distribution/scripts/bundles.mjs +167 -48
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/compliance/compliance-audit/LICENSE +3 -0
- package/skills/compliance/compliance-audit/SKILL.md +466 -0
- package/skills/compliance/compliance-audit/references/findings-schema.md +290 -0
- package/skills/compliance/compliance-audit/references/vars-checklist.md +1068 -0
- package/skills/compliance/compliance-audit/references/vars-findings-schema.md +395 -0
- package/skills/compliance/compliance-audit/scripts/reconcile_tracker.py +454 -0
- package/skills/compliance/compliance-audit/scripts/test_validate_tracker.py +90 -0
- package/skills/compliance/compliance-audit/scripts/validate_tracker.py +752 -0
- package/skills/compliance/doc-authenticity/LICENSE +3 -0
- package/skills/compliance/doc-authenticity/SKILL.md +158 -0
- package/skills/compliance/doc-authenticity/references/agreement.md +66 -0
- package/skills/compliance/doc-authenticity/references/bank-statement.md +150 -0
- package/skills/compliance/doc-authenticity/references/forensic-signals.md +157 -0
- package/skills/compliance/doc-authenticity/references/image-signals.md +114 -0
- package/skills/compliance/doc-authenticity/references/invoice.md +66 -0
- package/skills/compliance/doc-authenticity/references/issuer-profiles.md +86 -0
- package/skills/compliance/doc-authenticity/references/verdict-and-report.md +95 -0
- package/skills/compliance/doc-authenticity/references/verdict.schema.json +81 -0
- package/skills/compliance/doc-authenticity/scripts/image_forensics.py +294 -0
- package/skills/compliance/doc-authenticity/scripts/pdf_forensics.py +401 -0
- package/skills/compliance/doc-authenticity/scripts/validate_codes.py +224 -0
- package/skills/compliance/doc-authenticity/scripts/validate_verdict.py +308 -0
- package/skills/compliance/report-writing/LICENSE +3 -0
- package/skills/compliance/report-writing/SKILL.md +206 -0
- package/skills/compliance/report-writing/references/audit-report-template.md +121 -0
- package/skills/compliance/report-writing/references/external-assessment-template.md +167 -0
- package/skills/compliance/report-writing/references/management-report-template.md +145 -0
- package/skills/compliance/report-writing/references/vars-report-template.md +224 -0
- package/skills/document-utils/doc-to-md/LICENSE +3 -0
- package/skills/document-utils/doc-to-md/SKILL.md +77 -0
- package/skills/document-utils/doc-to-md/scripts/convert.py +157 -0
- package/skills/document-utils/reg-parser/LICENSE +3 -0
- package/skills/document-utils/reg-parser/SKILL.md +201 -0
- package/skills/document-utils/reg-parser/references/checklist-schema.md +223 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.json +1306 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.md +469 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.json +634 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.md +233 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.json +2194 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.md +771 -0
- package/skills/document-utils/reg-parser/references/checklists/kyc-aml-checklist.md +258 -0
- package/skills/document-utils/reg-parser/references/checklists/visa-vars-checklist.md +1058 -0
- package/skills/document-utils/reg-parser/references/mandates/hk-amlo-cap615.md +389 -0
- package/skills/document-utils/reg-parser/references/mandates/visa-vars.md +205 -0
- package/skills/document-utils/reg-parser/scripts/compare_checklist.py +228 -0
- package/skills/document-utils/reg-parser/scripts/dump_checklist.py +348 -0
- package/skills/document-utils/reg-parser/scripts/preprocess-mandate.sh +171 -0
- package/skills/document-utils/reg-parser/scripts/test_quarantine.sh +102 -0
- package/skills/document-utils/reg-parser/scripts/validate_checklist.py +532 -0
- package/skills/document-utils/reg-parser/scripts/validate_mandate.py +242 -0
- package/skills/engineering/engineering-runtime/coherence/workflow.json +16 -16
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/payment/similar-domain/LICENSE +3 -0
- package/skills/payment/similar-domain/SKILL.md +178 -0
- package/skills/payment/similar-domain/references/entities.md +58 -0
- package/skills/payment/similar-domain/references/fingerprints.md +82 -0
- package/skills/payment/similar-domain/references/pivots.md +66 -0
- package/skills/payment/similar-domain/references/report-template.md +76 -0
- package/skills/payment/similar-domain/references/scoring.md +61 -0
- package/skills/payment/similar-domain/references/terminology.md +37 -0
- package/skills/payment/similar-domain/references/workflow.md +79 -0
- package/skills/payment/tld-detection/SKILL.md +76 -21
- package/skills/payment/tld-detection/references/clean.md +1 -1
- package/skills/payment/tld-detection/references/clustering.md +21 -6
- package/skills/payment/tld-detection/references/decision.md +7 -2
- package/skills/payment/tld-detection/references/fx-conversion.md +229 -0
- package/skills/payment/tld-detection/references/report-template.md +63 -27
- package/skills/payment/tld-detection/references/streams.md +90 -0
- package/skills/payment/tld-detection/references/terminology.md +15 -7
- package/skills/payment/tld-detection/references/workflow.md +1 -1
- package/skills/payment/tld-detection/scripts/detect_streams.py +542 -0
- package/skills/engineering/ensure-coverage/evals/evals.json +0 -125
- package/skills/engineering/rush-issues/evals/evals.json +0 -65
- package/skills/engineering/rush-release/evals/evals.json +0 -58
- package/skills/fullstack/shadcn/evals/evals.json +0 -90
- package/skills/payment/fraud-analysis/evals/evals.json +0 -40
- package/skills/payment/tld-detection/evals/evals.json +0 -107
- package/skills/payment/tld-detection/tests/test_detect_ladders.py +0 -132
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Infer upstream streams inside a descriptor: wallet currency, preset menu, frozen-rate batches, retail lists.
|
|
3
|
+
|
|
4
|
+
Discovery layer that runs before exact currency-amount-pair clustering. It reads a cleaned
|
|
5
|
+
authorization CSV plus a daily FX cache and, per descriptor and BIN-country group, reports:
|
|
6
|
+
|
|
7
|
+
* inferred wallet currency (the currency under which charges collapse onto the fewest peaks)
|
|
8
|
+
* the preset menu (peak set) and which known scheme it matches (10-scheme, 12-scheme)
|
|
9
|
+
* rate consistency: whether a group's rungs drift with the market (live) or sit on one frozen rate
|
|
10
|
+
* a retail-list constant (list prices ending .99/.95/.98/.00 times one factor)
|
|
11
|
+
* off-menu groups (no peak structure)
|
|
12
|
+
* behaviour metrics: repeat cards, retry after decline, burst regularity, hour concentration,
|
|
13
|
+
address verification, 3-D Secure, prepaid share, issuer-program concentration
|
|
14
|
+
|
|
15
|
+
Every threshold is a CLI option and is echoed in the output. Stdlib only.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import csv
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
import random
|
|
24
|
+
import statistics
|
|
25
|
+
import sys
|
|
26
|
+
import urllib.request
|
|
27
|
+
from collections import Counter, defaultdict
|
|
28
|
+
from datetime import date, datetime, timedelta
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
PEGS = {"USD": 1.0, "SAR": 3.75, "AED": 3.6725, "QAR": 3.64, "KWD": 0.3066, "BHD": 0.376, "OMR": 0.3845, "HKD": 7.8, "JOD": 0.709}
|
|
32
|
+
|
|
33
|
+
COUNTRY_CURRENCY = {
|
|
34
|
+
"united states of america": "USD", "united states": "USD", "us": "USD", "usa": "USD",
|
|
35
|
+
"united kingdom": "GBP", "gb": "GBP", "uk": "GBP", "australia": "AUD", "new zealand": "NZD", "canada": "CAD",
|
|
36
|
+
"switzerland": "CHF", "poland": "PLN", "czech republic": "CZK", "czechia": "CZK", "hungary": "HUF", "norway": "NOK",
|
|
37
|
+
"sweden": "SEK", "denmark": "DKK", "romania": "RON", "bulgaria": "BGN", "iceland": "ISK", "brazil": "BRL",
|
|
38
|
+
"mexico": "MXN", "japan": "JPY", "india": "INR", "south africa": "ZAR", "turkey": "TRY", "israel": "ILS",
|
|
39
|
+
"singapore": "SGD", "hong kong": "HKD", "korea, republic of": "KRW", "south korea": "KRW", "thailand": "THB",
|
|
40
|
+
"malaysia": "MYR", "philippines": "PHP", "indonesia": "IDR", "saudi arabia": "SAR", "united arab emirates": "AED",
|
|
41
|
+
"qatar": "QAR", "kuwait": "KWD", "bahrain": "BHD", "oman": "OMR", "jordan": "JOD",
|
|
42
|
+
}
|
|
43
|
+
EURO_COUNTRIES = {
|
|
44
|
+
"germany", "italy", "france", "spain", "portugal", "greece", "slovakia", "finland", "ireland", "austria",
|
|
45
|
+
"netherlands", "belgium", "lithuania", "croatia", "slovenia", "estonia", "latvia", "cyprus", "luxembourg", "malta",
|
|
46
|
+
"moldova, republic of", "moldova", "montenegro", "kosovo",
|
|
47
|
+
}
|
|
48
|
+
for _c in EURO_COUNTRIES:
|
|
49
|
+
COUNTRY_CURRENCY[_c] = "EUR"
|
|
50
|
+
|
|
51
|
+
SCHEMES = {
|
|
52
|
+
"10": [5, 10, 15, 20, 25, 30, 40, 50, 60, 75, 80, 100, 150, 200, 250, 300, 400, 500, 750, 1000, 1500, 2000],
|
|
53
|
+
"12": [12, 24, 36, 48, 72, 96, 108, 144, 192, 288, 384, 480],
|
|
54
|
+
}
|
|
55
|
+
SCHEME_SCALES = [1, 5, 10, 25, 100, 400, 1000]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ----------------------------------------------------------------------------- rates
|
|
59
|
+
|
|
60
|
+
def fetch_rates(start: str, end: str, base: str, symbols: list[str]) -> dict:
|
|
61
|
+
url = f"https://api.frankfurter.dev/v1/{start}..{end}?base={base}&symbols={','.join(sorted(set(symbols)))}"
|
|
62
|
+
with urllib.request.urlopen(url, timeout=60) as resp: # noqa: S310 - explicit user action
|
|
63
|
+
payload = json.load(resp)
|
|
64
|
+
return {"base": base, "source": url, "rates": payload["rates"]}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Rates:
|
|
68
|
+
"""Daily rates quoted as units of currency per one unit of base; prior-day carry up to ``carry`` days."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, cache: dict, carry: int = 7):
|
|
71
|
+
self.base = cache.get("base", "USD")
|
|
72
|
+
self.rates = cache["rates"]
|
|
73
|
+
self.days = sorted(self.rates)
|
|
74
|
+
self.carry = carry
|
|
75
|
+
self.carried: Counter = Counter()
|
|
76
|
+
|
|
77
|
+
def rate(self, day: str, cur: str) -> float | None:
|
|
78
|
+
if cur == self.base:
|
|
79
|
+
return 1.0
|
|
80
|
+
if cur in PEGS and self.base == "USD":
|
|
81
|
+
return PEGS[cur]
|
|
82
|
+
if not self.days:
|
|
83
|
+
return None
|
|
84
|
+
d = max(min(day, self.days[-1]), self.days[0])
|
|
85
|
+
for lag in range(self.carry + 1):
|
|
86
|
+
dd = (date.fromisoformat(d) - timedelta(days=lag)).isoformat()
|
|
87
|
+
if dd in self.rates and cur in self.rates[dd]:
|
|
88
|
+
if lag:
|
|
89
|
+
self.carried[cur] += 1
|
|
90
|
+
return self.rates[dd][cur]
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def market_move(self, cur: str, day_a: str, day_b: str) -> float | None:
|
|
94
|
+
a, b = self.rate(day_a, cur), self.rate(day_b, cur)
|
|
95
|
+
if not a or not b:
|
|
96
|
+
return None
|
|
97
|
+
return abs(b / a - 1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ----------------------------------------------------------------------------- helpers
|
|
101
|
+
|
|
102
|
+
def find_peaks(values: list[float], tol: float, min_share: float) -> list[tuple[float, int]]:
|
|
103
|
+
"""Group values within ±tol (relative) of the modal value, largest first; return (center, count)."""
|
|
104
|
+
if not values:
|
|
105
|
+
return []
|
|
106
|
+
counts = Counter(round(v, 2) for v in values)
|
|
107
|
+
n = len(values)
|
|
108
|
+
used: set[float] = set()
|
|
109
|
+
peaks = []
|
|
110
|
+
for v, _ in counts.most_common():
|
|
111
|
+
if v in used:
|
|
112
|
+
continue
|
|
113
|
+
grp = [w for w in counts if w not in used and abs(w - v) <= tol * max(v, 1e-9)]
|
|
114
|
+
cnt = sum(counts[w] for w in grp)
|
|
115
|
+
if cnt / n < min_share:
|
|
116
|
+
continue
|
|
117
|
+
used.update(grp)
|
|
118
|
+
center = sum(w * counts[w] for w in grp) / cnt
|
|
119
|
+
peaks.append((center, cnt))
|
|
120
|
+
return sorted(peaks, key=lambda p: -p[1])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def coverage(values: list[float], tol: float, min_share: float, top_k: int) -> tuple[float, list[tuple[float, int]]]:
|
|
124
|
+
peaks = find_peaks(values, tol, min_share)[:top_k]
|
|
125
|
+
return (sum(c for _, c in peaks) / len(values) if values else 0.0), peaks
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def coverage_against(values: list[float], centers: list[float], tol: float) -> float:
|
|
129
|
+
"""Share of values within tol of any given centre (used for the jittered control)."""
|
|
130
|
+
if not values or not centers:
|
|
131
|
+
return 0.0
|
|
132
|
+
return sum(1 for v in values if any(abs(v - c) <= tol * c for c in centers)) / len(values)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def day_drift(rows: list[dict], converted: list[float], centers: list[tuple[float, int]], tol: float, min_per_day: int = 3) -> float | None:
|
|
136
|
+
"""Weighted mean, over the top peaks, of (max - min) / centre of the daily median converted value.
|
|
137
|
+
|
|
138
|
+
Under the true wallet currency a preset converts to the same local value every day; under a wrong
|
|
139
|
+
currency the value follows the cross rate day by day."""
|
|
140
|
+
total = 0.0
|
|
141
|
+
weight = 0
|
|
142
|
+
for center, cnt in centers[:5]:
|
|
143
|
+
per_day: dict[str, list[float]] = defaultdict(list)
|
|
144
|
+
for r, v in zip(rows, converted):
|
|
145
|
+
if abs(v - center) <= tol * center:
|
|
146
|
+
per_day[r["day"]].append(v)
|
|
147
|
+
meds = [statistics.median(v) for v in per_day.values() if len(v) >= min_per_day]
|
|
148
|
+
if len(meds) < 2:
|
|
149
|
+
continue
|
|
150
|
+
total += (max(meds) - min(meds)) / center * cnt
|
|
151
|
+
weight += cnt
|
|
152
|
+
return total / weight if weight else None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def jitter(values: list[float], rng: random.Random, lo: float, hi: float) -> list[float]:
|
|
156
|
+
return [v * (1 + rng.choice((-1, 1)) * rng.uniform(lo, hi)) for v in values]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def retail_shaped(x: float, tol: float = 0.006) -> bool:
|
|
160
|
+
r = round(x)
|
|
161
|
+
return any(g > 0 and abs(x - g) <= tol for g in (r, r - 0.01, r - 0.05, r - 0.02))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def match_scheme(peaks: list[tuple[float, int]], tol: float) -> tuple[str, int, float]:
|
|
165
|
+
"""Return (scheme name, scale, share of peak attempts on the scheme)."""
|
|
166
|
+
name, scale, share, _ = match_scheme_peaks(peaks, tol)
|
|
167
|
+
return name, scale, share
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def match_scheme_peaks(peaks: list[tuple[float, int]], tol: float) -> tuple[str, int, float, list[tuple[float, int]]]:
|
|
171
|
+
total = sum(c for _, c in peaks) or 1
|
|
172
|
+
best = ("none", 1, 0.0, [])
|
|
173
|
+
for name, menu in SCHEMES.items():
|
|
174
|
+
for scale in SCHEME_SCALES:
|
|
175
|
+
on = [(center, cnt) for center, cnt in peaks if any(abs(center - m * scale) <= tol * m * scale for m in menu)]
|
|
176
|
+
share = sum(c for _, c in on) / total
|
|
177
|
+
if share > best[2]:
|
|
178
|
+
best = (name, scale, share, on)
|
|
179
|
+
return best
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ----------------------------------------------------------------------------- core
|
|
183
|
+
|
|
184
|
+
def parse_rows(path: Path, cols: dict) -> list[dict]:
|
|
185
|
+
rows = []
|
|
186
|
+
with path.open(newline="", encoding="utf-8-sig") as fh:
|
|
187
|
+
for r in csv.DictReader(fh):
|
|
188
|
+
try:
|
|
189
|
+
amt = float(r[cols["amount"]])
|
|
190
|
+
except (KeyError, ValueError):
|
|
191
|
+
continue
|
|
192
|
+
ts = r.get(cols["date"], "")[:19]
|
|
193
|
+
rows.append({
|
|
194
|
+
"amount": amt,
|
|
195
|
+
"currency": r.get(cols["currency"], "USD").strip().upper() or "USD",
|
|
196
|
+
"day": ts[:10],
|
|
197
|
+
"ts": ts,
|
|
198
|
+
"descriptor": (r.get(cols["descriptor"], "") or "UNKNOWN_DESCRIPTOR").strip(),
|
|
199
|
+
"country": (r.get(cols["country"], "") or "UNKNOWN").strip(),
|
|
200
|
+
"response": (r.get(cols["response"], "") if cols.get("response") else "").strip(),
|
|
201
|
+
"account": (r.get(cols["account"], "") if cols.get("account") else "").strip(),
|
|
202
|
+
"card_type": (r.get(cols["card_type"], "") if cols.get("card_type") else "").strip(),
|
|
203
|
+
"avs": (r.get(cols["avs"], "") if cols.get("avs") else "").strip(),
|
|
204
|
+
"auth": (r.get(cols["auth"], "") if cols.get("auth") else "").strip(),
|
|
205
|
+
})
|
|
206
|
+
return rows
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def infer_wallet(rows: list[dict], rates: Rates, candidates: list[str], opt, rng) -> dict:
|
|
210
|
+
"""Per group: coverage under each candidate currency, control against the same centres, day drift."""
|
|
211
|
+
out = {}
|
|
212
|
+
for cur in candidates:
|
|
213
|
+
vals, ok = [], True
|
|
214
|
+
for r in rows:
|
|
215
|
+
rt = rates.rate(r["day"], cur)
|
|
216
|
+
src = rates.rate(r["day"], r["currency"])
|
|
217
|
+
if rt is None or src is None:
|
|
218
|
+
ok = False
|
|
219
|
+
break
|
|
220
|
+
vals.append(r["amount"] / src * rt)
|
|
221
|
+
if not ok or not vals:
|
|
222
|
+
continue
|
|
223
|
+
cov, peaks = coverage(vals, opt.peak_tol, opt.peak_min_share, opt.top_k)
|
|
224
|
+
ctrl = coverage_against(jitter(vals, rng, opt.jitter_lo, opt.jitter_hi), [c for c, _ in peaks], opt.peak_tol)
|
|
225
|
+
drift = day_drift(rows, vals, peaks, opt.peak_tol)
|
|
226
|
+
name, scale, sshare, on = match_scheme_peaks(peaks, opt.scheme_tol)
|
|
227
|
+
out[cur] = {"coverage": round(cov, 4), "control": round(ctrl, 4), "excess": round(cov - ctrl, 4), "day_drift": None if drift is None else round(drift, 4),
|
|
228
|
+
"peaks": [(round(c, 2), n) for c, n in peaks], "scheme": name, "scheme_scale": scale, "scheme_share": round(sshare, 4),
|
|
229
|
+
"scheme_coverage": round(sum(n for _, n in on) / len(vals), 4), "scheme_peaks": [(round(c, 2), n) for c, n in on]}
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def wallet_acceptable(info: dict, opt) -> bool:
|
|
234
|
+
if info["excess"] < opt.wallet_min_excess * 0.4:
|
|
235
|
+
return False
|
|
236
|
+
if info["coverage"] >= opt.wallet_min_coverage and info["excess"] >= opt.wallet_min_excess:
|
|
237
|
+
return True
|
|
238
|
+
return info["scheme_coverage"] >= opt.wallet_min_coverage_with_scheme and info["scheme_share"] >= opt.scheme_min_share
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def choose_wallet(walls: dict, legal: str | None, opt) -> str | None:
|
|
242
|
+
"""Prefer a currency whose peaks land on a known menu, then the lowest day drift, then legal tender, EUR, USD."""
|
|
243
|
+
ok = [(cur, info) for cur, info in walls.items() if wallet_acceptable(info, opt)]
|
|
244
|
+
if not ok:
|
|
245
|
+
return None
|
|
246
|
+
prio = {legal: 0, "EUR": 1, "USD": 2}
|
|
247
|
+
|
|
248
|
+
def key(kv):
|
|
249
|
+
cur, info = kv
|
|
250
|
+
drift = info["day_drift"] if info["day_drift"] is not None else 1.0
|
|
251
|
+
return (0 if info["scheme_share"] >= opt.scheme_min_share else 1, round(drift / opt.drift_bucket), prio.get(cur, 3), -info["coverage"])
|
|
252
|
+
return sorted(ok, key=key)[0][0]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def rate_consistency(rows: list[dict], rates: Rates, wallet: str, peaks: list, opt) -> dict:
|
|
256
|
+
"""For each peak, per day: modal processed amount. Frozen = same cents on >= frozen_days days while market moved."""
|
|
257
|
+
result = {"frozen_peaks": 0, "live_peaks": 0, "frozen_attempts": 0, "tested_attempts": 0, "detail": []}
|
|
258
|
+
for center, _ in peaks:
|
|
259
|
+
by_day: dict[str, Counter] = defaultdict(Counter)
|
|
260
|
+
for r in rows:
|
|
261
|
+
src = rates.rate(r["day"], r["currency"])
|
|
262
|
+
rt = rates.rate(r["day"], wallet)
|
|
263
|
+
if not src or not rt:
|
|
264
|
+
continue
|
|
265
|
+
local = r["amount"] / src * rt
|
|
266
|
+
if abs(local - center) <= opt.peak_tol * center:
|
|
267
|
+
by_day[r["day"]][round(r["amount"], 2)] += 1
|
|
268
|
+
days = sorted(d for d in by_day if sum(by_day[d].values()) >= opt.frozen_min_per_day)
|
|
269
|
+
if len(days) < opt.frozen_days:
|
|
270
|
+
continue
|
|
271
|
+
modal = [by_day[d].most_common(1)[0][0] for d in days]
|
|
272
|
+
attempts = sum(sum(by_day[d].values()) for d in days)
|
|
273
|
+
result["tested_attempts"] += attempts
|
|
274
|
+
move = rates.market_move(wallet, days[0], days[-1]) or 0.0
|
|
275
|
+
distinct = len(set(modal))
|
|
276
|
+
frozen = distinct == 1 and move >= opt.frozen_market_move and wallet != rates.base
|
|
277
|
+
if frozen:
|
|
278
|
+
result["frozen_peaks"] += 1
|
|
279
|
+
result["frozen_attempts"] += attempts
|
|
280
|
+
else:
|
|
281
|
+
result["live_peaks"] += 1
|
|
282
|
+
result["detail"].append({"peak": round(center, 2), "days": len(days), "modal_amounts": sorted(set(modal)), "market_move": round(move, 4), "frozen": frozen, "attempts": attempts})
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def retail_scan(rows: list[dict], opt, rng) -> dict:
|
|
287
|
+
"""Find one constant k such that amount / k is retail-shaped for a large share of rows, with many distinct bases."""
|
|
288
|
+
amounts = [r["amount"] for r in rows]
|
|
289
|
+
empty = {"constant": None, "share": 0.0, "control": 0.0, "excess": 0.0, "distinct_bases": 0, "rows": len(amounts)}
|
|
290
|
+
if len(amounts) < opt.retail_min_rows:
|
|
291
|
+
return empty
|
|
292
|
+
best = (1.0, 0.0)
|
|
293
|
+
k = opt.retail_k_min
|
|
294
|
+
while k <= opt.retail_k_max + 1e-9:
|
|
295
|
+
share = sum(retail_shaped(a / k) for a in amounts) / len(amounts)
|
|
296
|
+
if share > best[1]:
|
|
297
|
+
best = (round(k, 4), share)
|
|
298
|
+
k += opt.retail_k_step
|
|
299
|
+
bases = Counter(round(a / best[0], 2) for a in amounts if retail_shaped(a / best[0]))
|
|
300
|
+
distinct = sum(1 for _, c in bases.items() if c >= 3)
|
|
301
|
+
ctrl = sum(retail_shaped(a / best[0]) for a in jitter(amounts, rng, 0.004, 0.02)) / len(amounts)
|
|
302
|
+
return {"constant": best[0], "share": round(best[1], 4), "control": round(ctrl, 4), "excess": round(best[1] - ctrl, 4), "distinct_bases": distinct, "rows": len(amounts)}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def retail_passes(res: dict, opt, strong: bool = False) -> bool:
|
|
306
|
+
if not res["constant"] or res["excess"] < opt.retail_min_excess:
|
|
307
|
+
return False
|
|
308
|
+
if strong:
|
|
309
|
+
return res["share"] >= opt.retail_min_share and res["distinct_bases"] >= opt.retail_strong_distinct
|
|
310
|
+
return res["share"] >= opt.retail_weak_share and res["distinct_bases"] >= opt.retail_min_distinct
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def behaviour(rows: list[dict], opt) -> dict:
|
|
314
|
+
n = len(rows)
|
|
315
|
+
acc = Counter(r["account"] for r in rows if r["account"])
|
|
316
|
+
repeat = sum(1 for v in acc.values() if v >= 2) / len(acc) if acc else None
|
|
317
|
+
# retry after decline within window
|
|
318
|
+
by_acc: dict[str, list] = defaultdict(list)
|
|
319
|
+
for r in rows:
|
|
320
|
+
if r["account"] and r["ts"]:
|
|
321
|
+
by_acc[r["account"]].append(r)
|
|
322
|
+
declines = retries = 0
|
|
323
|
+
approvals = {v for v in opt.approval_values}
|
|
324
|
+
for lst in by_acc.values():
|
|
325
|
+
lst.sort(key=lambda r: r["ts"])
|
|
326
|
+
for i, r in enumerate(lst):
|
|
327
|
+
if opt.response_column and r["response"] not in approvals:
|
|
328
|
+
declines += 1
|
|
329
|
+
if i + 1 < len(lst):
|
|
330
|
+
try:
|
|
331
|
+
gap = (datetime.fromisoformat(lst[i + 1]["ts"]) - datetime.fromisoformat(r["ts"])).total_seconds()
|
|
332
|
+
except ValueError:
|
|
333
|
+
continue
|
|
334
|
+
if gap <= opt.retry_window_seconds:
|
|
335
|
+
retries += 1
|
|
336
|
+
hours = Counter(int(r["ts"][11:13]) for r in rows if len(r["ts"]) >= 13)
|
|
337
|
+
top3 = sum(v for _, v in hours.most_common(3)) / n if hours else None
|
|
338
|
+
# burst regularity: coefficient of variation of gaps inside the busiest hour-day
|
|
339
|
+
gaps = []
|
|
340
|
+
if hours:
|
|
341
|
+
buckets: dict[str, list] = defaultdict(list)
|
|
342
|
+
for r in rows:
|
|
343
|
+
if len(r["ts"]) >= 13:
|
|
344
|
+
buckets[r["ts"][:13]].append(r["ts"])
|
|
345
|
+
busiest = max(buckets.values(), key=len)
|
|
346
|
+
ts = sorted(datetime.fromisoformat(t) for t in busiest if len(t) >= 19)
|
|
347
|
+
gaps = [(ts[i + 1] - ts[i]).total_seconds() for i in range(len(ts) - 1)]
|
|
348
|
+
cv = (statistics.pstdev(gaps) / statistics.mean(gaps)) if len(gaps) >= 5 and statistics.mean(gaps) > 0 else None
|
|
349
|
+
avs = sum(1 for r in rows if r["avs"] and "not checked" not in r["avs"].lower()) / n if opt.avs_column else None
|
|
350
|
+
tds = sum(1 for r in rows if r["auth"] and r["auth"].lower() not in ("none", "", "n/a")) / n if opt.auth_column else None
|
|
351
|
+
prepaid = sum(1 for r in rows if "prepaid" in r["card_type"].lower()) / n if opt.card_type_column else None
|
|
352
|
+
bins = Counter(r["account"][:6] for r in rows if len(r["account"]) >= 6)
|
|
353
|
+
top5 = sum(v for _, v in bins.most_common(5)) / n if bins else None
|
|
354
|
+
approved = sum(1 for r in rows if r["response"] in approvals) / n if opt.response_column else None
|
|
355
|
+
fraud = sum(1 for r in rows if r["response"] in set(opt.suspected_fraud_values)) / n if opt.response_column else None
|
|
356
|
+
return {
|
|
357
|
+
"attempts": n, "cards": len(acc) or None, "repeat_card_share": repeat,
|
|
358
|
+
"retry_after_decline": (retries / declines) if declines else None, "declines": declines,
|
|
359
|
+
"top3_hour_share_utc": top3, "burst_gap_cv": cv, "avs_present_share": avs, "three_ds_share": tds,
|
|
360
|
+
"prepaid_share": prepaid, "top5_bin_share": top5, "approval_rate": approved, "suspected_fraud_share": fraud,
|
|
361
|
+
"first_day": min(r["day"] for r in rows), "last_day": max(r["day"] for r in rows),
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def on_peak_rows(rows: list[dict], rates: Rates, wallet: str, centers: list[float], tol: float) -> tuple[list[dict], list[dict]]:
|
|
366
|
+
inside, outside = [], []
|
|
367
|
+
for r in rows:
|
|
368
|
+
src = rates.rate(r["day"], r["currency"])
|
|
369
|
+
rt = rates.rate(r["day"], wallet)
|
|
370
|
+
if src and rt and any(abs(r["amount"] / src * rt - c) <= tol * c for c in centers):
|
|
371
|
+
inside.append(r)
|
|
372
|
+
else:
|
|
373
|
+
outside.append(r)
|
|
374
|
+
return inside, outside
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def extract_substreams(grows: list[dict], legal: str | None, rates: Rates, opt, rng) -> list[dict]:
|
|
378
|
+
"""Peel streams off a country group: menu (wallet + scheme), retail list, then remainder as off-menu."""
|
|
379
|
+
subs = []
|
|
380
|
+
residual = list(grows)
|
|
381
|
+
for _round in range(opt.max_substreams):
|
|
382
|
+
if len(residual) < opt.min_group:
|
|
383
|
+
break
|
|
384
|
+
retail = retail_scan(residual, opt, rng)
|
|
385
|
+
if retail_passes(retail, opt, strong=True):
|
|
386
|
+
k = retail["constant"]
|
|
387
|
+
inside = [r for r in residual if retail_shaped(r["amount"] / k)]
|
|
388
|
+
residual = [r for r in residual if not retail_shaped(r["amount"] / k)]
|
|
389
|
+
subs.append({"stream": f"retail x{k}", "rows": inside, "retail": retail})
|
|
390
|
+
continue
|
|
391
|
+
cands = [c for c in dict.fromkeys([legal, *opt.candidate_currencies]) if c]
|
|
392
|
+
walls = infer_wallet(residual, rates, cands, opt, rng)
|
|
393
|
+
wallet = choose_wallet(walls, legal, opt)
|
|
394
|
+
if wallet:
|
|
395
|
+
info = walls[wallet]
|
|
396
|
+
scheme, scale, sshare = info["scheme"], info["scheme_scale"], info["scheme_share"]
|
|
397
|
+
use_peaks = info["scheme_peaks"] if sshare >= opt.scheme_min_share else info["peaks"]
|
|
398
|
+
inside, outside = on_peak_rows(residual, rates, wallet, [c for c, _ in use_peaks], opt.peak_tol)
|
|
399
|
+
cons = rate_consistency(inside, rates, wallet, use_peaks, opt)
|
|
400
|
+
frozen = bool(cons["tested_attempts"] and cons["frozen_attempts"] / cons["tested_attempts"] >= opt.frozen_share)
|
|
401
|
+
label = f"{wallet} menu-{scheme if sshare >= opt.scheme_min_share else 'none'}{' frozen' if frozen else ' live'}"
|
|
402
|
+
subs.append({"stream": label, "rows": inside, "wallet": wallet, "coverage": info["coverage"], "day_drift": info["day_drift"], "scheme": scheme,
|
|
403
|
+
"scheme_scale": scale, "scheme_share": round(sshare, 3), "peaks": use_peaks, "all_peaks": info["peaks"], "frozen": frozen, "rate_consistency": cons,
|
|
404
|
+
"alternatives": {c: i["coverage"] for c, i in walls.items() if c != wallet}})
|
|
405
|
+
residual = outside
|
|
406
|
+
continue
|
|
407
|
+
if retail_passes(retail, opt):
|
|
408
|
+
k = retail["constant"]
|
|
409
|
+
inside = [r for r in residual if retail_shaped(r["amount"] / k)]
|
|
410
|
+
residual = [r for r in residual if not retail_shaped(r["amount"] / k)]
|
|
411
|
+
subs.append({"stream": f"retail x{k}", "rows": inside, "retail": retail})
|
|
412
|
+
continue
|
|
413
|
+
break
|
|
414
|
+
if residual:
|
|
415
|
+
subs.append({"stream": "off-menu", "rows": residual})
|
|
416
|
+
return subs
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def analyse(rows: list[dict], rates: Rates, opt) -> dict:
|
|
420
|
+
rng = random.Random(opt.seed)
|
|
421
|
+
report = {"parameters": {k: v for k, v in vars(opt).items() if k not in ("csv_path", "output", "markdown", "fetch_rates", "rates")}, "descriptors": []}
|
|
422
|
+
by_desc: dict[str, list] = defaultdict(list)
|
|
423
|
+
for r in rows:
|
|
424
|
+
by_desc[r["descriptor"]].append(r)
|
|
425
|
+
for desc, drows in sorted(by_desc.items(), key=lambda x: -len(x[1])):
|
|
426
|
+
entry = {"descriptor": desc, "attempts": len(drows), "groups": [], "streams": []}
|
|
427
|
+
by_ctry: dict[str, list] = defaultdict(list)
|
|
428
|
+
for r in drows:
|
|
429
|
+
by_ctry[r["country"]].append(r)
|
|
430
|
+
stream_rows: dict[str, list] = defaultdict(list)
|
|
431
|
+
small = 0
|
|
432
|
+
for ctry, grows in sorted(by_ctry.items(), key=lambda x: -len(x[1])):
|
|
433
|
+
if len(grows) < opt.min_group:
|
|
434
|
+
small += len(grows)
|
|
435
|
+
stream_rows["small groups"].extend(grows)
|
|
436
|
+
continue
|
|
437
|
+
legal = COUNTRY_CURRENCY.get(ctry.lower())
|
|
438
|
+
subs = extract_substreams(grows, legal, rates, opt, rng)
|
|
439
|
+
group = {"country": ctry, "attempts": len(grows), "legal_tender": legal, "substreams": []}
|
|
440
|
+
for sdef in subs:
|
|
441
|
+
srows = sdef.pop("rows")
|
|
442
|
+
sdef["attempts"] = len(srows)
|
|
443
|
+
sdef["share_of_group"] = round(len(srows) / len(grows), 3)
|
|
444
|
+
sdef["behaviour"] = behaviour(srows, opt)
|
|
445
|
+
group["substreams"].append(sdef)
|
|
446
|
+
stream_rows[sdef["stream"]].extend(srows)
|
|
447
|
+
entry["groups"].append(group)
|
|
448
|
+
for label, srows in sorted(stream_rows.items(), key=lambda kv: -len(kv[1])):
|
|
449
|
+
entry["streams"].append({"stream": label, "attempts": len(srows), "share_of_descriptor": round(len(srows) / len(drows), 3),
|
|
450
|
+
"countries": [c for c, _ in Counter(r["country"] for r in srows).most_common(6)], "behaviour": behaviour(srows, opt)})
|
|
451
|
+
report["descriptors"].append(entry)
|
|
452
|
+
report["rates_carried_days"] = dict(rates.carried)
|
|
453
|
+
return report
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def to_markdown(report: dict) -> str:
|
|
457
|
+
fmt = lambda v: "n/a" if v is None else f"{v:.0%}" # noqa: E731
|
|
458
|
+
out = ["# Stream inference", ""]
|
|
459
|
+
for d in report["descriptors"]:
|
|
460
|
+
out += [f"## {d['descriptor']} ({d['attempts']} attempts)", "", "| Stream | Attempts | Share | Countries | Approval | Suspected fraud | Retry after decline | Repeat cards | Top-3-hour share | AVS present |", "|---|---|---|---|---|---|---|---|---|---|"]
|
|
461
|
+
for s in d["streams"]:
|
|
462
|
+
b = s["behaviour"]
|
|
463
|
+
out.append(f"| {s['stream']} | {s['attempts']} | {s['share_of_descriptor']:.0%} | {', '.join(s['countries'])} | {fmt(b['approval_rate'])} | {fmt(b['suspected_fraud_share'])} | {fmt(b['retry_after_decline'])} | {fmt(b['repeat_card_share'])} | {fmt(b['top3_hour_share_utc'])} | {fmt(b['avs_present_share'])} |")
|
|
464
|
+
out += ["", "| Country | n | Sub-stream | Rows | Coverage | Day drift | Scheme share | Peaks |", "|---|---|---|---|---|---|---|---|"]
|
|
465
|
+
for g in d["groups"]:
|
|
466
|
+
for sdef in g["substreams"]:
|
|
467
|
+
peaks = " ".join(f"{c:g}x{n}" for c, n in sdef.get("peaks", [])[:6])
|
|
468
|
+
cov = sdef.get("coverage"); dr = sdef.get("day_drift"); ss = sdef.get("scheme_share")
|
|
469
|
+
out.append(f"| {g['country']} | {g['attempts']} | {sdef['stream']} | {sdef['attempts']} | {'' if cov is None else f'{cov:.0%}'} | {'' if dr is None else f'{dr:.2%}'} | {'' if ss is None else f'{ss:.0%}'} | {peaks} |")
|
|
470
|
+
out.append("")
|
|
471
|
+
return "\n".join(out)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def main(argv=None) -> None:
|
|
475
|
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
476
|
+
p.add_argument("csv_path", type=Path)
|
|
477
|
+
p.add_argument("--rates", type=Path, required=True, help="daily FX cache JSON: {base, rates:{YYYY-MM-DD:{CCY:rate}}}")
|
|
478
|
+
p.add_argument("--fetch-rates", nargs=2, metavar=("START", "END"), help="download Frankfurter v1 rates into --rates before analysis")
|
|
479
|
+
p.add_argument("--amount-column", required=True)
|
|
480
|
+
p.add_argument("--currency-column", required=True)
|
|
481
|
+
p.add_argument("--date-column", required=True, help="timestamp or date column (ISO)")
|
|
482
|
+
p.add_argument("--descriptor-column", required=True)
|
|
483
|
+
p.add_argument("--bin-country-column", required=True)
|
|
484
|
+
p.add_argument("--response-column")
|
|
485
|
+
p.add_argument("--approval-values", nargs="*", default=["A001"])
|
|
486
|
+
p.add_argument("--suspected-fraud-values", nargs="*", default=["D102"])
|
|
487
|
+
p.add_argument("--account-column")
|
|
488
|
+
p.add_argument("--card-type-column")
|
|
489
|
+
p.add_argument("--avs-column")
|
|
490
|
+
p.add_argument("--auth-column")
|
|
491
|
+
p.add_argument("--candidate-currencies", nargs="*", default=["EUR", "USD", "GBP", "CHF"])
|
|
492
|
+
p.add_argument("--min-group", type=int, default=80)
|
|
493
|
+
p.add_argument("--peak-tol", type=float, default=0.012)
|
|
494
|
+
p.add_argument("--peak-min-share", type=float, default=0.02)
|
|
495
|
+
p.add_argument("--top-k", type=int, default=10)
|
|
496
|
+
p.add_argument("--jitter-lo", type=float, default=0.02)
|
|
497
|
+
p.add_argument("--jitter-hi", type=float, default=0.05)
|
|
498
|
+
p.add_argument("--wallet-min-coverage", type=float, default=0.45, help="share of a group on its top-k peaks needed to call it menu-driven")
|
|
499
|
+
p.add_argument("--wallet-min-excess", type=float, default=0.25)
|
|
500
|
+
p.add_argument("--wallet-min-coverage-with-scheme", type=float, default=0.15, help="lower coverage bar when the peaks sit on a known menu")
|
|
501
|
+
p.add_argument("--drift-bucket", type=float, default=0.004, help="day-drift values within one bucket are ties broken by currency priority")
|
|
502
|
+
p.add_argument("--scheme-min-share", type=float, default=0.5)
|
|
503
|
+
p.add_argument("--scheme-tol", type=float, default=0.015, help="relative tolerance when matching peaks to a menu scheme (cashier markups run 0-3%%)")
|
|
504
|
+
p.add_argument("--max-substreams", type=int, default=3)
|
|
505
|
+
p.add_argument("--frozen-days", type=int, default=4)
|
|
506
|
+
p.add_argument("--frozen-min-per-day", type=int, default=3)
|
|
507
|
+
p.add_argument("--frozen-market-move", type=float, default=0.001)
|
|
508
|
+
p.add_argument("--frozen-share", type=float, default=0.5)
|
|
509
|
+
p.add_argument("--retail-k-min", type=float, default=1.0)
|
|
510
|
+
p.add_argument("--retail-k-max", type=float, default=1.15)
|
|
511
|
+
p.add_argument("--retail-k-step", type=float, default=0.0025)
|
|
512
|
+
p.add_argument("--retail-min-excess", type=float, default=0.2)
|
|
513
|
+
p.add_argument("--retail-min-share", type=float, default=0.3)
|
|
514
|
+
p.add_argument("--retail-min-distinct", type=int, default=5, help="distinct retail base prices (>=3 rows each) needed")
|
|
515
|
+
p.add_argument("--retail-strong-distinct", type=int, default=8, help="distinct bases for retail to be tried before wallet inference")
|
|
516
|
+
p.add_argument("--retail-weak-share", type=float, default=0.2)
|
|
517
|
+
p.add_argument("--retail-min-rows", type=int, default=60)
|
|
518
|
+
p.add_argument("--retry-window-seconds", type=int, default=1800)
|
|
519
|
+
p.add_argument("--seed", type=int, default=7)
|
|
520
|
+
p.add_argument("--output", type=Path)
|
|
521
|
+
p.add_argument("--markdown", type=Path)
|
|
522
|
+
opt = p.parse_args(argv)
|
|
523
|
+
cols = {"amount": opt.amount_column, "currency": opt.currency_column, "date": opt.date_column, "descriptor": opt.descriptor_column, "country": opt.bin_country_column,
|
|
524
|
+
"response": opt.response_column, "account": opt.account_column, "card_type": opt.card_type_column, "avs": opt.avs_column, "auth": opt.auth_column}
|
|
525
|
+
rows = parse_rows(opt.csv_path, cols)
|
|
526
|
+
if opt.fetch_rates:
|
|
527
|
+
symbols = sorted({c for c in COUNTRY_CURRENCY.values() if c not in PEGS} | set(opt.candidate_currencies))
|
|
528
|
+
cache = fetch_rates(opt.fetch_rates[0], opt.fetch_rates[1], "USD", [s for s in symbols if s != "USD"])
|
|
529
|
+
opt.rates.write_text(json.dumps(cache, indent=1))
|
|
530
|
+
rates = Rates(json.loads(opt.rates.read_text()))
|
|
531
|
+
report = analyse(rows, rates, opt)
|
|
532
|
+
text = json.dumps(report, indent=1, default=str)
|
|
533
|
+
if opt.output:
|
|
534
|
+
opt.output.write_text(text)
|
|
535
|
+
else:
|
|
536
|
+
sys.stdout.write(text + "\n")
|
|
537
|
+
if opt.markdown:
|
|
538
|
+
opt.markdown.write_text(to_markdown(report))
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
if __name__ == "__main__":
|
|
542
|
+
main()
|