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.
- finforge/__init__.py +5 -0
- finforge/behavior/__init__.py +1 -0
- finforge/behavior/adaptive_spending.py +129 -0
- finforge/behavior/balance_awareness.py +94 -0
- finforge/behavior/budgeting.py +78 -0
- finforge/behavior/clustering.py +102 -0
- finforge/behavior/identity.py +168 -0
- finforge/behavior/lifecycle.py +64 -0
- finforge/behavior/merchant_affinity.py +126 -0
- finforge/behavior/overdraft.py +49 -0
- finforge/behavior/sessions.py +230 -0
- finforge/behavior/spending_patterns.py +152 -0
- finforge/behavior/subscriptions.py +70 -0
- finforge/core/__init__.py +1 -0
- finforge/core/config.py +31 -0
- finforge/core/constants.py +13 -0
- finforge/core/enums.py +17 -0
- finforge/core/models.py +68 -0
- finforge/dataset.py +142 -0
- finforge/exporters/__init__.py +1 -0
- finforge/exporters/csv_exporter.py +18 -0
- finforge/generators/__init__.py +1 -0
- finforge/generators/scheduler.py +34 -0
- finforge/generators/transaction_generator.py +417 -0
- finforge/generators/user_generator.py +76 -0
- finforge/llm/__init__.py +5 -0
- finforge/llm/interfaces.py +12 -0
- finforge/merchants/__init__.py +1 -0
- finforge/merchants/catalog.py +120 -0
- finforge/personas/__init__.py +1 -0
- finforge/personas/base.py +61 -0
- finforge/personas/salaried.py +103 -0
- finforge/personas/student.py +86 -0
- finforge/utils/__init__.py +1 -0
- finforge/utils/balances.py +10 -0
- finforge/utils/dates.py +30 -0
- finforge/utils/randomness.py +24 -0
- finforge-1.0.0.dist-info/METADATA +282 -0
- finforge-1.0.0.dist-info/RECORD +42 -0
- finforge-1.0.0.dist-info/WHEEL +5 -0
- finforge-1.0.0.dist-info/licenses/LICENSE +21 -0
- finforge-1.0.0.dist-info/top_level.txt +1 -0
finforge/__init__.py
ADDED
|
@@ -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
|