finforge 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. finforge/__init__.py +5 -0
  2. finforge/behavior/__init__.py +1 -0
  3. finforge/behavior/adaptive_spending.py +129 -0
  4. finforge/behavior/balance_awareness.py +94 -0
  5. finforge/behavior/budgeting.py +78 -0
  6. finforge/behavior/clustering.py +102 -0
  7. finforge/behavior/identity.py +168 -0
  8. finforge/behavior/lifecycle.py +64 -0
  9. finforge/behavior/merchant_affinity.py +126 -0
  10. finforge/behavior/overdraft.py +49 -0
  11. finforge/behavior/sessions.py +230 -0
  12. finforge/behavior/spending_patterns.py +152 -0
  13. finforge/behavior/subscriptions.py +70 -0
  14. finforge/core/__init__.py +1 -0
  15. finforge/core/config.py +31 -0
  16. finforge/core/constants.py +13 -0
  17. finforge/core/enums.py +17 -0
  18. finforge/core/models.py +68 -0
  19. finforge/dataset.py +142 -0
  20. finforge/exporters/__init__.py +1 -0
  21. finforge/exporters/csv_exporter.py +18 -0
  22. finforge/generators/__init__.py +1 -0
  23. finforge/generators/scheduler.py +34 -0
  24. finforge/generators/transaction_generator.py +417 -0
  25. finforge/generators/user_generator.py +76 -0
  26. finforge/llm/__init__.py +5 -0
  27. finforge/llm/interfaces.py +12 -0
  28. finforge/merchants/__init__.py +1 -0
  29. finforge/merchants/catalog.py +120 -0
  30. finforge/personas/__init__.py +1 -0
  31. finforge/personas/base.py +61 -0
  32. finforge/personas/salaried.py +103 -0
  33. finforge/personas/student.py +86 -0
  34. finforge/utils/__init__.py +1 -0
  35. finforge/utils/balances.py +10 -0
  36. finforge/utils/dates.py +30 -0
  37. finforge/utils/randomness.py +24 -0
  38. finforge-1.0.0.dist-info/METADATA +282 -0
  39. finforge-1.0.0.dist-info/RECORD +42 -0
  40. finforge-1.0.0.dist-info/WHEEL +5 -0
  41. finforge-1.0.0.dist-info/licenses/LICENSE +21 -0
  42. finforge-1.0.0.dist-info/top_level.txt +1 -0
finforge/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Public package exports for FinForge."""
2
+
3
+ from finforge.dataset import DatasetGenerator
4
+
5
+ __all__ = ["DatasetGenerator"]
@@ -0,0 +1 @@
1
+ """Behavioral simulation components for FinForge."""
@@ -0,0 +1,129 @@
1
+ """Adaptive spending adjustments based on balance, budget, and lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from finforge.behavior.budgeting import BudgetingEngine, UserBudgetState
8
+ from finforge.core.models import User
9
+ from finforge.personas.base import SpendingProfile
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AdaptiveSpendingSignal:
14
+ """Per-day spending signal derived from balance and memory."""
15
+
16
+ state: str
17
+ frequency_multiplier: float
18
+ amount_multiplier: float
19
+ category_multipliers: dict[str, float]
20
+ amount_multipliers: dict[str, float]
21
+ overspend_pressure: float
22
+ lifecycle_phase: str
23
+ max_discretionary_transactions: int | None
24
+
25
+
26
+ class AdaptiveSpendingEngine:
27
+ """Combines balance, month phase, and recent spend memory."""
28
+
29
+ def __init__(self, budgeting_engine: BudgetingEngine) -> None:
30
+ self.budgeting_engine = budgeting_engine
31
+
32
+ def assess(
33
+ self,
34
+ user: User,
35
+ state: UserBudgetState,
36
+ day_of_month: int,
37
+ days_in_month: int,
38
+ spending_profile: SpendingProfile,
39
+ ) -> AdaptiveSpendingSignal:
40
+ """Compute day-level adaptive spending controls."""
41
+ phase = self._phase(day_of_month, days_in_month)
42
+ overspend = self.budgeting_engine.overspend_pressure(state)
43
+ low_threshold, high_threshold = self._thresholds(user)
44
+ category_multipliers = {category: 1.0 for category in user.category_affinities}
45
+ amount_multipliers = {category: 1.0 for category in user.category_affinities}
46
+
47
+ for category, affinity in user.category_affinities.items():
48
+ category_multipliers[category] *= affinity
49
+
50
+ if phase == "early":
51
+ category_multipliers["shopping"] = category_multipliers.get("shopping", 1.0) * (1.05 + user.impulse_buying_score * 0.18)
52
+ category_multipliers["entertainment"] = category_multipliers.get("entertainment", 1.0) * (1.03 + user.entertainment_preference * 0.15)
53
+ elif phase == "late":
54
+ for category, multiplier in spending_profile.month_end_category_multipliers.items():
55
+ category_multipliers[category] = category_multipliers.get(category, 1.0) * multiplier
56
+
57
+ if state.balance <= low_threshold:
58
+ low_balance_multipliers = {
59
+ "entertainment": 0.10,
60
+ "shopping": 0.15,
61
+ "food": 0.40,
62
+ "coffee": 0.50,
63
+ "travel": 0.80,
64
+ "groceries": 1.20,
65
+ }
66
+ low_balance_amount_multipliers = {
67
+ "entertainment": 0.50,
68
+ "shopping": 0.50,
69
+ "food": 0.65,
70
+ "coffee": 0.75,
71
+ "travel": 0.90,
72
+ "groceries": 1.00,
73
+ }
74
+ for category, multiplier in low_balance_multipliers.items():
75
+ category_multipliers[category] = category_multipliers.get(category, 1.0) * multiplier
76
+ for category, multiplier in low_balance_amount_multipliers.items():
77
+ amount_multipliers[category] = amount_multipliers.get(category, 1.0) * multiplier
78
+ signal_state = "low"
79
+ frequency_multiplier = 0.26 if user.persona.value == "student" else 0.34
80
+ amount_multiplier = 0.46
81
+ max_discretionary_transactions = 1 if user.persona.value == "student" else 2
82
+ elif state.balance >= high_threshold:
83
+ for category, multiplier in spending_profile.high_balance_category_multipliers.items():
84
+ category_multipliers[category] = category_multipliers.get(category, 1.0) * multiplier
85
+ signal_state = "high"
86
+ frequency_multiplier = 1.10
87
+ amount_multiplier = 1.08
88
+ max_discretionary_transactions = None
89
+ else:
90
+ signal_state = "normal"
91
+ frequency_multiplier = 1.0
92
+ amount_multiplier = 1.0
93
+ max_discretionary_transactions = None
94
+
95
+ if overspend > 1.0:
96
+ pressure_discount = min((overspend - 1.0) * 0.35, 0.4)
97
+ frequency_multiplier *= 1.0 - pressure_discount
98
+ amount_multiplier *= 1.0 - pressure_discount * 0.8
99
+ category_multipliers["shopping"] = category_multipliers.get("shopping", 1.0) * 0.45
100
+ category_multipliers["entertainment"] = category_multipliers.get("entertainment", 1.0) * 0.55
101
+ category_multipliers["food"] = category_multipliers.get("food", 1.0) * 0.78
102
+ amount_multipliers["shopping"] = amount_multipliers.get("shopping", 1.0) * 0.7
103
+ amount_multipliers["entertainment"] = amount_multipliers.get("entertainment", 1.0) * 0.75
104
+ amount_multipliers["food"] = amount_multipliers.get("food", 1.0) * 0.85
105
+
106
+ return AdaptiveSpendingSignal(
107
+ state=signal_state,
108
+ frequency_multiplier=round(frequency_multiplier, 3),
109
+ amount_multiplier=round(amount_multiplier, 3),
110
+ category_multipliers={key: round(value, 3) for key, value in category_multipliers.items()},
111
+ amount_multipliers={key: round(value, 3) for key, value in amount_multipliers.items()},
112
+ overspend_pressure=round(overspend, 3),
113
+ lifecycle_phase=phase,
114
+ max_discretionary_transactions=max_discretionary_transactions,
115
+ )
116
+
117
+ def _phase(self, day_of_month: int, days_in_month: int) -> str:
118
+ """Map a day in month into a lifecycle phase."""
119
+ if day_of_month <= 7:
120
+ return "early"
121
+ if day_of_month >= max(days_in_month - 5, 25):
122
+ return "late"
123
+ return "mid"
124
+
125
+ def _thresholds(self, user: User) -> tuple[float, float]:
126
+ """Return persona-level low and high balance thresholds."""
127
+ if user.persona.value == "student":
128
+ return 500.0, 5000.0
129
+ return 5000.0, 50000.0
@@ -0,0 +1,94 @@
1
+ """Balance-aware spending adjustments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from finforge.personas.base import SpendingProfile
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class BalanceAdjustment:
12
+ """Spending modifiers derived from current financial headroom."""
13
+
14
+ state: str
15
+ frequency_multiplier: float
16
+ amount_multiplier: float
17
+ category_multipliers: dict[str, float]
18
+
19
+
20
+ class BalanceAwarenessEngine:
21
+ """Adjusts spending based on current liquidity and month timing."""
22
+
23
+ def assess(
24
+ self,
25
+ current_balance: float,
26
+ monthly_income: float,
27
+ day_of_month: int,
28
+ days_in_month: int,
29
+ spending_profile: SpendingProfile,
30
+ savings_preference: float,
31
+ ) -> BalanceAdjustment:
32
+ """Return spending modifiers for the user's current financial state."""
33
+ low_threshold = max(monthly_income * 0.18 * savings_preference, 120.0)
34
+ high_threshold = max(monthly_income * 0.85, 900.0)
35
+ month_end = day_of_month >= max(days_in_month - 4, 24)
36
+
37
+ if current_balance <= low_threshold:
38
+ category_multipliers = dict(spending_profile.low_balance_category_multipliers)
39
+ if month_end:
40
+ category_multipliers = self._merge_category_multipliers(
41
+ category_multipliers,
42
+ spending_profile.month_end_category_multipliers,
43
+ )
44
+ return BalanceAdjustment(
45
+ state="low",
46
+ frequency_multiplier=0.50,
47
+ amount_multiplier=0.72,
48
+ category_multipliers=category_multipliers,
49
+ )
50
+
51
+ if current_balance >= high_threshold:
52
+ category_multipliers = dict(spending_profile.high_balance_category_multipliers)
53
+ if month_end:
54
+ category_multipliers = self._merge_category_multipliers(
55
+ category_multipliers,
56
+ spending_profile.month_end_category_multipliers,
57
+ )
58
+ return BalanceAdjustment(
59
+ state="high",
60
+ frequency_multiplier=1.15,
61
+ amount_multiplier=1.10,
62
+ category_multipliers=category_multipliers,
63
+ )
64
+
65
+ category_multipliers = {category: 1.0 for category in spending_profile.category_weights_weekday}
66
+ if month_end:
67
+ category_multipliers = self._merge_category_multipliers(
68
+ category_multipliers,
69
+ spending_profile.month_end_category_multipliers,
70
+ )
71
+ return BalanceAdjustment(
72
+ state="normal",
73
+ frequency_multiplier=0.88,
74
+ amount_multiplier=0.92,
75
+ category_multipliers=category_multipliers,
76
+ )
77
+
78
+ return BalanceAdjustment(
79
+ state="normal",
80
+ frequency_multiplier=1.0,
81
+ amount_multiplier=1.0,
82
+ category_multipliers=category_multipliers,
83
+ )
84
+
85
+ def _merge_category_multipliers(
86
+ self,
87
+ base: dict[str, float],
88
+ overlay: dict[str, float],
89
+ ) -> dict[str, float]:
90
+ """Multiply an existing category map by another one."""
91
+ merged = dict(base)
92
+ for category, multiplier in overlay.items():
93
+ merged[category] = round(merged.get(category, 1.0) * multiplier, 4)
94
+ return merged
@@ -0,0 +1,78 @@
1
+ """Budget memory and discretionary spending state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from dataclasses import dataclass, field
7
+
8
+ from finforge.core.models import User
9
+
10
+
11
+ @dataclass
12
+ class UserBudgetState:
13
+ """Mutable simulation state for a user's cashflow and spending memory."""
14
+
15
+ balance: float
16
+ discretionary_budget: float
17
+ discretionary_spent: float = 0.0
18
+ essentials_spent: float = 0.0
19
+ income_received: float = 0.0
20
+ pressure_score: float = 0.0
21
+ recent_discretionary_spend: deque[float] = field(default_factory=lambda: deque(maxlen=10))
22
+ recent_daily_spend: deque[float] = field(default_factory=lambda: deque(maxlen=14))
23
+
24
+
25
+ class BudgetingEngine:
26
+ """Tracks ongoing spending pressure and budget fatigue."""
27
+
28
+ DISCRETIONARY_CATEGORIES = {"food", "shopping", "entertainment"}
29
+
30
+ def start_user(self, user: User) -> UserBudgetState:
31
+ """Create a budget state for a new user."""
32
+ return UserBudgetState(
33
+ balance=user.initial_balance,
34
+ discretionary_budget=self._monthly_discretionary_budget(user),
35
+ )
36
+
37
+ def start_month(self, state: UserBudgetState, user: User) -> None:
38
+ """Reset month-scoped budget counters while keeping recent memory."""
39
+ state.discretionary_budget = self._monthly_discretionary_budget(user)
40
+ state.discretionary_spent = 0.0
41
+ state.essentials_spent = 0.0
42
+ state.income_received = 0.0
43
+ state.pressure_score = 0.0
44
+
45
+ def record_transaction(self, state: UserBudgetState, amount: float, category: str) -> None:
46
+ """Update budgeting memory after a transaction is applied."""
47
+ if amount > 0:
48
+ state.income_received += amount
49
+ if category == "income" and state.discretionary_budget < amount * 0.35:
50
+ state.discretionary_budget += amount * 0.08
51
+ return
52
+
53
+ spend = abs(amount)
54
+ if category in self.DISCRETIONARY_CATEGORIES:
55
+ state.discretionary_spent += spend
56
+ state.recent_discretionary_spend.append(spend)
57
+ state.recent_daily_spend.append(spend)
58
+ elif category != "income":
59
+ state.essentials_spent += spend
60
+ state.recent_daily_spend.append(spend * 0.35)
61
+
62
+ budget_ratio = state.discretionary_spent / max(state.discretionary_budget, 1.0)
63
+ recent_pressure = sum(state.recent_discretionary_spend) / max(state.discretionary_budget * 0.35, 1.0)
64
+ state.pressure_score = round(max(budget_ratio, recent_pressure), 3)
65
+
66
+ def overspend_pressure(self, state: UserBudgetState) -> float:
67
+ """Return a smooth overspend indicator."""
68
+ return min(max(state.pressure_score, 0.0), 2.2)
69
+
70
+ def _monthly_discretionary_budget(self, user: User) -> float:
71
+ """Derive a discretionary budget from user traits."""
72
+ base_ratio = 0.34 if user.persona.value == "student" else 0.27
73
+ ratio = base_ratio * user.spending_intensity / max(user.savings_tendency, 0.5)
74
+ ratio *= 0.75 + user.lifestyle_score * 0.45
75
+ budget = max(user.monthly_income * ratio, 80.0)
76
+ reserved_subscriptions = sum(user.recurring_subscription_amounts.values())
77
+ budget -= reserved_subscriptions * 0.55
78
+ return round(max(budget, 45.0), 2)
@@ -0,0 +1,102 @@
1
+ """Temporal clustering for human-like transaction bursts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date, datetime, timedelta
7
+
8
+ from finforge.utils.dates import combine_timestamp
9
+ from finforge.utils.randomness import RandomContext
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class SessionTemplate:
14
+ """A reusable daily spending cluster pattern."""
15
+
16
+ name: str
17
+ category_sequence: tuple[str, ...]
18
+ hour_range: tuple[int, int]
19
+ minute_offsets: tuple[int, ...]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ClusteredEvent:
24
+ """A planned transaction event inside a behavioral cluster."""
25
+
26
+ timestamp: datetime
27
+ category: str
28
+
29
+
30
+ class ClusteringEngine:
31
+ """Generates grouped transaction bursts instead of isolated events."""
32
+
33
+ def __init__(self, random_context: RandomContext) -> None:
34
+ self.random_context = random_context
35
+ self.templates = {
36
+ "commute_coffee": SessionTemplate(
37
+ name="commute_coffee",
38
+ category_sequence=("travel", "coffee"),
39
+ hour_range=(7, 9),
40
+ minute_offsets=(0, 35),
41
+ ),
42
+ "lunch_run": SessionTemplate(
43
+ name="lunch_run",
44
+ category_sequence=("food",),
45
+ hour_range=(12, 14),
46
+ minute_offsets=(0,),
47
+ ),
48
+ "grocery_stop": SessionTemplate(
49
+ name="grocery_stop",
50
+ category_sequence=("groceries",),
51
+ hour_range=(18, 21),
52
+ minute_offsets=(0,),
53
+ ),
54
+ "weekend_hangout": SessionTemplate(
55
+ name="weekend_hangout",
56
+ category_sequence=("food", "entertainment"),
57
+ hour_range=(13, 20),
58
+ minute_offsets=(0, 110),
59
+ ),
60
+ "shopping_trip": SessionTemplate(
61
+ name="shopping_trip",
62
+ category_sequence=("shopping", "food"),
63
+ hour_range=(12, 18),
64
+ minute_offsets=(0, 95),
65
+ ),
66
+ "cinema_night": SessionTemplate(
67
+ name="cinema_night",
68
+ category_sequence=("entertainment", "food"),
69
+ hour_range=(18, 21),
70
+ minute_offsets=(0, 140),
71
+ ),
72
+ "food_run": SessionTemplate(
73
+ name="food_run",
74
+ category_sequence=("food",),
75
+ hour_range=(18, 22),
76
+ minute_offsets=(0,),
77
+ ),
78
+ "campus_commute": SessionTemplate(
79
+ name="campus_commute",
80
+ category_sequence=("coffee", "food", "travel"),
81
+ hour_range=(8, 11),
82
+ minute_offsets=(0, 190, 430),
83
+ ),
84
+ "late_streaming": SessionTemplate(
85
+ name="late_streaming",
86
+ category_sequence=("entertainment",),
87
+ hour_range=(21, 23),
88
+ minute_offsets=(0,),
89
+ ),
90
+ }
91
+
92
+ def build_session(self, target_date: date, template_name: str) -> list[ClusteredEvent]:
93
+ """Generate clustered events for a daily session template."""
94
+ template = self.templates[template_name]
95
+ base_hour = self.random_context.rng.randint(template.hour_range[0], template.hour_range[1])
96
+ base_minute = self.random_context.rng.randint(0, 45)
97
+ base_timestamp = combine_timestamp(target_date, base_hour, base_minute)
98
+ events: list[ClusteredEvent] = []
99
+ for category, offset in zip(template.category_sequence, template.minute_offsets):
100
+ timestamp = base_timestamp + timedelta(minutes=offset)
101
+ events.append(ClusteredEvent(timestamp=timestamp, category=category))
102
+ return events
@@ -0,0 +1,168 @@
1
+ """Persistent user identity generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from finforge.core.models import User
8
+ from finforge.utils.randomness import RandomContext
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class IdentityProfile:
13
+ """Stable long-term behavioral traits for a user."""
14
+
15
+ spending_style: str
16
+ savings_tendency: float
17
+ merchant_loyalty: float
18
+ lifestyle_score: float
19
+ impulse_buying_score: float
20
+ entertainment_preference: float
21
+ preferred_categories: list[str]
22
+ category_affinities: dict[str, float]
23
+ commute_pattern: str
24
+ night_activity_score: float
25
+ spending_intensity: float
26
+ savings_preference: float
27
+
28
+
29
+ class IdentityEngine:
30
+ """Creates persistent financial personalities."""
31
+
32
+ _STYLE_LIBRARY = {
33
+ "salaried": (
34
+ {
35
+ "name": "budget_conscious",
36
+ "weight": 0.35,
37
+ "savings_tendency": (1.15, 1.45),
38
+ "merchant_loyalty": (0.65, 0.9),
39
+ "lifestyle_score": (0.25, 0.45),
40
+ "impulse_buying_score": (0.15, 0.35),
41
+ "entertainment_preference": (0.2, 0.4),
42
+ "preferred_categories": ("groceries", "travel", "coffee"),
43
+ "commute_patterns": ("public_transit", "mixed"),
44
+ "night_activity_score": (0.15, 0.35),
45
+ "spending_intensity": (0.72, 0.95),
46
+ },
47
+ {
48
+ "name": "lifestyle_spender",
49
+ "weight": 0.40,
50
+ "savings_tendency": (0.7, 0.95),
51
+ "merchant_loyalty": (0.55, 0.8),
52
+ "lifestyle_score": (0.65, 0.95),
53
+ "impulse_buying_score": (0.45, 0.7),
54
+ "entertainment_preference": (0.65, 0.9),
55
+ "preferred_categories": ("food", "entertainment", "shopping"),
56
+ "commute_patterns": ("ride_hailing", "mixed"),
57
+ "night_activity_score": (0.45, 0.75),
58
+ "spending_intensity": (1.05, 1.3),
59
+ },
60
+ {
61
+ "name": "minimalist",
62
+ "weight": 0.25,
63
+ "savings_tendency": (1.2, 1.55),
64
+ "merchant_loyalty": (0.75, 0.95),
65
+ "lifestyle_score": (0.15, 0.35),
66
+ "impulse_buying_score": (0.1, 0.25),
67
+ "entertainment_preference": (0.12, 0.25),
68
+ "preferred_categories": ("groceries", "coffee", "travel"),
69
+ "commute_patterns": ("public_transit",),
70
+ "night_activity_score": (0.08, 0.25),
71
+ "spending_intensity": (0.55, 0.82),
72
+ },
73
+ ),
74
+ "student": (
75
+ {
76
+ "name": "impulsive_student",
77
+ "weight": 0.45,
78
+ "savings_tendency": (0.45, 0.75),
79
+ "merchant_loyalty": (0.45, 0.7),
80
+ "lifestyle_score": (0.55, 0.85),
81
+ "impulse_buying_score": (0.7, 0.95),
82
+ "entertainment_preference": (0.7, 0.95),
83
+ "preferred_categories": ("entertainment", "food", "coffee"),
84
+ "commute_patterns": ("ride_hailing", "mixed"),
85
+ "night_activity_score": (0.65, 0.95),
86
+ "spending_intensity": (1.1, 1.4),
87
+ },
88
+ {
89
+ "name": "budget_conscious",
90
+ "weight": 0.35,
91
+ "savings_tendency": (0.95, 1.2),
92
+ "merchant_loyalty": (0.6, 0.85),
93
+ "lifestyle_score": (0.25, 0.45),
94
+ "impulse_buying_score": (0.2, 0.4),
95
+ "entertainment_preference": (0.3, 0.55),
96
+ "preferred_categories": ("food", "groceries", "travel"),
97
+ "commute_patterns": ("public_transit", "mixed"),
98
+ "night_activity_score": (0.25, 0.5),
99
+ "spending_intensity": (0.75, 0.98),
100
+ },
101
+ {
102
+ "name": "minimalist",
103
+ "weight": 0.20,
104
+ "savings_tendency": (1.05, 1.35),
105
+ "merchant_loyalty": (0.72, 0.92),
106
+ "lifestyle_score": (0.15, 0.3),
107
+ "impulse_buying_score": (0.12, 0.25),
108
+ "entertainment_preference": (0.15, 0.3),
109
+ "preferred_categories": ("groceries", "coffee", "food"),
110
+ "commute_patterns": ("public_transit",),
111
+ "night_activity_score": (0.05, 0.2),
112
+ "spending_intensity": (0.55, 0.8),
113
+ },
114
+ ),
115
+ }
116
+
117
+ _CATEGORY_BIASES = {
118
+ "budget_conscious": {"groceries": 1.2, "coffee": 0.9, "travel": 1.0, "food": 0.9, "shopping": 0.55, "entertainment": 0.6},
119
+ "lifestyle_spender": {"groceries": 0.85, "coffee": 1.0, "travel": 1.0, "food": 1.25, "shopping": 1.15, "entertainment": 1.3},
120
+ "minimalist": {"groceries": 1.15, "coffee": 0.9, "travel": 0.95, "food": 0.8, "shopping": 0.45, "entertainment": 0.45},
121
+ "impulsive_student": {"groceries": 0.7, "coffee": 1.05, "travel": 0.85, "food": 1.15, "shopping": 0.95, "entertainment": 1.4},
122
+ }
123
+
124
+ def __init__(self, random_context: RandomContext) -> None:
125
+ self.random_context = random_context
126
+
127
+ def build_for_user(self, user: User) -> IdentityProfile:
128
+ """Create a persistent identity profile for a user."""
129
+ templates = self._STYLE_LIBRARY[user.persona.value]
130
+ selected = self.random_context.rng.choices(
131
+ population=list(templates),
132
+ weights=[template["weight"] for template in templates],
133
+ k=1,
134
+ )[0]
135
+ style_name = str(selected["name"])
136
+ preferred_categories = list(selected["preferred_categories"])
137
+ self.random_context.rng.shuffle(preferred_categories)
138
+ preferred_categories = preferred_categories[:2]
139
+ commute_pattern = self.random_context.rng.choice(list(selected["commute_patterns"]))
140
+
141
+ category_affinities = self._build_category_affinities(style_name)
142
+ return IdentityProfile(
143
+ spending_style=style_name,
144
+ savings_tendency=round(self._sample_range(selected["savings_tendency"]), 2),
145
+ merchant_loyalty=round(self._sample_range(selected["merchant_loyalty"]), 2),
146
+ lifestyle_score=round(self._sample_range(selected["lifestyle_score"]), 2),
147
+ impulse_buying_score=round(self._sample_range(selected["impulse_buying_score"]), 2),
148
+ entertainment_preference=round(self._sample_range(selected["entertainment_preference"]), 2),
149
+ preferred_categories=preferred_categories,
150
+ category_affinities=category_affinities,
151
+ commute_pattern=commute_pattern,
152
+ night_activity_score=round(self._sample_range(selected["night_activity_score"]), 2),
153
+ spending_intensity=round(self._sample_range(selected["spending_intensity"]), 2),
154
+ savings_preference=round(self._sample_range(selected["savings_tendency"]), 2),
155
+ )
156
+
157
+ def _build_category_affinities(self, style_name: str) -> dict[str, float]:
158
+ """Build stable user category affinities."""
159
+ base_bias = self._CATEGORY_BIASES[style_name]
160
+ affinities = {}
161
+ for category, bias in base_bias.items():
162
+ noise = self.random_context.rng.uniform(0.92, 1.08)
163
+ affinities[category] = round(bias * noise, 3)
164
+ return affinities
165
+
166
+ def _sample_range(self, value_range: tuple[float, float]) -> float:
167
+ """Sample a random value from a range."""
168
+ return self.random_context.rng.uniform(value_range[0], value_range[1])
@@ -0,0 +1,64 @@
1
+ """Monthly lifecycle and cashflow helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+
8
+ from finforge.behavior.budgeting import UserBudgetState
9
+ from finforge.core.models import User
10
+ from finforge.utils.randomness import RandomContext
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class LifecycleContext:
15
+ """Lightweight lifecycle classification for a given day."""
16
+
17
+ phase: str
18
+ is_weekend: bool
19
+ financial_stress: bool
20
+
21
+
22
+ class FinancialLifecycleEngine:
23
+ """Encodes persona-specific monthly financial rhythm."""
24
+
25
+ def __init__(self, random_context: RandomContext) -> None:
26
+ self.random_context = random_context
27
+
28
+ def context_for_day(self, user: User, target_date: date, state: UserBudgetState, days_in_month: int) -> LifecycleContext:
29
+ """Return lifecycle context for a user on a day."""
30
+ if target_date.day <= 7:
31
+ phase = "early"
32
+ elif target_date.day >= max(days_in_month - 5, 25):
33
+ phase = "late"
34
+ else:
35
+ phase = "mid"
36
+ financial_stress = phase == "late" or state.balance < max(user.monthly_income * 0.14, 120.0)
37
+ return LifecycleContext(
38
+ phase=phase,
39
+ is_weekend=target_date.weekday() >= 5,
40
+ financial_stress=financial_stress,
41
+ )
42
+
43
+ def irregular_income_for_day(self, user: User, target_date: date, state: UserBudgetState) -> list[dict[str, object]]:
44
+ """Create persona-specific irregular inflows."""
45
+ if user.persona.value != "student":
46
+ return []
47
+ inflows: list[dict[str, object]] = []
48
+ chance = 0.05
49
+ if state.balance < 120:
50
+ chance = 0.12
51
+ if target_date.day in {17, 24}:
52
+ chance += 0.03
53
+ if self.random_context.rng.random() < chance:
54
+ merchant = "Family Transfer" if state.balance < 90 else "Freelance Client"
55
+ amount = self.random_context.rng.uniform(35.0, 140.0) if merchant == "Freelance Client" else self.random_context.rng.uniform(60.0, 180.0)
56
+ inflows.append(
57
+ {
58
+ "merchant": merchant,
59
+ "category": "income",
60
+ "amount": round(amount, 2),
61
+ "time_bucket": "afternoon",
62
+ }
63
+ )
64
+ return inflows