Spaces:
Runtime error
Runtime error
Fix investigation view rendering, fix STR PDF generation (unicode/limit), and add graph checkpoint for fast server restart
3a44431 | """ | |
| Structuring Detector - Fixed | |
| - Scans both source and target | |
| - Rolling window via pandas | |
| - Configurable thresholds from config.yaml | |
| - No Streamlit | |
| """ | |
| import uuid | |
| import pandas as pd | |
| from datetime import datetime | |
| from src.config_loader import get_config | |
| def detect_structuring(df: pd.DataFrame) -> list[dict]: | |
| cfg = get_config()['detectors'] | |
| THRESHOLD = cfg['structuring_threshold'] | |
| LOWER_PCT = cfg['structuring_lower_pct'] | |
| WINDOW_DAYS = cfg['structuring_window_days'] | |
| MIN_TX_COUNT = cfg['structuring_min_tx_count'] | |
| CHECK_TARGET = cfg.get('structuring_check_target', True) | |
| lower = THRESHOLD * LOWER_PCT | |
| upper = THRESHOLD | |
| window = f"{WINDOW_DAYS}D" | |
| fraud_accounts = ( | |
| set(df[df['is_laundering'] == 1]['source'].unique()) | | |
| set(df[df['is_laundering'] == 1]['target'].unique()) | |
| ) | |
| df = df.copy() | |
| df['timestamp'] = pd.to_datetime(df['timestamp']) | |
| # Filter to transactions in the structuring band | |
| band_df = df[df['amount'].between(lower, upper, inclusive='left')].copy() | |
| if band_df.empty: | |
| return [] | |
| alerts = [] | |
| seen = set() | |
| def _scan_column(col: str, band: pd.DataFrame) -> None: | |
| for account in band[col].unique(): | |
| if account in seen: | |
| continue | |
| acct = band[band[col] == account].set_index('timestamp').sort_index() | |
| if acct.empty: | |
| continue | |
| # Rolling window count + sum | |
| rolled_count = acct['amount'].rolling(window).count() | |
| rolled_sum = acct['amount'].rolling(window).sum() | |
| triggered = (rolled_sum >= THRESHOLD) & (rolled_count >= MIN_TX_COUNT) | |
| if not triggered.any(): | |
| continue | |
| seen.add(account) | |
| best_idx = triggered[triggered].index[-1] | |
| # Use .loc then .iloc[-1] to handle duplicate timestamps safely | |
| total_val = rolled_sum.loc[best_idx] | |
| total = float(total_val.iloc[-1]) if hasattr(total_val, 'iloc') else float(total_val) | |
| count_val = rolled_count.loc[best_idx] | |
| count = int(count_val.iloc[-1]) if hasattr(count_val, 'iloc') else int(count_val) | |
| risk_score = min( | |
| int((total / THRESHOLD) * 40 + (count / MIN_TX_COUNT) * 30 + 20), | |
| 99 | |
| ) | |
| # Velocity acceleration: tx in last 24h vs full window | |
| last_24h = acct['amount'].rolling('1D').count() | |
| last_24h_val = last_24h.iloc[-1] | |
| accel = float(last_24h_val) / max(count, 1) | |
| risk_score = min(int(risk_score + accel * 10), 99) | |
| explanation = ( | |
| f"{count} transactions totalling {total:,.0f} detected within " | |
| f"{WINDOW_DAYS} days, clustering just below the " | |
| f"reporting threshold of {THRESHOLD:,}" | |
| ) | |
| alerts.append({ | |
| 'alert_id': uuid.uuid4().hex[:8], | |
| 'account': account, | |
| 'typology': 'Structuring', | |
| 'risk_score': risk_score, | |
| 'amount_involved': round(total, 2), | |
| 'tx_count': count, | |
| 'explanation': explanation, | |
| 'timestamp_detected': datetime.now().isoformat(), | |
| 'confirmed_fraud': account in fraud_accounts, | |
| }) | |
| _scan_column('source', band_df) | |
| if CHECK_TARGET: | |
| _scan_column('target', band_df) | |
| return alerts | |