Skip to content

Commit bdff821

Browse files
Merge pull request #503 from muhammad-muneeb3/issue-496-financial-ledger
Add synthetic financial ledger fixture with accounting negatives (#496)
2 parents e4a8819 + 951fcbc commit bdff821

9 files changed

Lines changed: 303 additions & 11 deletions

File tree

‎src/freshdata/engine/context.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
MIN_ROWS_FOR_ENGINE = 30
3232

3333
_ID_NAME = re.compile(r"(?:^|[_\s])(?:id|uuid|guid|key)s?$|^(?:id|index|pk)$", re.I)
34+
_MONEY_NAME = re.compile(
35+
r"price|amount|cost|salary|revenue|fee|balance|payment|charge|wage|income|"
36+
r"spend|budget|paid|total|usd|eur|inr|gbp|cash|fare",
37+
re.I,
38+
)
3439
_TARGET_NAMES = frozenset({"target", "label", "y", "outcome", "class", "response"})
3540
_TARGET_EXACT = frozenset({
3641
"aqi", "score", "rating", "churn", "default", "conversion", "label",
@@ -158,7 +163,12 @@ def infer_role(
158163
# Free text first: all-unique multi-word strings are prose, not keys.
159164
if _looks_like_text(s, nunique, non_null):
160165
return "text"
161-
if nunique is not None and non_null >= 20 and nunique == non_null:
166+
if (
167+
nunique is not None
168+
and non_null >= 20
169+
and nunique == non_null
170+
and not _MONEY_NAME.search(label)
171+
):
162172
return "id"
163173
return "categorical"
164174
# Mixed/object payloads we cannot reason about: treat as text (hands off).

‎src/freshdata/semantic/context.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def _build_info(
8181
currencies: tuple[str, ...] = (),
8282
n_nonnull_override: int | None = None,
8383
) -> SemanticColumnInfo:
84+
"""Build column semantic metadata from engine role and value distributions."""
8485
name = str(col)
8586
series = df[col]
8687
# On the native distinct path *series* holds only distinct values, so the
@@ -148,7 +149,11 @@ def _build_info(
148149
and (
149150
ctx.role == "id"
150151
or column_name_is_identifier(name)
151-
or (ctx.high_cardinality and ctx.role in ("text", "categorical"))
152+
or (
153+
ctx.high_cardinality
154+
and ctx.role in ("text", "categorical")
155+
and not bool(_MONEY_NAME.search(name))
156+
)
152157
)
153158
)
154159
)
@@ -173,8 +178,7 @@ def _build_info(
173178
)
174179
)
175180
money_like = (
176-
not free_text
177-
and not identifier_like
181+
not identifier_like
178182
and (
179183
semantic_type in ("money", "currency")
180184
or bool(_MONEY_NAME.search(name))

‎src/freshdata/semantic/experts.py‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def parse_number_words(text: str) -> int | None:
113113
#: consults this table. Deliberately small: a currency absent from it and not
114114
#: settled by structure is reported ambiguous rather than guessed.
115115
_COMMA_DECIMAL_CURRENCIES = frozenset({"EUR", "CHF"})
116+
_ACCOUNTING_NUM_RE = re.compile(r"^[+-]?\s*\d[\d\s.,']*$")
116117

117118

118119
def _valid_grouping(part: str, sep: str) -> bool:
@@ -215,17 +216,34 @@ def _split_amount(body: str, code: str | None) -> tuple[float | None, bool]:
215216
def parse_currency_parts(text: str) -> tuple[float | None, bool]:
216217
"""``(value, ambiguous)`` for a currency string.
217218
218-
Requires an explicit currency marker (symbol or ISO-ish code) so that a bare
219-
``"1,200"`` is left to ordinary dtype repair, not treated as money.
219+
Requires an explicit currency marker (symbol or ISO-ish code), or an
220+
accounting-negative parenthesized amount with valid accounting punctuation,
221+
so that bare numbers and unit strings are left to ordinary repair.
220222
"""
221223
s = text.strip()
222-
has_symbol = any(c in s for c in _CURRENCY_SYMBOLS)
224+
accounting_negative = s.startswith("(") and s.endswith(")")
225+
if accounting_negative:
226+
s = s[1:-1].strip()
227+
if not s:
228+
return None, False
223229
codes = {t.lower() for t in re.findall(r"[A-Za-z]+", s)}
230+
if codes - _CURRENCY_CODES:
231+
return None, False
232+
has_symbol = any(c in s for c in _CURRENCY_SYMBOLS)
224233
has_code = bool(codes & _CURRENCY_CODES)
225-
if not (has_symbol or has_code):
234+
if not (has_symbol or has_code or accounting_negative):
235+
return None, False
236+
if (
237+
accounting_negative
238+
and not (has_symbol or has_code)
239+
and (not ("." in s or "," in s) or not _ACCOUNTING_NUM_RE.match(s))
240+
):
226241
return None, False
227242
body = re.sub(r"[A-Za-z$€£¥₹\s\u00a0\u202f']", "", s)
228-
return _split_amount(body, detect_currency(s))
243+
value, ambiguous = _split_amount(body, detect_currency(s))
244+
if value is not None and accounting_negative:
245+
value = -abs(value) if value != 0 else 0.0
246+
return value, ambiguous
229247

230248

231249
def parse_currency(text: str) -> float | None:
@@ -597,14 +615,16 @@ class CurrencyStringExpert:
597615
issue_type = "currency_string"
598616

599617
def applies(self, info: SemanticColumnInfo) -> bool:
618+
"""True when the column has monetary semantic characteristics and is not free text."""
600619
return info.money_like and not info.free_text
601620

602621
def propose(self, series: pd.Series, info: SemanticColumnInfo) -> list[SemanticProposal]:
622+
"""Propose numeric conversions for formatted currency strings."""
603623
out: list[SemanticProposal] = []
604624
for raw, count in _value_counts(series).items():
605625
if not isinstance(raw, str):
606626
continue
607-
value = parse_currency(raw)
627+
value, ambiguous = parse_currency_parts(raw)
608628
if value is None:
609629
continue
610630
code = detect_currency(raw)
@@ -645,6 +665,36 @@ def propose(self, series: pd.Series, info: SemanticColumnInfo) -> list[SemanticP
645665
)
646666
)
647667
continue
668+
if ambiguous:
669+
out.append(
670+
make_proposal(
671+
column=info.name,
672+
raw_value=raw,
673+
proposed_value=value,
674+
issue_type=self.issue_type,
675+
expert=self.name,
676+
base_confidence=0.60,
677+
evidence=(
678+
SemanticEvidence(
679+
"pattern", f"{raw!r} is an ambiguous currency amount", 0.0
680+
),
681+
SemanticEvidence(
682+
"context_hint",
683+
f"{raw!r} has ambiguous thousands/decimal punctuation; "
684+
"needs human review",
685+
0.0,
686+
),
687+
),
688+
count=int(count),
689+
rationale=(
690+
f"{raw!r} is ambiguous between thousands and decimal separator; "
691+
"needs human review"
692+
),
693+
info=info,
694+
risk_override="high",
695+
)
696+
)
697+
continue
648698
evidence = (
649699
SemanticEvidence("pattern", f"{raw!r} is a currency string", 0.0),
650700
SemanticEvidence("column_role", "column reads as monetary", 0.02),

‎tests/expectations.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"large_panel",
4040
"duplicate_heavy",
4141
"locale_numbers",
42+
"financial_ledger",
4243
"mixed_roles",
4344
]
4445

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
transaction_id,transaction_date,description,amount,currency,account
2+
TX-001,2026-01-02,Opening balance,"$1,250.00",USD,Cash
3+
TX-002,2026-01-03,Vendor invoice,"($1,250.00)",USD,Accounts Payable
4+
TX-003,2026-01-04,European sale,"EUR 1.250,50",EUR,Revenue
5+
TX-004,2026-01-05,European refund,"(EUR 1.250,50)",EUR,Refunds
6+
TX-005,2026-01-06,Small card fee," € 0,50 ",EUR,Bank Fees
7+
TX-006,2026-01-07,Service charge,"EUR 12,5",EUR,Bank Fees
8+
TX-007,2026-01-08,Payroll batch,"$2,500.00",USD,Payroll
9+
TX-008,2026-01-09,Payroll reversal," ($2,500.00) ",USD,Payroll
10+
TX-009,2026-01-10,Office rent,"EUR 2.100,00",EUR,Operating Expense
11+
TX-010,2026-01-11,Rent correction,"(EUR 2.100,00)",EUR,Operating Expense
12+
TX-011,2026-01-12,Cloud hosting,"$3,450.75",USD,Technology
13+
TX-012,2026-01-13,Cloud credit,"($3,450.75)",USD,Technology
14+
TX-013,2026-01-14,Consulting income,"EUR 4.750,25",EUR,Revenue
15+
TX-014,2026-01-15,Consulting reversal,"(EUR 4.750,25)",EUR,Revenue
16+
TX-015,2026-01-16,Travel advance," $875.00 ",USD,Travel
17+
TX-016,2026-01-17,Travel return," ($875.00) ",USD,Travel
18+
TX-017,2026-01-18,Equipment purchase,"$12,000.00",USD,Fixed Assets
19+
TX-018,2026-01-19,Equipment refund,"($1,200.00)",USD,Fixed Assets
20+
TX-019,2026-01-20,Insurance premium,"EUR 1.050,00",EUR,Insurance
21+
TX-020,2026-01-21,Insurance rebate,"(EUR 150,00)",EUR,Insurance
22+
TX-021,2026-01-22,Interest received,"$45.67",USD,Interest
23+
TX-022,2026-01-23,Interest correction,"($5.67)",USD,Interest
24+
TX-023,2026-01-24,Settlement received,"EUR 10.000,00",EUR,Settlements
25+
TX-024,2026-01-25,Settlement adjustment,"(EUR 250,00)",EUR,Settlements
26+
TX-025,2026-01-26,Unmarked accounting adjustment,"(1,250.00)",USD,Settlements
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"balanced": {
3+
"idempotent": true,
4+
"row_count": 25
5+
},
6+
"semantic_auto": {
7+
"required_conversions": {
8+
"amount": "float64",
9+
"transaction_date": "datetime64"
10+
},
11+
"target_values": {
12+
"TX-002": -1250.0,
13+
"TX-003": 1250.5,
14+
"TX-004": -1250.5,
15+
"TX-005": 0.5,
16+
"TX-008": -2500.0,
17+
"TX-023": 10000.0,
18+
"TX-024": -250.0,
19+
"TX-025": -1250.0
20+
},
21+
"row_count": 25,
22+
"idempotent": true
23+
}
24+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"actions": [
3+
{
4+
"column": "amount",
5+
"confidence": 1.0,
6+
"count": 4,
7+
"description": "trimmed surrounding whitespace",
8+
"human_review": false,
9+
"memory_influenced": false,
10+
"model_id": "",
11+
"rationale": "",
12+
"reversible": null,
13+
"risk": "low",
14+
"status": "automatic",
15+
"step": "strip_whitespace"
16+
},
17+
{
18+
"column": "transaction_date",
19+
"confidence": 1.0,
20+
"count": 25,
21+
"description": "converted to datetime64[ns]",
22+
"human_review": false,
23+
"memory_influenced": false,
24+
"model_id": "",
25+
"rationale": "",
26+
"reversible": null,
27+
"risk": "low",
28+
"status": "automatic",
29+
"step": "fix_dtypes"
30+
}
31+
],
32+
"cols_after": 6,
33+
"cols_before": 6,
34+
"columns_dropped": [],
35+
"columns_imputed": [],
36+
"columns_preserved": [],
37+
"duplicates_removed": 0,
38+
"missing_after": 0,
39+
"missing_before": 0,
40+
"outliers_handled": 0,
41+
"recommendations": [],
42+
"rows_after": 25,
43+
"rows_before": 25,
44+
"warnings": []
45+
}

‎tests/fixtures/golden_diff_summary.jsonl‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@
4141
{"changed": false, "created": false, "fixture": "weather_json", "new_action_count": 3, "online": true, "previous_action_count": 3, "strategy": "balanced"}
4242
{"changed": true, "created": false, "fixture": "wine_quality", "new_action_count": 13, "online": true, "previous_action_count": 13, "strategy": "balanced"}
4343
{"changed": true, "created": false, "fixture": "adult_income", "new_action_count": 11, "online": true, "previous_action_count": 10, "strategy": "balanced"}
44+
{"changed": true, "created": true, "fixture": "financial_ledger", "new_action_count": 2, "online": false, "previous_action_count": 0, "strategy": "balanced"}

0 commit comments

Comments
 (0)