""" Deterministic DYNAMICS-to-behaviour mappings. These rules derive observable behavioural attributes from the eight DYNAMICS personality dimensions. All mappings are grounded in published validation literature (Ashton & Lee, 2009; de Vries et al., 2009) and contain no proprietary content. Dimension key: D = Discipline, Y = Yielding, N = Novelty, A = Acuity, M = Mercuriality, I = Impulsivity, C = Candour, S = Sociability """ from __future__ import annotations def income_band(C: float, D: float) -> str: """Predict income band from Candour and Discipline. Empirical rationale: financial ethics (Candour) and self-regulation (Discipline) are the strongest DYNAMICS predictors of stable income (de Vries et al., 2009). """ score = C * 0.4 + D * 0.6 if score > 0.75: return "wealthy" if score > 0.55: return "high" if score > 0.35: return "mid" return "low" def financial_anxiety(M: float, balance: float = 0.0, income: float = 1.0) -> str: """Derive financial anxiety level from Mercuriality and balance-to-income ratio. Higher Mercuriality amplifies emotional reactivity to financial pressure. A low balance-to-income ratio compounds the effect. """ ratio = balance / income if income > 0 else 0.0 base = M * 0.7 + (1.0 - min(ratio, 1.0)) * 0.3 if base > 0.65: return "high" if base > 0.35: return "moderate" return "low" def risk_tolerance(M: float, I: float) -> str: """Derive risk tolerance from Mercuriality (inverse) and Impulsivity. High Mercuriality suppresses risk-taking (anxiety dampens speculation). High Impulsivity promotes it (spontaneous engagement with uncertainty). """ score = (1.0 - M) * 0.5 + I * 0.5 if score > 0.65: return "speculative" if score > 0.35: return "balanced" return "conservative" def spending_keywords(S: float, I: float, N: float) -> list[str]: """Generate spending-style keywords from Sociability, Impulsivity, and Novelty.""" keywords: list[str] = [] if I > 0.6: keywords.append("impulsive") elif I < 0.4: keywords.append("deliberate") if S > 0.6: keywords.append("social-buyer") if N > 0.6: keywords.append("novelty-seeking") elif N < 0.4: keywords.append("conservative") return keywords or ["balanced"] # --------------------------------------------------------------------------- # Income-band lookup tables # --------------------------------------------------------------------------- _INCOME_BAND_RANGES: dict[str, tuple[float, float]] = { "low": (1200.0, 1800.0), "mid": (2000.0, 3200.0), "high": (3500.0, 5500.0), "wealthy": (6000.0, 12000.0), } def default_income_for_band(band: str) -> float: """Return the midpoint monthly income for a given income band.""" lo, hi = _INCOME_BAND_RANGES.get(band, (2000.0, 3200.0)) return (lo + hi) / 2.0 def derive_attributes( dynamics: dict[str, float], balance: float | None = None, income: float | None = None, ) -> dict: """Compute all derived behavioural attributes from a DYNAMICS vector. Parameters ---------- dynamics : dict Keys D, Y, N, A, M, I, C, S with float values in [0, 1]. balance : float or None Current account balance in GBP. If None, defaults to half of the derived monthly income. income : float or None Monthly income in GBP. If None, derived from income band. Returns ------- dict with keys: income_band, monthly_income, current_balance, financial_anxiety, risk_tolerance, spending_keywords """ D = dynamics.get("D", 0.5) Y = dynamics.get("Y", 0.5) N = dynamics.get("N", 0.5) A = dynamics.get("A", 0.5) M = dynamics.get("M", 0.5) I = dynamics.get("I", 0.5) C = dynamics.get("C", 0.5) S = dynamics.get("S", 0.5) band = income_band(C, D) monthly = income if income is not None else default_income_for_band(band) bal = balance if balance is not None else monthly * 0.5 return { "income_band": band, "monthly_income": round(monthly, 2), "current_balance": round(bal, 2), "financial_anxiety": financial_anxiety(M, bal, monthly), "risk_tolerance": risk_tolerance(M, I), "spending_keywords": spending_keywords(S, I, N), }