diff --git a/.firebaserc b/.firebaserc
deleted file mode 100644
index 228161c..0000000
--- a/.firebaserc
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "projects": {
- "default": "risk-compliance-system"
- }
-}
diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index 4a63abc..0000000
--- a/.gitattributes
+++ /dev/null
@@ -1,2 +0,0 @@
-*.csv filter=lfs diff=lfs merge=lfs -text
-SAML-D.csv filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitignore b/.gitignore
index 8f5bc22..721f485 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,102 +1,47 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-firebase-debug.log*
-firebase-debug.*.log*
-
-# Firebase cache
-.firebase/
-
-# Firebase config
-
-# Uncomment this if you'd like others to create their own Firebase project.
-# For a team working on the same Firebase project(s), it is recommended to leave
-# it commented so all members can deploy to the same project(s) in .firebaserc.
-# .firebaserc
-
-# Runtime data
-pids
-*.pid
-*.seed
-*.pid.lock
-
-# Directory for instrumented libs generated by jscoverage/JSCover
-lib-cov
-
-# Coverage directory used by tools like istanbul
-coverage
-
-# nyc test coverage
-.nyc_output
-
-# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
-.grunt
-
-# Bower dependency directory (https://bower.io/)
-bower_components
-
-# node-waf configuration
-.lock-wscript
-
-# Compiled binary addons (http://nodejs.org/api/addons.html)
-build/Release
-
-# Dependency directories
-node_modules/
-
-# Optional npm cache directory
-.npm
-
-# Optional eslint cache
-.eslintcache
-
-# Optional REPL history
-.node_repl_history
-
-# Output of 'npm pack'
-*.tgz
-
-# Yarn Integrity file
-.yarn-integrity
-
-# dotenv environment variables file
-.env
-
-# dataconnect generated files
-.dataconnect
-
-
# Python
__pycache__/
-*.pyc
-*.pyo
-*.pyd
+*.py[cod]
+*.so
.Python
venv/
+.venv/
env/
-# Firebase
-firebase-credentials.json
-.firebase/
-
# Environment
.env
-# Models
-*.pkl
-src/models/*.pkl
+# IDE
+.vscode/
+.idea/
+.DS_Store
+
+# Jupyter
+.ipynb_checkpoints/
+
+# Raw datasets
+data/raw/*
+!data/raw/.gitkeep
+
+# Generated datasets
+data/processed/
+*.parquet
+
+# Model artifacts
+artifacts/*
+!artifacts/.gitkeep
+
+# Generated figures
+reports/figures/*
+!reports/figures/.gitkeep
# Logs
logs/
*.log
-# VS Code
-.vscode/settings.json
+# Testing
+.pytest_cache/
+.coverage
+htmlcov/
-# Data
-data/
-*.csv
-*.json
\ No newline at end of file
+# Large data
+*.csv
\ No newline at end of file
diff --git a/SAML-D.csv b/SAML-D.csv
deleted file mode 100644
index 8ecbb59..0000000
--- a/SAML-D.csv
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:5b71ce2ea7b47fe6f19da1aa151776b04ec74560a852c2c077df91d20b8b4ef9
-size 996168850
diff --git a/artifacts/.gitkeep b/artifacts/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/data/README.md b/data/README.md
new file mode 100644
index 0000000..9d81f7c
--- /dev/null
+++ b/data/README.md
@@ -0,0 +1,25 @@
+# Dataset
+
+This project uses the Synthetic Anti-Money Laundering Dataset (SAML-D).
+
+The raw dataset is intentionally not committed to this repository because of
+its size and third-party licensing terms.
+
+## Dataset
+
+SAML-D contains approximately 9.5 million synthetic financial transactions,
+including rare suspicious transaction patterns and multiple laundering
+typologies.
+
+Expected raw file:
+
+data/raw/SAML-D.csv
+
+## Citation
+
+B. Oztas, D. Cetinkaya, F. Adedoyin, M. Budka, H. Dogan and G. Aksu,
+"Enhancing Anti-Money Laundering: Development of a Synthetic Transaction
+Monitoring Dataset," 2023 IEEE International Conference on e-Business
+Engineering (ICEBE), 2023.
+
+Dataset license: CC BY-NC-SA 4.0.
\ No newline at end of file
diff --git a/data/raw/.gitkeep b/data/raw/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/notebooks/system.ipynb b/notebooks/01_eda.ipynb
similarity index 100%
rename from src/notebooks/system.ipynb
rename to notebooks/01_eda.ipynb
diff --git a/src/notebooks/Model.ipynb b/notebooks/02_model_research.ipynb
similarity index 100%
rename from src/notebooks/Model.ipynb
rename to notebooks/02_model_research.ipynb
diff --git a/notebooks/03_backtesting.ipynb b/notebooks/03_backtesting.ipynb
new file mode 100644
index 0000000..63f130f
--- /dev/null
+++ b/notebooks/03_backtesting.ipynb
@@ -0,0 +1,19 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Out-of-Time Backtesting\n",
+ "\n",
+ "Use this notebook for walk-forward evaluation, alert-budget metrics, threshold optimisation, drift monitoring, and laundering-typology analysis. Populate results only from executed experiments."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
+ "language_info": {"name": "python", "version": "3.12"}
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/reports/.gitkeep b/reports/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/reports/figures/.gitkeep b/reports/figures/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/requirements.txt b/requirements.txt
index e4a6d93..20256bd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,16 +1,18 @@
-pandas>=1.3.0
-numpy>=1.21.0
-scikit-learn>=1.0.0
-xgboost>=1.5.0
-imbalanced-learn>=0.8.0
-flask>=2.0.0
-flask-cors>=3.0.10
-python-dotenv>=0.19.0
-firebase-admin>=6.0.0
-google-cloud-firestore>=2.7.0
-schedule>=1.1.0
-matplotlib>=3.4.0
-seaborn>=0.11.0
-plotly>=5.0.0
-pytest>=6.2.0
-python-dateutil>=2.8.0
\ No newline at end of file
+pandas>=2.2,<3.0
+numpy>=1.26,<3.0
+scikit-learn>=1.5,<2.0
+xgboost>=2.1,<4.0
+
+duckdb>=1.1,<2.0
+pyarrow>=17,<22
+joblib>=1.4,<2.0
+
+matplotlib>=3.9,<4.0
+shap>=0.46,<1.0
+
+flask>=3.0,<4.0
+flask-cors>=5.0,<7.0
+python-dotenv>=1.0,<2.0
+requests>=2.32,<3.0
+
+pytest>=8.0,<9.0
\ No newline at end of file
diff --git a/scripts/build_features.py b/scripts/build_features.py
new file mode 100644
index 0000000..976fdc6
--- /dev/null
+++ b/scripts/build_features.py
@@ -0,0 +1,40 @@
+import sys
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from src.data.loader import load_saml_data
+from src.features.behavioral_features import add_behavioral_features
+from src.features.transaction_features import add_transaction_features
+
+RAW_PATH = PROJECT_ROOT / "data/raw/SAML-D.csv"
+OUTPUT_PATH = PROJECT_ROOT / "data/processed/transactions_features.parquet"
+
+
+def main():
+ print("Loading SAML-D...")
+ df = load_saml_data(RAW_PATH)
+
+ print(f"Loaded {len(df):,} transactions")
+
+ print("Building transaction features...")
+ df = add_transaction_features(df)
+
+ print("Building behavioral features...")
+ df = add_behavioral_features(df)
+
+ OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
+
+ df.to_parquet(
+ OUTPUT_PATH,
+ index=False,
+ compression="snappy",
+ )
+
+ print(f"Feature dataset written to {OUTPUT_PATH}")
+ print(f"Shape: {df.shape}")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/scripts/feature_ablation.py b/scripts/feature_ablation.py
new file mode 100644
index 0000000..2c4f87d
--- /dev/null
+++ b/scripts/feature_ablation.py
@@ -0,0 +1,93 @@
+import sys
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(PROJECT_ROOT))
+
+import pandas as pd
+
+from src.evaluation.metrics import (
+ evaluate_model,
+ precision_recall_at_alert_rate,
+)
+from src.models.calibration import ProbabilityCalibrator
+from src.models.train import (
+ ABLATION_FEATURE_SETS,
+ TARGET,
+ chronological_split,
+ fit_model,
+)
+
+FEATURE_PATH = PROJECT_ROOT / "data/processed/transactions_features.parquet"
+ALERT_RATE = 0.005
+
+
+def evaluate_feature_set(name, features, train, validation, test):
+ (
+ preprocessor,
+ model,
+ X_validation_processed,
+ y_validation,
+ ) = fit_model(train, validation, features)
+
+ validation_probabilities = model.predict_proba(
+ X_validation_processed
+ )[:, 1]
+
+ calibrator = ProbabilityCalibrator().fit(
+ validation_probabilities,
+ y_validation,
+ )
+
+ test_probabilities = calibrator.predict(
+ model.predict_proba(
+ preprocessor.transform(test[features])
+ )[:, 1]
+ )
+
+ metrics = evaluate_model(
+ test[TARGET],
+ test_probabilities,
+ )
+ alert_metrics = precision_recall_at_alert_rate(
+ test[TARGET],
+ test_probabilities,
+ ALERT_RATE,
+ )
+
+ return {
+ "model": name,
+ "pr_auc": metrics["pr_auc"],
+ "recall_at_0.5%": alert_metrics["recall"],
+ }
+
+
+def main():
+ df = pd.read_parquet(FEATURE_PATH)
+ train, validation, test = chronological_split(df)
+
+ results = [
+ evaluate_feature_set(
+ name,
+ features,
+ train,
+ validation,
+ test,
+ )
+ for name, features in ABLATION_FEATURE_SETS.items()
+ ]
+
+ print("\nFEATURE ABLATION RESULTS")
+ print("=" * 55)
+ print(f"{'Model':<22} {'PR-AUC':>12} {'Recall@0.5%':>16}")
+ print("-" * 55)
+ for result in results:
+ print(
+ f"{result['model']:<22} "
+ f"{result['pr_auc']:>12.6f} "
+ f"{result['recall_at_0.5%']:>16.6f}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/train_model.py b/scripts/train_model.py
new file mode 100644
index 0000000..5c0f0f7
--- /dev/null
+++ b/scripts/train_model.py
@@ -0,0 +1,224 @@
+import sys
+from pathlib import Path
+
+import joblib
+import pandas as pd
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from src.evaluation.metrics import (
+ evaluate_model,
+ threshold_for_alert_rate,
+)
+from src.models.calibration import (
+ ProbabilityCalibrator,
+)
+from src.models.train import (
+ MODEL_FEATURES,
+ TARGET,
+ chronological_split,
+ fit_model,
+)
+from src.models.baseline import build_logistic_baseline
+
+FEATURE_PATH = PROJECT_ROOT / "data/processed/transactions_features.parquet"
+ARTIFACT_PATH = PROJECT_ROOT / "artifacts/risk_model.joblib"
+
+
+def print_split_stats(name, frame):
+
+ positive = frame[TARGET].sum()
+ prevalence = frame[TARGET].mean()
+
+ print(
+ f"{name}: "
+ f"{len(frame):,} rows | "
+ f"{positive:,} suspicious | "
+ f"{prevalence:.4%}"
+ )
+
+
+def main():
+
+ ARTIFACT_PATH.parent.mkdir(exist_ok=True)
+
+ print("Loading feature dataset...")
+
+ df = pd.read_parquet(
+ FEATURE_PATH
+ )
+
+ train, validation, test = (
+ chronological_split(df)
+ )
+
+ print_split_stats(
+ "TRAIN",
+ train,
+ )
+
+ print_split_stats(
+ "VALIDATION",
+ validation,
+ )
+
+ print_split_stats(
+ "TEST",
+ test,
+ )
+
+ (
+ preprocessor,
+ model,
+ X_validation_processed,
+ y_validation,
+ ) = fit_model(
+ train,
+ validation,
+ )
+
+ # ---------------------------
+ # CALIBRATION
+ # ---------------------------
+
+ raw_validation_prob = (
+ model.predict_proba(
+ X_validation_processed
+ )[:, 1]
+ )
+
+ calibrator = ProbabilityCalibrator()
+
+ calibrator.fit(
+ raw_validation_prob,
+ y_validation,
+ )
+
+ calibrated_validation_prob = (
+ calibrator.predict(
+ raw_validation_prob
+ )
+ )
+
+ # Use a realistic operational alert capacity
+ alert_rate = 0.005
+
+ threshold = (
+ threshold_for_alert_rate(
+ calibrated_validation_prob,
+ alert_rate,
+ )
+ )
+
+ print(
+ f"\nThreshold for "
+ f"{alert_rate:.2%} alert rate: "
+ f"{threshold:.6f}"
+ )
+
+ # ---------------------------
+ # TEST
+ # ---------------------------
+
+ X_test = test[MODEL_FEATURES]
+ y_test = test[TARGET]
+
+ X_test_processed = (
+ preprocessor.transform(
+ X_test
+ )
+ )
+
+ raw_test_prob = (
+ model.predict_proba(
+ X_test_processed
+ )[:, 1]
+ )
+
+ calibrated_test_prob = (
+ calibrator.predict(
+ raw_test_prob
+ )
+ )
+
+ metrics = evaluate_model(
+ y_test,
+ calibrated_test_prob,
+ )
+
+ print("\nOUT-OF-TIME TEST RESULTS")
+ print("=" * 50)
+
+ for key, value in metrics.items():
+
+ if isinstance(value, float):
+ print(
+ f"{key:30s}: "
+ f"{value:.6f}"
+ )
+
+ else:
+ print(
+ f"{key:30s}: "
+ f"{value}"
+ )
+
+ # Benchmark the nonlinear model against a scalable logistic classifier on
+ # the exact same chronological partitions and preprocessing contract.
+ baseline = build_logistic_baseline()
+ baseline.fit(preprocessor.transform(train[MODEL_FEATURES]), train[TARGET])
+ baseline_validation_prob = baseline.predict_proba(
+ X_validation_processed
+ )[:, 1]
+ baseline_calibrator = ProbabilityCalibrator().fit(
+ baseline_validation_prob,
+ y_validation,
+ )
+ baseline_test_prob = baseline_calibrator.predict(
+ baseline.predict_proba(X_test_processed)[:, 1]
+ )
+ baseline_metrics = evaluate_model(y_test, baseline_test_prob)
+
+ print("\nLOGISTIC BASELINE TEST RESULTS")
+ print("=" * 50)
+ for key in ("pr_auc", "alert_0.500%_recall", "alert_0.500%_lift"):
+ print(f"{key:30s}: {baseline_metrics[key]:.6f}")
+
+ # ---------------------------
+ # SAVE
+ # ---------------------------
+
+ artifact = {
+ "preprocessor": preprocessor,
+ "model": model,
+ "calibrator": calibrator,
+
+ "features": MODEL_FEATURES,
+
+ "decision_threshold": threshold,
+ "alert_rate": alert_rate,
+
+ "test_metrics": metrics,
+ "baseline_test_metrics": baseline_metrics,
+ "validation_probability_quantiles": [
+ float(value)
+ for value in sorted(calibrated_validation_prob)
+ ],
+
+ "model_version": "2.0.0",
+ }
+
+ joblib.dump(
+ artifact,
+ ARTIFACT_PATH,
+ )
+
+ print(
+ "\nSaved model to "
+ f"{ARTIFACT_PATH.relative_to(PROJECT_ROOT)}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/api/__init__.py b/src/api/__init__.py
new file mode 100644
index 0000000..f295278
--- /dev/null
+++ b/src/api/__init__.py
@@ -0,0 +1 @@
+"""HTTP interfaces for the surveillance engine."""
diff --git a/src/api/app.py b/src/api/app.py
new file mode 100644
index 0000000..f896c2e
--- /dev/null
+++ b/src/api/app.py
@@ -0,0 +1,94 @@
+"""Artifact-backed API for feature-store supplied transaction features.
+
+This API intentionally does not calculate behavioural history from a single
+raw transaction. That responsibility belongs to an online feature store.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import joblib
+from flask import Flask, jsonify, request
+from flask_cors import CORS
+
+from src.models.inference import (
+ model_input_from_features,
+ predict_calibrated_probability,
+ probability_percentile,
+)
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+MODEL_PATH = PROJECT_ROOT / "artifacts/risk_model.joblib"
+
+
+def create_app(model_path: Path = MODEL_PATH) -> Flask:
+ app = Flask(__name__)
+ CORS(app)
+
+ artifact: dict[str, Any] | None = None
+ load_error: str | None = None
+ try:
+ artifact = joblib.load(model_path)
+ except FileNotFoundError:
+ load_error = f"Model artifact not found at {model_path}. Train the offline model first."
+ except Exception as error: # pragma: no cover - defensive startup path
+ load_error = f"Unable to load model artifact: {error}"
+
+ @app.get("/api/health")
+ def health():
+ return jsonify(
+ {
+ "status": "healthy" if artifact else "model_unavailable",
+ "model_loaded": artifact is not None,
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "detail": load_error,
+ }
+ )
+
+ @app.get("/api/model/info")
+ def model_info():
+ if artifact is None:
+ return jsonify({"error": load_error}), 503
+ return jsonify(
+ {
+ "model": "XGBoost",
+ "model_version": artifact["model_version"],
+ "alert_rate": artifact["alert_rate"],
+ "decision_threshold": artifact["decision_threshold"],
+ "test_metrics": artifact["test_metrics"],
+ }
+ )
+
+ @app.post("/api/predict")
+ def predict():
+ if artifact is None:
+ return jsonify({"error": load_error}), 503
+ payload = request.get_json(silent=True)
+ if not isinstance(payload, dict):
+ return jsonify({"error": "Request body must be a JSON object."}), 400
+ try:
+ features = model_input_from_features(payload, artifact["features"])
+ probability = predict_calibrated_probability(artifact, features)
+ except ValueError as error:
+ return jsonify({"error": str(error)}), 400
+
+ result: dict[str, Any] = {
+ "risk_probability": probability,
+ "requires_review": probability >= artifact["decision_threshold"],
+ }
+ percentile = probability_percentile(artifact, probability)
+ if percentile is not None:
+ result["risk_percentile"] = percentile
+ return jsonify(result)
+
+ return app
+
+
+app = create_app()
+
+
+if __name__ == "__main__":
+ app.run(host="0.0.0.0", port=5000, debug=False)
diff --git a/src/api/simple_api_server.py b/src/api/simple_api_server.py
index b4fb6ae..2e95b91 100644
--- a/src/api/simple_api_server.py
+++ b/src/api/simple_api_server.py
@@ -227,7 +227,7 @@ def process_transaction():
if risk_level == 'HIGH':
high_risk_transactions.append({
'transaction_id': data['transaction_id'],
- 'risk_score': risk_score,
+ 'risk_probability': risk_score,
'risk_level': risk_level,
'amount': data['amount'],
'sender_id': data['sender_id'],
@@ -241,7 +241,7 @@ def process_transaction():
'type': 'HIGH_RISK_TRANSACTION',
'severity': 'HIGH',
'message': f'High risk transaction detected: {data["transaction_id"]}',
- 'risk_score': risk_score,
+ 'risk_probability': risk_score,
'amount': data['amount'],
'sender': data['sender_id'],
'receiver': data['receiver_id'],
@@ -252,12 +252,11 @@ def process_transaction():
return jsonify({
'transaction_id': data['transaction_id'],
- 'risk_score': risk_score,
+ 'risk_probability': risk_score,
'risk_level': risk_level,
'compliance_status': 'PENDING' if requires_review else 'APPROVED',
'requires_review': requires_review,
'flagged_features': flagged_features,
- 'confidence': abs(risk_score - 0.5) * 2, # Simple confidence calculation
'processed_at': datetime.now().isoformat()
})
@@ -342,12 +341,11 @@ def bulk_process_transactions():
results.append({
'transaction_id': tx_data['transaction_id'],
- 'risk_score': risk_score,
+ 'risk_probability': risk_score,
'risk_level': risk_level,
'compliance_status': 'PENDING' if requires_review else 'APPROVED',
'requires_review': requires_review,
'flagged_features': flagged_features,
- 'confidence': abs(risk_score - 0.5) * 2,
'processed_at': datetime.now().isoformat()
})
diff --git a/src/dashboard/real_time_dashboard.html b/src/dashboard/real_time_dashboard.html
index fc8e12b..3a51225 100644
--- a/src/dashboard/real_time_dashboard.html
+++ b/src/dashboard/real_time_dashboard.html
@@ -913,7 +913,7 @@
Recent Alerts
${alert.type || 'Risk Alert'}
Amount: ${amount}
-
Risk Score: ${alert.risk_score ? alert.risk_score.toFixed(3) : 'N/A'}
+
Risk Probability: ${alert.risk_probability ? alert.risk_probability.toFixed(3) : 'N/A'}
${timestamp}
`;
diff --git a/src/data/__init__.py b/src/data/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/data/loader.py b/src/data/loader.py
new file mode 100644
index 0000000..f36b35a
--- /dev/null
+++ b/src/data/loader.py
@@ -0,0 +1,76 @@
+from pathlib import Path
+
+import pandas as pd
+
+EXPECTED_COLUMNS = [
+ "Time",
+ "Date",
+ "Sender_account",
+ "Receiver_account",
+ "Amount",
+ "Payment_currency",
+ "Received_currency",
+ "Sender_bank_location",
+ "Receiver_bank_location",
+ "Payment_type",
+ "Is_laundering",
+ "Laundering_type",
+]
+
+
+def load_saml_data(
+ path: str | Path = "data/raw/SAML-D.csv",
+) -> pd.DataFrame:
+ """
+ Load the SAML-D transaction dataset and construct a chronological timestamp.
+ """
+
+ path = Path(path)
+
+ if not path.exists():
+ raise FileNotFoundError(
+ f"SAML-D dataset not found at {path}. "
+ "Place the CSV inside data/raw/."
+ )
+
+ # A Git LFS pointer is text metadata, not the dataset itself. Detecting it
+ # here gives contributors an actionable error instead of a misleading
+ # missing-column failure from pandas.
+ if path.read_bytes()[:64].startswith(b"version https://git-lfs.github.com/spec/v1"):
+ raise FileNotFoundError(
+ f"{path} is a Git LFS pointer, not the SAML-D CSV. "
+ "Download the licensed dataset and place the actual CSV in data/raw/."
+ )
+
+ df = pd.read_csv(
+ path,
+ dtype={
+ "Sender_account": "string",
+ "Receiver_account": "string",
+ "Amount": "float64",
+ "Payment_currency": "category",
+ "Received_currency": "category",
+ "Sender_bank_location": "category",
+ "Receiver_bank_location": "category",
+ "Payment_type": "category",
+ "Is_laundering": "int8",
+ "Laundering_type": "category",
+ },
+ )
+
+ missing = set(EXPECTED_COLUMNS) - set(df.columns)
+
+ if missing:
+ raise ValueError(f"Missing columns: {missing}")
+
+ df["timestamp"] = pd.to_datetime(
+ df["Date"].astype(str) + " " + df["Time"].astype(str),
+ errors="coerce",
+ )
+
+ df = df.dropna(subset=["timestamp", "Amount"])
+
+ # Chronology is critical for financial backtesting.
+ df = df.sort_values("timestamp").reset_index(drop=True)
+
+ return df
diff --git a/src/evaluation/__init__.py b/src/evaluation/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/evaluation/explainability.py b/src/evaluation/explainability.py
new file mode 100644
index 0000000..cb70136
--- /dev/null
+++ b/src/evaluation/explainability.py
@@ -0,0 +1,18 @@
+import shap
+
+
+def create_shap_explainer(model):
+ return shap.TreeExplainer(model)
+
+
+def calculate_shap_values(
+ model,
+ X,
+):
+ explainer = create_shap_explainer(
+ model
+ )
+
+ return explainer.shap_values(
+ X
+ )
\ No newline at end of file
diff --git a/src/evaluation/metrics.py b/src/evaluation/metrics.py
new file mode 100644
index 0000000..9e9dca3
--- /dev/null
+++ b/src/evaluation/metrics.py
@@ -0,0 +1,196 @@
+import numpy as np
+from sklearn.metrics import (
+ average_precision_score,
+ brier_score_loss,
+ log_loss,
+ roc_auc_score,
+)
+
+
+def threshold_for_alert_rate(
+ probabilities,
+ alert_rate=0.005,
+):
+ """
+ Alert only the top X% highest-risk transactions.
+ """
+
+ probabilities = np.asarray(probabilities, dtype=float)
+ if not 0 < alert_rate <= 1:
+ raise ValueError("alert_rate must be in (0, 1].")
+
+ return float(
+ np.quantile(
+ probabilities,
+ 1 - alert_rate,
+ )
+ )
+
+
+def precision_recall_at_alert_rate(
+ y_true,
+ probabilities,
+ alert_rate=0.005,
+):
+ y_true = np.asarray(y_true)
+ probabilities = np.asarray(probabilities)
+
+ threshold = threshold_for_alert_rate(
+ probabilities,
+ alert_rate,
+ )
+
+ predicted = probabilities >= threshold
+
+ tp = np.sum(
+ (predicted == 1) &
+ (y_true == 1)
+ )
+
+ fp = np.sum(
+ (predicted == 1) &
+ (y_true == 0)
+ )
+
+ fn = np.sum(
+ (predicted == 0) &
+ (y_true == 1)
+ )
+
+ precision = (
+ tp / (tp + fp)
+ if tp + fp > 0
+ else 0
+ )
+
+ recall = (
+ tp / (tp + fn)
+ if tp + fn > 0
+ else 0
+ )
+
+ base_rate = y_true.mean()
+
+ lift = (
+ precision / base_rate
+ if base_rate > 0
+ else 0
+ )
+
+ return {
+ "alert_rate": alert_rate,
+ "threshold": threshold,
+ "precision": precision,
+ "recall": recall,
+ "lift": lift,
+ "alerts": int(predicted.sum()),
+ }
+
+
+def expected_decision_cost(
+ y_true,
+ probabilities,
+ threshold,
+ false_negative_cost=100,
+ false_positive_cost=1,
+):
+ """Return the operational cost induced by a binary alert threshold."""
+
+ y_true = np.asarray(y_true)
+ predictions = (np.asarray(probabilities) >= threshold).astype(int)
+
+ false_negatives = np.sum((predictions == 0) & (y_true == 1))
+ false_positives = np.sum((predictions == 1) & (y_true == 0))
+
+ return float(
+ false_negative_cost * false_negatives
+ + false_positive_cost * false_positives
+ )
+
+def calculate_risk_weighted_exposure(
+ amounts,
+ probabilities,
+):
+ """
+ Probability-weighted transaction amount.
+
+ This is NOT called expected financial loss because
+ the dataset does not contain realized loss severity.
+ """
+
+ amounts = np.asarray(amounts)
+ probabilities = np.asarray(probabilities)
+
+ return amounts * probabilities
+
+
+def evaluate_model(
+ y_true,
+ probabilities,
+):
+
+ y_true = np.asarray(y_true)
+ if np.unique(y_true).size < 2:
+ raise ValueError("Evaluation requires both target classes.")
+
+ probabilities = np.clip(
+ probabilities,
+ 1e-8,
+ 1 - 1e-8,
+ )
+
+ results = {
+ "pr_auc":
+ average_precision_score(
+ y_true,
+ probabilities,
+ ),
+
+ "roc_auc":
+ roc_auc_score(
+ y_true,
+ probabilities,
+ ),
+
+ "brier_score":
+ brier_score_loss(
+ y_true,
+ probabilities,
+ ),
+
+ "log_loss":
+ log_loss(
+ y_true,
+ probabilities,
+ ),
+ }
+
+ for rate in [
+ 0.001,
+ 0.005,
+ 0.01,
+ ]:
+
+ performance = (
+ precision_recall_at_alert_rate(
+ y_true,
+ probabilities,
+ alert_rate=rate,
+ )
+ )
+
+ prefix = f"alert_{rate:.3%}"
+
+ results[
+ f"{prefix}_precision"
+ ] = performance["precision"]
+
+ results[
+ f"{prefix}_recall"
+ ] = performance["recall"]
+
+ results[
+ f"{prefix}_lift"
+ ] = performance["lift"]
+
+ return results
diff --git a/src/features/__init__.py b/src/features/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/features/behavioral_features.py b/src/features/behavioral_features.py
new file mode 100644
index 0000000..4d291a2
--- /dev/null
+++ b/src/features/behavioral_features.py
@@ -0,0 +1,198 @@
+import duckdb
+import pandas as pd
+
+
+def add_behavioral_features(df: pd.DataFrame) -> pd.DataFrame:
+ """
+ Generate historical behavioral variables.
+
+ IMPORTANT:
+ All rolling windows end immediately BEFORE the current transaction.
+ This prevents future information from leaking into the prediction.
+ """
+
+ con = duckdb.connect()
+
+ con.register("transactions", df)
+
+ query = """
+ WITH history AS (
+ SELECT
+ *,
+
+ COUNT(*) OVER (
+ PARTITION BY Sender_account
+ ORDER BY timestamp
+ RANGE BETWEEN INTERVAL '24 hours' PRECEDING
+ AND INTERVAL '1 microsecond' PRECEDING
+ ) AS sender_txn_count_24h,
+
+ COALESCE(
+ SUM(Amount) OVER (
+ PARTITION BY Sender_account
+ ORDER BY timestamp
+ RANGE BETWEEN INTERVAL '24 hours' PRECEDING
+ AND INTERVAL '1 microsecond' PRECEDING
+ ),
+ 0
+ ) AS sender_amount_sum_24h,
+
+ AVG(Amount) OVER (
+ PARTITION BY Sender_account
+ ORDER BY timestamp
+ RANGE BETWEEN INTERVAL '30 days' PRECEDING
+ AND INTERVAL '1 microsecond' PRECEDING
+ ) AS sender_amount_mean_30d,
+
+ STDDEV_SAMP(Amount) OVER (
+ PARTITION BY Sender_account
+ ORDER BY timestamp
+ RANGE BETWEEN INTERVAL '30 days' PRECEDING
+ AND INTERVAL '1 microsecond' PRECEDING
+ ) AS sender_amount_std_30d,
+
+ COUNT(*) OVER (
+ PARTITION BY Receiver_account
+ ORDER BY timestamp
+ RANGE BETWEEN INTERVAL '24 hours' PRECEDING
+ AND INTERVAL '1 microsecond' PRECEDING
+ ) AS receiver_txn_count_24h,
+
+ COALESCE(
+ SUM(Amount) OVER (
+ PARTITION BY Receiver_account
+ ORDER BY timestamp
+ RANGE BETWEEN INTERVAL '24 hours' PRECEDING
+ AND INTERVAL '1 microsecond' PRECEDING
+ ),
+ 0
+ ) AS receiver_amount_sum_24h,
+
+ ROW_NUMBER() OVER (
+ PARTITION BY Sender_account, Receiver_account
+ ORDER BY timestamp
+ ) - 1 AS sender_receiver_prior_count,
+
+ LAG(timestamp) OVER (
+ PARTITION BY Sender_account
+ ORDER BY timestamp
+ ) AS previous_sender_timestamp,
+
+ COUNT(*) OVER (
+ PARTITION BY Sender_account
+ ORDER BY timestamp
+ RANGE BETWEEN UNBOUNDED PRECEDING
+ AND INTERVAL '1 microsecond' PRECEDING
+ ) AS sender_out_degree,
+
+ COUNT(*) OVER (
+ PARTITION BY Receiver_account
+ ORDER BY timestamp
+ RANGE BETWEEN UNBOUNDED PRECEDING
+ AND INTERVAL '1 microsecond' PRECEDING
+ ) AS receiver_in_degree,
+
+ COUNT(DISTINCT Receiver_account) OVER (
+ PARTITION BY Sender_account
+ ORDER BY timestamp
+ RANGE BETWEEN UNBOUNDED PRECEDING
+ AND INTERVAL '1 microsecond' PRECEDING
+ ) AS sender_unique_counterparties,
+
+ COUNT(DISTINCT Sender_account) OVER (
+ PARTITION BY Receiver_account
+ ORDER BY timestamp
+ RANGE BETWEEN UNBOUNDED PRECEDING
+ AND INTERVAL '1 microsecond' PRECEDING
+ ) AS receiver_unique_counterparties,
+
+ COUNT(*) OVER (
+ PARTITION BY Sender_account, Receiver_account
+ ORDER BY timestamp
+ RANGE BETWEEN UNBOUNDED PRECEDING
+ AND INTERVAL '1 microsecond' PRECEDING
+ ) AS pair_transaction_count
+
+ FROM transactions
+ )
+
+ SELECT
+ *,
+
+ CASE
+ WHEN sender_amount_std_30d IS NULL
+ OR sender_amount_std_30d = 0
+ THEN 0
+ ELSE
+ (Amount - sender_amount_mean_30d)
+ / sender_amount_std_30d
+ END AS sender_amount_zscore,
+
+ COALESCE(
+ DATE_DIFF(
+ 'second',
+ previous_sender_timestamp,
+ timestamp
+ ),
+ -1
+ ) AS seconds_since_sender_txn
+
+ FROM history
+ ORDER BY timestamp
+ """
+
+ result = con.execute(query).fetchdf()
+
+ con.close()
+
+ numerical_history_columns = [
+ "sender_txn_count_24h",
+ "sender_amount_sum_24h",
+ "sender_amount_mean_30d",
+ "sender_amount_std_30d",
+ "receiver_txn_count_24h",
+ "receiver_amount_sum_24h",
+ "sender_receiver_prior_count",
+ "sender_amount_zscore",
+ "seconds_since_sender_txn",
+ ]
+
+ result[numerical_history_columns] = (
+ result[numerical_history_columns]
+ .replace([float("inf"), float("-inf")], 0)
+ .fillna(0)
+ )
+
+ sender_totals = {}
+ sender_squared_totals = {}
+ sender_receiver_totals = {}
+ concentration = []
+
+ for _, timestamp_group in result.groupby("timestamp", sort=False):
+ for row in timestamp_group.itertuples(index=False):
+ sender = row.Sender_account
+ receiver = row.Receiver_account
+ total = sender_totals.get(sender, 0.0)
+ concentration.append(
+ sender_squared_totals.get(sender, 0.0) / total**2
+ if total > 0
+ else 0.0
+ )
+
+ for row in timestamp_group.itertuples(index=False):
+ sender = row.Sender_account
+ receiver = row.Receiver_account
+ amount = float(row.Amount)
+ pair_key = (sender, receiver)
+ pair_total = sender_receiver_totals.get(pair_key, 0.0)
+ sender_receiver_totals[pair_key] = pair_total + amount
+ sender_totals[sender] = sender_totals.get(sender, 0.0) + amount
+ sender_squared_totals[sender] = (
+ sender_squared_totals.get(sender, 0.0)
+ + 2 * pair_total * amount
+ + amount**2
+ )
+
+ result["sender_counterparty_hhi"] = concentration
+
+ return result
\ No newline at end of file
diff --git a/src/features/transaction_features.py b/src/features/transaction_features.py
new file mode 100644
index 0000000..66b6160
--- /dev/null
+++ b/src/features/transaction_features.py
@@ -0,0 +1,53 @@
+import numpy as np
+import pandas as pd
+
+
+def add_transaction_features(df: pd.DataFrame) -> pd.DataFrame:
+ """
+ Features derived only from the current transaction.
+ No historical information is used here.
+ """
+
+ df = df.copy()
+
+ ts = df["timestamp"]
+
+ hour = ts.dt.hour
+ day_of_week = ts.dt.dayofweek
+ month = ts.dt.month
+
+ # Heavy-tailed transaction amounts
+ df["log_amount"] = np.log1p(df["Amount"])
+
+ # Cyclical temporal encoding
+ df["hour_sin"] = np.sin(2 * np.pi * hour / 24)
+ df["hour_cos"] = np.cos(2 * np.pi * hour / 24)
+
+ df["dow_sin"] = np.sin(2 * np.pi * day_of_week / 7)
+ df["dow_cos"] = np.cos(2 * np.pi * day_of_week / 7)
+
+ df["month_sin"] = np.sin(2 * np.pi * month / 12)
+ df["month_cos"] = np.cos(2 * np.pi * month / 12)
+
+ # Time risk indicators
+ df["is_weekend"] = (day_of_week >= 5).astype("int8")
+ df["is_night"] = ((hour >= 22) | (hour <= 6)).astype("int8")
+
+ # Transaction structure
+ df["currency_mismatch"] = (
+ df["Payment_currency"].astype(str)
+ != df["Received_currency"].astype(str)
+ ).astype("int8")
+
+ df["cross_border"] = (
+ df["Sender_bank_location"].astype(str)
+ != df["Receiver_bank_location"].astype(str)
+ ).astype("int8")
+
+ # Round-value behavior
+ df["is_round_amount"] = np.isclose(
+ df["Amount"] % 1000,
+ 0,
+ ).astype("int8")
+
+ return df
\ No newline at end of file
diff --git a/src/models/__init__.py b/src/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/models/baseline.py b/src/models/baseline.py
new file mode 100644
index 0000000..ce59901
--- /dev/null
+++ b/src/models/baseline.py
@@ -0,0 +1,14 @@
+from sklearn.linear_model import SGDClassifier
+
+
+def build_logistic_baseline():
+
+ return SGDClassifier(
+ loss="log_loss",
+ penalty="l2",
+ alpha=1e-4,
+ class_weight="balanced",
+ max_iter=1000,
+ tol=1e-3,
+ random_state=42,
+ )
\ No newline at end of file
diff --git a/src/models/calibration.py b/src/models/calibration.py
new file mode 100644
index 0000000..7c702d3
--- /dev/null
+++ b/src/models/calibration.py
@@ -0,0 +1,47 @@
+import numpy as np
+from sklearn.linear_model import LogisticRegression
+
+EPSILON = 1e-6
+
+
+def probability_to_logit(probability):
+ probability = np.clip(
+ probability,
+ EPSILON,
+ 1 - EPSILON,
+ )
+
+ return np.log(
+ probability / (1 - probability)
+ )
+
+
+class ProbabilityCalibrator:
+
+ def __init__(self):
+ self.model = LogisticRegression()
+
+ def fit(self, probabilities, targets):
+ targets = np.asarray(targets)
+ if np.unique(targets).size < 2:
+ raise ValueError("Probability calibration requires both target classes.")
+
+ logits = probability_to_logit(
+ np.asarray(probabilities)
+ ).reshape(-1, 1)
+
+ self.model.fit(
+ logits,
+ targets,
+ )
+
+ return self
+
+ def predict(self, probabilities):
+ logits = probability_to_logit(
+ np.asarray(probabilities)
+ ).reshape(-1, 1)
+
+ return self.model.predict_proba(
+ logits
+ )[:, 1]
diff --git a/src/models/inference.py b/src/models/inference.py
new file mode 100644
index 0000000..2742e41
--- /dev/null
+++ b/src/models/inference.py
@@ -0,0 +1,56 @@
+"""Artifact-backed scoring helpers shared by offline and API inference."""
+
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+import numpy as np
+import pandas as pd
+
+
+def model_input_from_features(
+ transaction_features: Mapping[str, Any],
+ feature_names: list[str],
+) -> pd.DataFrame:
+ """Validate and order an already-computed feature payload.
+
+ Account identifiers are deliberately absent: a production feature store
+ must turn them into historical behavioural variables before this boundary.
+ """
+
+ forbidden = {"Sender_account", "Receiver_account", "Is_laundering", "Laundering_type"}
+ supplied = set(transaction_features)
+ forbidden_supplied = supplied & forbidden
+ if forbidden_supplied:
+ raise ValueError(
+ "Identifier or target fields are not model inputs: "
+ f"{sorted(forbidden_supplied)}"
+ )
+
+ missing = set(feature_names) - supplied
+ if missing:
+ raise ValueError(
+ "Missing model features. Historical behavioural features must be "
+ f"provided by the feature store: {sorted(missing)}"
+ )
+
+ return pd.DataFrame([{name: transaction_features[name] for name in feature_names}])
+
+
+def predict_calibrated_probability(artifact: Mapping[str, Any], features: pd.DataFrame) -> float:
+ """Score one feature row with the saved preprocessing, model, and calibrator."""
+
+ processed = artifact["preprocessor"].transform(features)
+ raw_probability = artifact["model"].predict_proba(processed)[:, 1]
+ return float(artifact["calibrator"].predict(raw_probability)[0])
+
+
+def probability_percentile(artifact: Mapping[str, Any], probability: float) -> float | None:
+ """Rank a probability against validation probabilities, when available."""
+
+ reference = artifact.get("validation_probability_quantiles")
+ if not reference:
+ return None
+
+ values = np.asarray(reference, dtype=float)
+ return float(100 * np.searchsorted(values, probability, side="right") / len(values))
diff --git a/src/models/train.py b/src/models/train.py
new file mode 100644
index 0000000..8647465
--- /dev/null
+++ b/src/models/train.py
@@ -0,0 +1,232 @@
+import numpy as np
+import pandas as pd
+import xgboost as xgb
+from sklearn.compose import ColumnTransformer
+from sklearn.impute import SimpleImputer
+from sklearn.pipeline import Pipeline
+from sklearn.preprocessing import OneHotEncoder
+
+
+def chronological_split(
+ df: pd.DataFrame,
+ train_fraction: float = 0.70,
+ validation_fraction: float = 0.15,
+):
+ df = df.sort_values("timestamp").reset_index(drop=True)
+
+ n = len(df)
+
+ train_end = int(n * train_fraction)
+ validation_end = int(
+ n * (train_fraction + validation_fraction)
+ )
+
+ train = df.iloc[:train_end].copy()
+ validation = df.iloc[train_end:validation_end].copy()
+ test = df.iloc[validation_end:].copy()
+
+ return train, validation, test
+
+TARGET = "Is_laundering"
+
+
+NUMERIC_FEATURES = [
+ "Amount",
+ "log_amount",
+
+ "hour_sin",
+ "hour_cos",
+ "dow_sin",
+ "dow_cos",
+ "month_sin",
+ "month_cos",
+
+ "is_weekend",
+ "is_night",
+
+ "currency_mismatch",
+ "cross_border",
+ "is_round_amount",
+
+ "sender_txn_count_24h",
+ "sender_amount_sum_24h",
+ "sender_amount_mean_30d",
+ "sender_amount_std_30d",
+ "sender_amount_zscore",
+
+ "receiver_txn_count_24h",
+ "receiver_amount_sum_24h",
+
+ "sender_receiver_prior_count",
+ "seconds_since_sender_txn",
+
+ "sender_out_degree",
+ "receiver_in_degree",
+ "sender_unique_counterparties",
+ "receiver_unique_counterparties",
+ "pair_transaction_count",
+ "sender_counterparty_hhi",
+]
+
+
+CATEGORICAL_FEATURES = [
+ "Payment_type",
+ "Payment_currency",
+ "Received_currency",
+ "Sender_bank_location",
+ "Receiver_bank_location",
+]
+
+
+MODEL_FEATURES = NUMERIC_FEATURES + CATEGORICAL_FEATURES
+
+BASE_FEATURES = [
+ "Amount",
+ "log_amount",
+ "hour_sin",
+ "hour_cos",
+ "dow_sin",
+ "dow_cos",
+ "month_sin",
+ "month_cos",
+ "is_weekend",
+ "is_night",
+ "currency_mismatch",
+ "cross_border",
+ "is_round_amount",
+]
+
+BEHAVIORAL_FEATURES = [
+ "sender_txn_count_24h",
+ "sender_amount_sum_24h",
+ "sender_amount_mean_30d",
+ "sender_amount_std_30d",
+ "sender_amount_zscore",
+ "receiver_txn_count_24h",
+ "receiver_amount_sum_24h",
+ "sender_receiver_prior_count",
+ "seconds_since_sender_txn",
+]
+
+NETWORK_FEATURES = [
+ "sender_out_degree",
+ "receiver_in_degree",
+ "sender_unique_counterparties",
+ "receiver_unique_counterparties",
+ "pair_transaction_count",
+ "sender_counterparty_hhi",
+]
+
+ABLATION_FEATURE_SETS = {
+ "Base": BASE_FEATURES + CATEGORICAL_FEATURES,
+ "+ Behavioral": BASE_FEATURES + BEHAVIORAL_FEATURES + CATEGORICAL_FEATURES,
+ "+ Network": BASE_FEATURES + NETWORK_FEATURES + CATEGORICAL_FEATURES,
+ "All": MODEL_FEATURES,
+}
+
+
+def build_preprocessor(feature_names=MODEL_FEATURES):
+ numeric_features = [
+ feature for feature in feature_names
+ if feature in NUMERIC_FEATURES
+ ]
+ categorical_features = [
+ feature for feature in feature_names
+ if feature in CATEGORICAL_FEATURES
+ ]
+
+ numeric_pipeline = Pipeline(
+ steps=[
+ (
+ "imputer",
+ SimpleImputer(strategy="median"),
+ ),
+ ]
+ )
+
+ categorical_pipeline = Pipeline(
+ steps=[
+ (
+ "imputer",
+ SimpleImputer(strategy="most_frequent"),
+ ),
+ (
+ "onehot",
+ OneHotEncoder(
+ handle_unknown="ignore",
+ min_frequency=20,
+ ),
+ ),
+ ]
+ )
+
+ return ColumnTransformer(
+ transformers=[
+ (
+ "numeric",
+ numeric_pipeline,
+ numeric_features,
+ ),
+ (
+ "categorical",
+ categorical_pipeline,
+ categorical_features,
+ ),
+ ],
+ remainder="drop",
+ )
+
+
+def build_xgboost_model(y_train):
+ positives = int(y_train.sum())
+ negatives = len(y_train) - positives
+
+ scale_pos_weight = negatives / max(positives, 1)
+
+ print(
+ "scale_pos_weight:",
+ round(scale_pos_weight, 2),
+ )
+
+ return xgb.XGBClassifier(
+ objective="binary:logistic",
+ n_estimators=500,
+ max_depth=5,
+ learning_rate=0.05,
+ subsample=0.8,
+ colsample_bytree=0.8,
+ min_child_weight=5,
+ reg_alpha=0.1,
+ reg_lambda=2.0,
+ scale_pos_weight=scale_pos_weight,
+ eval_metric="aucpr",
+ tree_method="hist",
+ random_state=42,
+ n_jobs=-1,
+ )
+
+
+def fit_model(train, validation, feature_names=MODEL_FEATURES):
+ X_train = train[feature_names]
+ y_train = train[TARGET]
+
+ X_validation = validation[feature_names]
+ y_validation = validation[TARGET]
+
+ preprocessor = build_preprocessor(feature_names)
+
+ X_train_processed = preprocessor.fit_transform(X_train)
+ X_validation_processed = preprocessor.transform(X_validation)
+
+ model = build_xgboost_model(y_train)
+ model.fit(
+ X_train_processed,
+ y_train,
+ )
+
+ return (
+ preprocessor,
+ model,
+ X_validation_processed,
+ y_validation,
+ )
\ No newline at end of file
diff --git a/src/utils/simple_ingestion.py b/src/utils/simple_ingestion.py
index 25bc473..5af7dbd 100644
--- a/src/utils/simple_ingestion.py
+++ b/src/utils/simple_ingestion.py
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
-"""
-Simple Real-Time Transaction Generator
-Generates transactions continuously to demonstrate the system
+"""Demo Transaction Stream Simulator.
+
+This random generator is only for demonstrating legacy UI/infrastructure; it
+is not used for SAML-D model training, backtesting, or performance claims.
"""
import requests
@@ -22,7 +23,7 @@ def generate_transaction(self):
currencies = ['USD', 'EUR', 'GBP', 'JPY', 'CAD']
locations = ['US', 'UK', 'EU', 'JP', 'CA', 'AU', 'SG']
- # Generate realistic transaction data
+ # Deliberately simple demo data, not realistic financial behaviour.
amount = random.uniform(100, 1000000)
transaction_type = random.choice(transaction_types)
@@ -60,7 +61,7 @@ def send_transaction(self, transaction):
if response.status_code == 200:
result = response.json()
self.transaction_count += 1
- print(f"Transaction {self.transaction_count}: {transaction['transaction_id']} - Risk Score: {result['risk_score']:.3f} - Amount: ${transaction['amount']:,.2f}")
+ print(f"Transaction {self.transaction_count}: {transaction['transaction_id']} - Risk Probability: {result['risk_probability']:.3f} - Amount: ${transaction['amount']:,.2f}")
return True
else:
print(f"Transaction failed: {response.status_code}")
@@ -72,7 +73,7 @@ def send_transaction(self, transaction):
def start_generation(self):
"""Start generating transactions continuously"""
- print("Starting Simple Real-Time Transaction Generator...")
+ print("Starting Demo Transaction Stream Simulator...")
print(f"API URL: {self.api_url}")
print("Generating transactions every 2-5 seconds...")
print("=" * 60)
@@ -110,7 +111,7 @@ def stop_generation(self):
def main():
"""Main function"""
print("=" * 60)
- print("SIMPLE REAL-TIME TRANSACTION GENERATOR")
+ print("DEMO TRANSACTION STREAM SIMULATOR")
print("=" * 60)
# Check if API is running
diff --git a/src/utils/start_system.py b/src/utils/start_system.py
index 809665c..042da60 100644
--- a/src/utils/start_system.py
+++ b/src/utils/start_system.py
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
-"""
-Simple startup script for the Real-Time Compliance Monitoring System
-Launches the API server and transaction generator
+"""Start the artifact-backed surveillance API and static dashboard.
+
+Live transaction simulation remains intentionally disabled until an online
+feature store supplies the model's behavioural history.
"""
import subprocess
@@ -17,9 +18,9 @@ def start_api_server():
print("š Starting API Server...")
try:
- print(f"š Starting API server with command: {sys.executable} src/api/simple_api_server.py")
+ print(f"š Starting API server with command: {sys.executable} -m src.api.app")
process = subprocess.Popen(
- [sys.executable, "src/api/simple_api_server.py"],
+ [sys.executable, "-m", "src.api.app"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
@@ -61,44 +62,6 @@ def start_api_server():
print(f"ā Error starting API Server: {e}")
return None
-def start_transaction_generator():
- """Start the transaction generator"""
- print("š” Starting Transaction Generator...")
-
- try:
- print(f"š Starting transaction generator with command: {sys.executable} src/utils/simple_ingestion.py")
- process = subprocess.Popen(
- [sys.executable, "src/utils/simple_ingestion.py"],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True
- )
-
- # Check if process started
- if process.poll() is not None:
- print("ā Transaction generator process failed to start")
- stdout, stderr = process.communicate()
- print(f"STDOUT: {stdout}")
- print(f"STDERR: {stderr}")
- return None
-
- # Wait for generator to start
- time.sleep(2)
-
- if process.poll() is None:
- print("ā
Transaction Generator started successfully")
- return process
- else:
- print("ā Failed to start Transaction Generator")
- stdout, stderr = process.communicate()
- print(f"STDOUT: {stdout}")
- print(f"STDERR: {stderr}")
- return None
-
- except Exception as e:
- print(f"ā Error starting Transaction Generator: {e}")
- return None
-
def start_dashboard_server():
"""Start the dashboard server"""
print("š Starting Dashboard Server...")
@@ -138,18 +101,10 @@ def start_dashboard_server():
print(f"ā Error starting Dashboard Server: {e}")
return None
-def cleanup(api_process, generator_process, dashboard_process):
+def cleanup(api_process, dashboard_process):
"""Clean up processes on exit"""
print("\nš Shutting down Real-Time Compliance System...")
- if generator_process:
- print("Stopping Transaction Generator...")
- generator_process.terminate()
- try:
- generator_process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- generator_process.kill()
-
if dashboard_process:
print("Stopping Dashboard Server...")
dashboard_process.terminate()
@@ -171,13 +126,12 @@ def cleanup(api_process, generator_process, dashboard_process):
def main():
"""Main startup function"""
print("=" * 60)
- print("š REAL-TIME COMPLIANCE MONITORING SYSTEM")
+ print("š QUANTITATIVE TRANSACTION RISK SURVEILLANCE ENGINE")
print("=" * 60)
# Check if required files exist
required_files = [
- "src/api/simple_api_server.py",
- "src/utils/simple_ingestion.py",
+ "src/api/app.py",
"src/dashboard/serve_dashboard.py",
"src/dashboard/real_time_dashboard.html"
]
@@ -190,7 +144,6 @@ def main():
print("ā
All required files found")
api_process = None
- generator_process = None
dashboard_process = None
try:
@@ -200,12 +153,6 @@ def main():
print("ā Failed to start API Server. Exiting...")
sys.exit(1)
- # Start transaction generator
- generator_process = start_transaction_generator()
- if not generator_process:
- print("ā Failed to start Transaction Generator. Exiting...")
- sys.exit(1)
-
# Start dashboard server
dashboard_process = start_dashboard_server()
if not dashboard_process:
@@ -213,11 +160,11 @@ def main():
sys.exit(1)
print("\n" + "=" * 60)
- print("š REAL-TIME COMPLIANCE MONITORING SYSTEM IS RUNNING!")
+ print("š SURVEILLANCE ENGINE IS RUNNING!")
print("=" * 60)
print("š Dashboard: http://localhost:8082/real_time_dashboard.html")
print("š API Server: http://localhost:5000")
- print("š” Transaction Generator: Running")
+ print("š” Demo stream: disabled until an online feature store is available")
print("\nš” Press Ctrl+C to stop the system")
print("=" * 60)
@@ -230,7 +177,7 @@ def main():
except Exception as e:
print(f"\nā Unexpected error: {e}")
finally:
- cleanup(api_process, generator_process, dashboard_process)
+ cleanup(api_process, dashboard_process)
if __name__ == "__main__":
main()
diff --git a/src/utils/test_ingestion.py b/src/utils/test_ingestion.py
index 0708d43..9652ca4 100644
--- a/src/utils/test_ingestion.py
+++ b/src/utils/test_ingestion.py
@@ -40,7 +40,7 @@ def test_ingestion():
response = requests.post('http://localhost:5000/api/process_transaction', json=test_tx)
if response.status_code == 200:
result = response.json()
- print(f"ā
Test Transaction Processed: Risk Score {result['risk_score']:.3f}")
+ print(f"ā
Test Transaction Processed: Risk Probability {result['risk_probability']:.3f}")
else:
print(f"ā Test Transaction Failed: {response.status_code}")
return
diff --git a/tests/test_features.py b/tests/test_features.py
new file mode 100644
index 0000000..8182bb2
--- /dev/null
+++ b/tests/test_features.py
@@ -0,0 +1,25 @@
+import pandas as pd
+
+from src.features.transaction_features import add_transaction_features
+
+
+def test_transaction_features_do_not_encode_account_ids():
+ frame = pd.DataFrame(
+ {
+ "timestamp": pd.to_datetime(["2026-01-01 12:00:00"]),
+ "Sender_account": ["sender-001"],
+ "Receiver_account": ["receiver-001"],
+ "Amount": [1000.0],
+ "Payment_currency": ["UK pounds"],
+ "Received_currency": ["UK pounds"],
+ "Sender_bank_location": ["UK"],
+ "Receiver_bank_location": ["UK"],
+ }
+ )
+
+ result = add_transaction_features(frame)
+
+ assert "log_amount" in result
+ assert "currency_mismatch" in result
+ assert result["Sender_account"].dtype == object
+ assert result["Receiver_account"].dtype == object
diff --git a/tests/test_inference.py b/tests/test_inference.py
new file mode 100644
index 0000000..3634e8a
--- /dev/null
+++ b/tests/test_inference.py
@@ -0,0 +1,27 @@
+from pathlib import Path
+
+import pytest
+
+from src.api.app import create_app
+from src.models.inference import model_input_from_features
+
+
+def test_inference_rejects_identifiers_and_targets():
+ with pytest.raises(ValueError, match="Identifier or target fields"):
+ model_input_from_features(
+ {"Amount": 100.0, "Sender_account": "not-a-feature"},
+ ["Amount"],
+ )
+
+
+def test_inference_requires_feature_store_output():
+ with pytest.raises(ValueError, match="Missing model features"):
+ model_input_from_features({"Amount": 100.0}, ["Amount", "sender_txn_count_24h"])
+
+
+def test_api_reports_missing_model_artifact(tmp_path: Path):
+ app = create_app(tmp_path / "absent.joblib")
+ response = app.test_client().get("/api/health")
+
+ assert response.status_code == 200
+ assert response.get_json()["model_loaded"] is False
diff --git a/tests/test_leakage.py b/tests/test_leakage.py
new file mode 100644
index 0000000..fc9019e
--- /dev/null
+++ b/tests/test_leakage.py
@@ -0,0 +1,13 @@
+from src.models.train import MODEL_FEATURES
+
+
+FORBIDDEN_FEATURES = {
+ "Is_laundering",
+ "Laundering_type",
+ "Sender_account",
+ "Receiver_account",
+}
+
+
+def test_no_identifier_or_target_leakage_features():
+ assert not (set(MODEL_FEATURES) & FORBIDDEN_FEATURES)