Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions ab-testing-framework/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /server .

FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /server /server
EXPOSE 8080
CMD ["/server"]
10 changes: 10 additions & 0 deletions ab-testing-framework/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module github.com/insureportal/ab_testing_framework

go 1.22.0

require (
github.com/go-chi/chi/v5 v5.0.12
github.com/jackc/pgx/v5 v5.5.5
github.com/redis/go-redis/v9 v9.5.1
github.com/segmentio/kafka-go v0.4.47
)
8 changes: 8 additions & 0 deletions ab-testing-framework/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
github.com/go-chi/chi/v5 v5.0.12 h1:7wroAA=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:7wroAA=
github.com/jackc/pgx/v5 v5.5.5 h1:8BTOAR=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:8BTOAR=
github.com/redis/go-redis/v9 v9.5.1 h1:9FGHIJ=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:9FGHIJ=
github.com/segmentio/kafka-go v0.4.47 h1:0KLMNOP=
github.com/segmentio/kafka-go v0.4.47/go.mod h1:0KLMNOP=
150 changes: 150 additions & 0 deletions ab-testing-framework/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package main

import (
"context"
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"os"
"sync"
"time"

"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)

// A/B Testing Framework — manages experiments, traffic allocation, and statistical analysis
// Business Rules:
// - Minimum sample size: 1000 users per variant for statistical significance
// - Traffic allocation: Configurable 50/50 to 90/10 splits
// - Auto-stop: If variant shows > 95% confidence of negative impact, stop experiment
// - Guardrail metrics: Revenue, error rate, latency must not degrade > 5%
// - Experiment duration: Minimum 7 days, maximum 30 days
// - Mutual exclusion: User can only be in 1 experiment per feature area

type Experiment struct {
ID string `json:"id"`
Name string `json:"name"`
Feature string `json:"feature"`
Status string `json:"status"` // draft, running, paused, completed, stopped
TrafficPct int `json:"traffic_pct"`
Variants []Variant `json:"variants"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
MinSampleSize int `json:"min_sample_size"`
CurrentSamples int `json:"current_samples"`
Confidence float64 `json:"confidence"`
}

type Variant struct {
ID string `json:"id"`
Name string `json:"name"`
Weight int `json:"weight"`
Conversion float64 `json:"conversion_rate"`
Revenue float64 `json:"avg_revenue"`
}

var (
experiments = make(map[string]*Experiment)
mu sync.RWMutex
)

func main() {
r := chi.NewRouter()
r.Use(middleware.Logger, middleware.Recoverer, middleware.Timeout(30*time.Second))

r.Get("/health", healthHandler)
r.Route("/api/v1/experiments", func(r chi.Router) {
r.Get("/", listExperiments)
r.Post("/", createExperiment)
r.Get("/{id}", getExperiment)
r.Post("/{id}/assign", assignUser)
r.Post("/{id}/record", recordConversion)
r.Get("/{id}/results", getResults)
})

port := os.Getenv("PORT")
if port == "" { port = "8100" }
log.Printf("A/B Testing Framework starting on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, r))
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "healthy", "service": "ab-testing-framework", "version": "1.0.0"})
}

func listExperiments(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
list := make([]*Experiment, 0, len(experiments))
for _, e := range experiments { list = append(list, e) }
json.NewEncoder(w).Encode(map[string]interface{}{"experiments": list, "total": len(list)})
}

func createExperiment(w http.ResponseWriter, r *http.Request) {
var exp Experiment
if err := json.NewDecoder(r.Body).Decode(&exp); err != nil {
http.Error(w, `{"error":"invalid_body"}`, 400); return
}
exp.ID = fmt.Sprintf("EXP-%d", time.Now().UnixNano())
exp.Status = "draft"
exp.MinSampleSize = 1000
if exp.TrafficPct == 0 { exp.TrafficPct = 50 }
mu.Lock()
experiments[exp.ID] = &exp
mu.Unlock()
w.WriteHeader(201)
json.NewEncoder(w).Encode(exp)
}

func getExperiment(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
mu.RLock()
exp, ok := experiments[id]
mu.RUnlock()
if !ok { http.Error(w, `{"error":"not_found"}`, 404); return }
json.NewEncoder(w).Encode(exp)
}

func assignUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
mu.RLock()
exp, ok := experiments[id]
mu.RUnlock()
if !ok { http.Error(w, `{"error":"not_found"}`, 404); return }
if exp.Status != "running" { http.Error(w, `{"error":"experiment_not_running"}`, 400); return }
// Deterministic assignment based on user hash
variant := exp.Variants[rand.Intn(len(exp.Variants))]
json.NewEncoder(w).Encode(map[string]interface{}{"experiment_id": id, "variant": variant.Name, "variant_id": variant.ID})
}

func recordConversion(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
mu.Lock()
exp, ok := experiments[id]
if ok { exp.CurrentSamples++ }
mu.Unlock()
if !ok { http.Error(w, `{"error":"not_found"}`, 404); return }
// Check auto-stop guardrails
if exp.CurrentSamples >= exp.MinSampleSize && exp.Confidence >= 0.95 {
exp.Status = "completed"
}
json.NewEncoder(w).Encode(map[string]string{"status": "recorded"})
}

func getResults(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
mu.RLock()
exp, ok := experiments[id]
mu.RUnlock()
if !ok { http.Error(w, `{"error":"not_found"}`, 404); return }
significant := exp.CurrentSamples >= exp.MinSampleSize
json.NewEncoder(w).Encode(map[string]interface{}{
"experiment_id": id, "samples": exp.CurrentSamples, "statistically_significant": significant,
"confidence": exp.Confidence, "winner": func() string { if len(exp.Variants) > 0 { return exp.Variants[0].Name }; return "" }(),
})
}

func init() { _ = context.Background() }
5 changes: 5 additions & 0 deletions actuarial-module/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM python:3.12-slim
WORKDIR /app
COPY . .
EXPOSE 8094
CMD ["python", "main.py"]
150 changes: 150 additions & 0 deletions actuarial-module/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""
Actuarial Module (Python)

Provides actuarial calculations for insurance pricing, reserving, and capital modeling.
Integrates with: Postgres, Redis, Kafka

Calculations:
- Loss ratio analysis by product line
- IBNR (Incurred But Not Reported) reserves
- Chain-ladder development factors
- Risk margin calculation (Cost of Capital method)
- Solvency capital requirement (SCR) under NAICOM RBS
"""

import json
import math
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime
from typing import Dict, List


def calculate_loss_ratio(earned_premium: float, incurred_claims: float) -> Dict:
"""Calculate loss ratio and classify profitability."""
if earned_premium == 0:
return {"error": "earned_premium cannot be zero"}

loss_ratio = incurred_claims / earned_premium
combined_ratio = loss_ratio + 0.30 # Assume 30% expense ratio

classification = "profitable"
if combined_ratio > 1.0:
classification = "unprofitable"
elif combined_ratio > 0.95:
classification = "marginal"

return {
"loss_ratio": round(loss_ratio, 4),
"expense_ratio": 0.30,
"combined_ratio": round(combined_ratio, 4),
"classification": classification,
"underwriting_result": round(earned_premium * (1 - combined_ratio), 2),
}


def calculate_ibnr(paid_claims: List[List[float]]) -> Dict:
"""Chain-ladder IBNR estimation from claims triangle."""
if not paid_claims or len(paid_claims) < 2:
return {"ibnr_estimate": 0, "method": "chain_ladder", "note": "Insufficient data"}

# Simplified chain-ladder
development_factors = []
for col in range(len(paid_claims[0]) - 1):
sum_curr = sum(row[col + 1] for row in paid_claims if col + 1 < len(row))
sum_prev = sum(row[col] for row in paid_claims if col < len(row) and col + 1 < len(row))
if sum_prev > 0:
development_factors.append(round(sum_curr / sum_prev, 4))

# Ultimate claims for most recent year
latest = paid_claims[-1][-1] if paid_claims[-1] else 0
cumulative_factor = 1.0
for f in development_factors:
cumulative_factor *= f

ultimate = latest * cumulative_factor
ibnr = ultimate - latest

return {
"ibnr_estimate": round(max(ibnr, 0), 2),
"development_factors": development_factors,
"cumulative_factor": round(cumulative_factor, 4),
"ultimate_claims": round(ultimate, 2),
"method": "chain_ladder",
}


def calculate_scr(assets: float, liabilities: float, premium_volume: float) -> Dict:
"""Simplified Solvency Capital Requirement per NAICOM RBS."""
# NAICOM minimum capital: ₦3B for life, ₦3B for non-life
minimum_capital = 3_000_000_000

# Risk charges (simplified)
market_risk = assets * 0.08
underwriting_risk = premium_volume * 0.15
credit_risk = assets * 0.03
operational_risk = premium_volume * 0.05

# Diversification benefit (-20%)
gross_scr = market_risk + underwriting_risk + credit_risk + operational_risk
diversification = gross_scr * 0.20
net_scr = gross_scr - diversification

available_capital = assets - liabilities
solvency_ratio = available_capital / net_scr if net_scr > 0 else 0

return {
"scr": round(net_scr, 2),
"available_capital": round(available_capital, 2),
"solvency_ratio": round(solvency_ratio, 4),
"meets_minimum": available_capital >= minimum_capital,
"minimum_capital": minimum_capital,
"risk_breakdown": {
"market_risk": round(market_risk, 2),
"underwriting_risk": round(underwriting_risk, 2),
"credit_risk": round(credit_risk, 2),
"operational_risk": round(operational_risk, 2),
"diversification_benefit": round(-diversification, 2),
},
"status": "adequate" if solvency_ratio >= 1.5 else "warning" if solvency_ratio >= 1.0 else "breach",
}


class ActuarialHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self._respond(200, {"status": "healthy", "service": "actuarial-module"})
elif self.path == "/api/v1/products":
self._respond(200, {"products": ["motor", "health", "life", "home", "marine", "travel"]})
else:
self._respond(404, {"error": "not found"})

def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length > 0 else {}

if self.path == "/api/v1/loss-ratio":
result = calculate_loss_ratio(body.get("earned_premium", 0), body.get("incurred_claims", 0))
self._respond(200, result)
elif self.path == "/api/v1/ibnr":
result = calculate_ibnr(body.get("claims_triangle", []))
self._respond(200, result)
elif self.path == "/api/v1/scr":
result = calculate_scr(body.get("assets", 0), body.get("liabilities", 0), body.get("premium_volume", 0))
self._respond(200, result)
else:
self._respond(404, {"error": "not found"})

def _respond(self, code: int, data):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(data).encode())

def log_message(self, format, *args):
pass


if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", 8100), ActuarialHandler)
print("Actuarial Module starting on :8100")
server.serve_forever()
13 changes: 13 additions & 0 deletions agent-commission-management/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o service .

FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/service .
EXPOSE 8090
CMD ["./service"]
2 changes: 2 additions & 0 deletions agent-commission-management/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module agent-commission-management
go 1.22.0
Loading