fund-flow-backend / src /detectors /structuring.py
Aniket2006's picture
Fix investigation view rendering, fix STR PDF generation (unicode/limit), and add graph checkpoint for fast server restart
3a44431
Raw
History Blame Contribute Delete
3.46 kB
"""
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