impact-ledger 0.1.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.
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: impact-ledger
3
+ Version: 0.1.0
4
+ Summary: Open source impact investment portfolio tracker for CDFIs, private debt, and community development finance
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/Jaypatel1511/impact-ledger
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: pandas>=1.4.0
10
+ Requires-Dist: numpy>=1.21.0
11
+
12
+ # impact-ledger 📊
13
+
14
+ **Open source impact investment portfolio tracker for CDFIs, private debt, and community development finance.**
15
+
16
+ Track loans, NMTC deals, grants, and equity investments alongside social impact metrics —
17
+ jobs created, affordable units financed, borrower demographics, and geographic reach.
18
+ Generate standardized CDFI Fund-style reports in Python.
19
+
20
+ ---
21
+
22
+ ## Why impact-ledger?
23
+
24
+ Impact funds tracking private debt, community facilities, and social impact metrics
25
+ currently have two options: custom Excel spreadsheets or $50k+/year proprietary CRM software.
26
+ impact-ledger is the open source alternative — standardized, auditable, and free.
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ pip install impact-ledger
33
+
34
+ ---
35
+
36
+ ## Quickstart
37
+
38
+ from impactledger import loan, nmtc, grant, add_impact, Portfolio
39
+ from impactledger import cdfi_fund_report, sector_impact_report
40
+
41
+ # Create a portfolio
42
+ p = Portfolio(name="CDFI Impact Fund I", vintage_year=2022)
43
+
44
+ # Add a loan with impact metrics
45
+ inv = loan(
46
+ name="Southside Health Center",
47
+ amount=2_000_000,
48
+ interest_rate=0.045,
49
+ date_closed="2023-01-15",
50
+ maturity_date="2033-01-15",
51
+ state="IL",
52
+ borrower_name="Southside Health Inc",
53
+ borrower_type="nonprofit",
54
+ sector="healthcare",
55
+ is_low_income_area=True,
56
+ is_minority_borrower=True,
57
+ )
58
+ add_impact(inv, jobs_created=25, jobs_retained=40, patients_served=5000)
59
+ p.add(inv)
60
+
61
+ # Add an NMTC deal
62
+ deal = nmtc(
63
+ name="Detroit Manufacturing NMTC",
64
+ amount=8_000_000,
65
+ date_closed="2022-06-01",
66
+ maturity_date="2029-06-01",
67
+ state="MI",
68
+ borrower_name="Detroit Advanced Mfg Co",
69
+ sector="small_business",
70
+ is_low_income_area=True,
71
+ )
72
+ add_impact(deal, jobs_created=45, jobs_retained=60)
73
+ p.add(deal)
74
+
75
+ # Portfolio summary
76
+ p.summary()
77
+
78
+ # Generate reports
79
+ cdfi_fund_report(p)
80
+ sector_impact_report(p)
81
+
82
+ # Export to DataFrame
83
+ df = p.to_dataframe()
84
+
85
+ ---
86
+
87
+ ## Investment Types Supported
88
+
89
+ - loan - Private debt / direct lending
90
+ - equity - Equity investment
91
+ - grant - Grant or forgivable loan
92
+ - nmtc - New Markets Tax Credit investment
93
+ - guarantee - Loan guarantee
94
+ - bond - Bond or debenture
95
+
96
+ ---
97
+
98
+ ## Impact Metrics Tracked
99
+
100
+ - Jobs created and retained
101
+ - Affordable housing units financed
102
+ - Community square footage
103
+ - Businesses supported
104
+ - Patients served
105
+ - Students served
106
+ - Borrower demographics (minority, women, low-income)
107
+ - Geographic reach (state, census tract, rural/urban)
108
+
109
+ ---
110
+
111
+ ## Reports
112
+
113
+ - cdfi_fund_report() - CDFI Fund Annual Certification Report format
114
+ - sector_impact_report() - Impact breakdown by sector
115
+ - watchlist_report() - Impaired and watch-list investments
116
+
117
+ ---
118
+
119
+ ## Running Tests
120
+
121
+ PYTHONPATH=. pytest tests/ -v
122
+
123
+ 27 tests across all modules.
124
+
125
+ ---
126
+
127
+ ## Who This Is For
128
+
129
+ - CDFI loan officers tracking portfolio performance and impact
130
+ - Impact fund managers reporting to LPs and board
131
+ - Researchers studying community development finance outcomes
132
+ - Anyone replacing a bespoke Excel impact tracker with Python
133
+
134
+ ---
135
+
136
+ ## License
137
+
138
+ MIT 2026 Jaypatel1511
@@ -0,0 +1,13 @@
1
+ impactledger/__init__.py,sha256=jOgPholOg0ED9jviq-On4YW7kDJR65h3I6KsTJeCE9M,619
2
+ impactledger/analysis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ impactledger/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ impactledger/data/schema.py,sha256=Q6fRm8HiAND0EiWh-KkhhE-ggtLWs5JgYsozU6pjKdA,4992
5
+ impactledger/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ impactledger/models/investment.py,sha256=xX__EPpqLjjC554E7wN9SakR2HRYgjEbtqL43qk0_kY,3169
7
+ impactledger/models/portfolio.py,sha256=zH0JsI6xBA0bphF_zsD_XWzsR9pXdWljR89yyAuD6fg,8892
8
+ impactledger/models/report.py,sha256=8zmp09x-SyHu30W8q7mTgZnDIn715Ih0EGIs0EcZzfY,3176
9
+ impactledger/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ impact_ledger-0.1.0.dist-info/METADATA,sha256=yAOn4y_tKB6gONbBQmd5h9U0doGVnh_C98uIIs9ryZE,3595
11
+ impact_ledger-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ impact_ledger-0.1.0.dist-info/top_level.txt,sha256=odTOoyFDU3onL3lRz11Gs5_wxql0MFJZ0uG7l7etYLk,13
13
+ impact_ledger-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ impactledger
@@ -0,0 +1,17 @@
1
+ from impactledger.data.schema import (
2
+ Investment, ImpactMetrics,
3
+ INVESTMENT_TYPES, SECTORS, BORROWER_TYPES, STATUSES,
4
+ )
5
+ from impactledger.models.investment import loan, nmtc, grant, add_impact
6
+ from impactledger.models.portfolio import Portfolio
7
+ from impactledger.models.report import (
8
+ cdfi_fund_report, sector_impact_report, watchlist_report
9
+ )
10
+
11
+ __version__ = "0.1.0"
12
+ __all__ = [
13
+ "Investment", "ImpactMetrics", "Portfolio",
14
+ "loan", "nmtc", "grant", "add_impact",
15
+ "cdfi_fund_report", "sector_impact_report", "watchlist_report",
16
+ "INVESTMENT_TYPES", "SECTORS", "BORROWER_TYPES", "STATUSES",
17
+ ]
File without changes
File without changes
@@ -0,0 +1,156 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+ from datetime import date
4
+
5
+
6
+ # Supported investment types
7
+ INVESTMENT_TYPES = {
8
+ "loan": "Private debt / direct lending",
9
+ "equity": "Equity investment",
10
+ "grant": "Grant or forgivable loan",
11
+ "nmtc": "New Markets Tax Credit investment",
12
+ "guarantee": "Loan guarantee",
13
+ "bond": "Bond or debenture",
14
+ }
15
+
16
+ # Supported sectors
17
+ SECTORS = {
18
+ "affordable_housing": "Affordable housing development",
19
+ "small_business": "Small business lending",
20
+ "community_facility": "Community facility (health, education, etc.)",
21
+ "healthcare": "Healthcare facility",
22
+ "education": "Educational facility",
23
+ "food_access": "Food access / grocery",
24
+ "childcare": "Childcare facility",
25
+ "mixed_use": "Mixed-use development",
26
+ "commercial_re": "Commercial real estate",
27
+ "microenterprise": "Microenterprise lending",
28
+ "other": "Other",
29
+ }
30
+
31
+ # Borrower types
32
+ BORROWER_TYPES = {
33
+ "nonprofit": "Nonprofit organization",
34
+ "for_profit": "For-profit business",
35
+ "cdfi": "Community Development Financial Institution",
36
+ "government": "Government entity",
37
+ "individual": "Individual borrower",
38
+ "cooperative": "Cooperative",
39
+ }
40
+
41
+ # Investment status
42
+ STATUSES = {
43
+ "active": "Active / performing",
44
+ "watch": "Watch list",
45
+ "default": "In default",
46
+ "repaid": "Fully repaid",
47
+ "written_off": "Written off",
48
+ }
49
+
50
+
51
+ @dataclass
52
+ class ImpactMetrics:
53
+ """
54
+ Social impact metrics associated with a single investment.
55
+ All metrics are optional — track what is relevant to your portfolio.
56
+ """
57
+ investment_id: str
58
+ jobs_created: int = 0
59
+ jobs_retained: int = 0
60
+ affordable_units: int = 0
61
+ sq_ft_community_space: float = 0.0
62
+ businesses_supported: int = 0
63
+ patients_served: int = 0
64
+ students_served: int = 0
65
+ meals_served: int = 0
66
+ childcare_slots: int = 0
67
+ notes: Optional[str] = None
68
+
69
+ @property
70
+ def total_jobs(self) -> int:
71
+ return self.jobs_created + self.jobs_retained
72
+
73
+
74
+ @dataclass
75
+ class Investment:
76
+ """
77
+ Core investment record for an impact portfolio.
78
+ Covers loans, equity, grants, NMTC deals, and guarantees.
79
+ """
80
+ id: str
81
+ name: str
82
+ investment_type: str
83
+ sector: str
84
+ amount: float
85
+ date_closed: str
86
+ maturity_date: str
87
+ interest_rate: float
88
+ state: str
89
+ borrower_name: str
90
+ borrower_type: str
91
+ status: str = "active"
92
+ census_tract: Optional[str] = None
93
+ county: Optional[str] = None
94
+ is_low_income_area: bool = False
95
+ is_distressed_area: bool = False
96
+ is_minority_borrower: bool = False
97
+ is_women_borrower: bool = False
98
+ is_rural: bool = False
99
+ outstanding_balance: Optional[float] = None
100
+ interest_received: float = 0.0
101
+ principal_received: float = 0.0
102
+ impact: Optional[ImpactMetrics] = None
103
+ notes: Optional[str] = None
104
+
105
+ def __post_init__(self):
106
+ if self.amount <= 0:
107
+ raise ValueError(f"Investment '{self.name}': amount must be positive")
108
+ if self.investment_type not in INVESTMENT_TYPES:
109
+ raise ValueError(
110
+ f"Investment '{self.name}': investment_type must be one of "
111
+ f"{list(INVESTMENT_TYPES.keys())}"
112
+ )
113
+ if self.sector not in SECTORS:
114
+ raise ValueError(
115
+ f"Investment '{self.name}': sector must be one of "
116
+ f"{list(SECTORS.keys())}"
117
+ )
118
+ if self.borrower_type not in BORROWER_TYPES:
119
+ raise ValueError(
120
+ f"Investment '{self.name}': borrower_type must be one of "
121
+ f"{list(BORROWER_TYPES.keys())}"
122
+ )
123
+ if self.status not in STATUSES:
124
+ raise ValueError(
125
+ f"Investment '{self.name}': status must be one of "
126
+ f"{list(STATUSES.keys())}"
127
+ )
128
+ if not (0 <= self.interest_rate < 1):
129
+ raise ValueError(
130
+ f"Investment '{self.name}': interest_rate must be between 0 and 1"
131
+ )
132
+ if self.outstanding_balance is None:
133
+ self.outstanding_balance = self.amount
134
+
135
+ @property
136
+ def amount_mm(self) -> float:
137
+ return self.amount / 1_000_000
138
+
139
+ @property
140
+ def annual_income(self) -> float:
141
+ return self.outstanding_balance * self.interest_rate
142
+
143
+ @property
144
+ def is_active(self) -> bool:
145
+ return self.status == "active"
146
+
147
+ @property
148
+ def is_impaired(self) -> bool:
149
+ return self.status in ("watch", "default")
150
+
151
+ def __repr__(self):
152
+ return (
153
+ f"Investment(id='{self.id}', name='{self.name}', "
154
+ f"type='{self.investment_type}', amount=${self.amount_mm:.2f}MM, "
155
+ f"state='{self.state}', status='{self.status}')"
156
+ )
File without changes
@@ -0,0 +1,118 @@
1
+ """
2
+ Investment model — factory functions and helpers for creating investments.
3
+ """
4
+ from impactledger.data.schema import Investment, ImpactMetrics
5
+ import uuid
6
+
7
+
8
+ def make_id() -> str:
9
+ """Generate a short unique investment ID."""
10
+ return str(uuid.uuid4())[:8].upper()
11
+
12
+
13
+ def loan(
14
+ name: str,
15
+ amount: float,
16
+ interest_rate: float,
17
+ date_closed: str,
18
+ maturity_date: str,
19
+ state: str,
20
+ borrower_name: str,
21
+ borrower_type: str = "nonprofit",
22
+ sector: str = "community_facility",
23
+ **kwargs,
24
+ ) -> Investment:
25
+ """Convenience factory for creating a loan investment."""
26
+ return Investment(
27
+ id=make_id(),
28
+ name=name,
29
+ investment_type="loan",
30
+ sector=sector,
31
+ amount=amount,
32
+ date_closed=date_closed,
33
+ maturity_date=maturity_date,
34
+ interest_rate=interest_rate,
35
+ state=state,
36
+ borrower_name=borrower_name,
37
+ borrower_type=borrower_type,
38
+ **kwargs,
39
+ )
40
+
41
+
42
+ def nmtc(
43
+ name: str,
44
+ amount: float,
45
+ date_closed: str,
46
+ maturity_date: str,
47
+ state: str,
48
+ borrower_name: str,
49
+ **kwargs,
50
+ ) -> Investment:
51
+ """Convenience factory for creating an NMTC investment."""
52
+ return Investment(
53
+ id=make_id(),
54
+ name=name,
55
+ investment_type="nmtc",
56
+ sector=kwargs.pop("sector", "community_facility"),
57
+ amount=amount,
58
+ date_closed=date_closed,
59
+ maturity_date=maturity_date,
60
+ interest_rate=kwargs.pop("interest_rate", 0.01),
61
+ state=state,
62
+ borrower_name=borrower_name,
63
+ borrower_type=kwargs.pop("borrower_type", "nonprofit"),
64
+ is_low_income_area=kwargs.pop("is_low_income_area", True),
65
+ **kwargs,
66
+ )
67
+
68
+
69
+ def grant(
70
+ name: str,
71
+ amount: float,
72
+ date_closed: str,
73
+ state: str,
74
+ borrower_name: str,
75
+ **kwargs,
76
+ ) -> Investment:
77
+ """Convenience factory for creating a grant."""
78
+ return Investment(
79
+ id=make_id(),
80
+ name=name,
81
+ investment_type="grant",
82
+ sector=kwargs.pop("sector", "community_facility"),
83
+ amount=amount,
84
+ date_closed=date_closed,
85
+ maturity_date=kwargs.pop("maturity_date", date_closed),
86
+ interest_rate=0.0,
87
+ state=state,
88
+ borrower_name=borrower_name,
89
+ borrower_type=kwargs.pop("borrower_type", "nonprofit"),
90
+ outstanding_balance=0.0,
91
+ **kwargs,
92
+ )
93
+
94
+
95
+ def add_impact(
96
+ investment: Investment,
97
+ jobs_created: int = 0,
98
+ jobs_retained: int = 0,
99
+ affordable_units: int = 0,
100
+ sq_ft_community_space: float = 0.0,
101
+ businesses_supported: int = 0,
102
+ patients_served: int = 0,
103
+ students_served: int = 0,
104
+ **kwargs,
105
+ ) -> Investment:
106
+ """Attach impact metrics to an existing investment."""
107
+ investment.impact = ImpactMetrics(
108
+ investment_id=investment.id,
109
+ jobs_created=jobs_created,
110
+ jobs_retained=jobs_retained,
111
+ affordable_units=affordable_units,
112
+ sq_ft_community_space=sq_ft_community_space,
113
+ businesses_supported=businesses_supported,
114
+ patients_served=patients_served,
115
+ students_served=students_served,
116
+ **kwargs,
117
+ )
118
+ return investment
@@ -0,0 +1,244 @@
1
+ """
2
+ Portfolio aggregator — tracks a collection of impact investments.
3
+ """
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+ import pandas as pd
7
+
8
+ from impactledger.data.schema import Investment, INVESTMENT_TYPES, SECTORS
9
+
10
+
11
+ class Portfolio:
12
+ """
13
+ Tracks a collection of impact investments with financial
14
+ and social impact aggregation.
15
+
16
+ Usage:
17
+ p = Portfolio(name="CDFI Impact Fund I")
18
+ p.add(investment1)
19
+ p.add(investment2)
20
+ p.summary()
21
+ df = p.to_dataframe()
22
+ """
23
+
24
+ def __init__(self, name: str, vintage_year: Optional[int] = None):
25
+ self.name = name
26
+ self.vintage_year = vintage_year
27
+ self._investments: list[Investment] = []
28
+
29
+ def add(self, investment: Investment) -> None:
30
+ """Add an investment to the portfolio."""
31
+ if not isinstance(investment, Investment):
32
+ raise TypeError("Only Investment objects can be added.")
33
+ if any(i.id == investment.id for i in self._investments):
34
+ raise ValueError(f"Investment '{investment.id}' already exists.")
35
+ self._investments.append(investment)
36
+
37
+ def remove(self, investment_id: str) -> None:
38
+ """Remove an investment by ID."""
39
+ self._investments = [
40
+ i for i in self._investments if i.id != investment_id
41
+ ]
42
+
43
+ def count(self) -> int:
44
+ return len(self._investments)
45
+
46
+ def get(self, investment_id: str) -> Optional[Investment]:
47
+ """Get a single investment by ID."""
48
+ for inv in self._investments:
49
+ if inv.id == investment_id:
50
+ return inv
51
+ return None
52
+
53
+ def filter_state(self, state: str) -> list:
54
+ return [i for i in self._investments if i.state == state.upper()]
55
+
56
+ def filter_type(self, investment_type: str) -> list:
57
+ return [i for i in self._investments if i.investment_type == investment_type]
58
+
59
+ def filter_sector(self, sector: str) -> list:
60
+ return [i for i in self._investments if i.sector == sector]
61
+
62
+ def filter_status(self, status: str) -> list:
63
+ return [i for i in self._investments if i.status == status]
64
+
65
+ @property
66
+ def total_committed(self) -> float:
67
+ return sum(i.amount for i in self._investments)
68
+
69
+ @property
70
+ def total_outstanding(self) -> float:
71
+ return sum(
72
+ i.outstanding_balance for i in self._investments
73
+ if i.outstanding_balance is not None
74
+ )
75
+
76
+ @property
77
+ def active_investments(self) -> list:
78
+ return [i for i in self._investments if i.is_active]
79
+
80
+ @property
81
+ def impaired_investments(self) -> list:
82
+ return [i for i in self._investments if i.is_impaired]
83
+
84
+ @property
85
+ def weighted_avg_rate(self) -> float:
86
+ total = self.total_outstanding
87
+ if total == 0:
88
+ return 0.0
89
+ return sum(
90
+ i.outstanding_balance * i.interest_rate
91
+ for i in self._investments
92
+ if i.outstanding_balance
93
+ ) / total
94
+
95
+ @property
96
+ def annual_income(self) -> float:
97
+ return sum(i.annual_income for i in self._investments)
98
+
99
+ # ── Impact aggregations ───────────────────────────────────────────────────
100
+
101
+ @property
102
+ def total_jobs_created(self) -> int:
103
+ return sum(
104
+ i.impact.jobs_created for i in self._investments
105
+ if i.impact
106
+ )
107
+
108
+ @property
109
+ def total_jobs_retained(self) -> int:
110
+ return sum(
111
+ i.impact.jobs_retained for i in self._investments
112
+ if i.impact
113
+ )
114
+
115
+ @property
116
+ def total_jobs(self) -> int:
117
+ return self.total_jobs_created + self.total_jobs_retained
118
+
119
+ @property
120
+ def total_affordable_units(self) -> int:
121
+ return sum(
122
+ i.impact.affordable_units for i in self._investments
123
+ if i.impact
124
+ )
125
+
126
+ @property
127
+ def total_sq_ft(self) -> float:
128
+ return sum(
129
+ i.impact.sq_ft_community_space for i in self._investments
130
+ if i.impact
131
+ )
132
+
133
+ @property
134
+ def pct_low_income_area(self) -> float:
135
+ if not self._investments:
136
+ return 0.0
137
+ return sum(
138
+ 1 for i in self._investments if i.is_low_income_area
139
+ ) / len(self._investments)
140
+
141
+ @property
142
+ def pct_minority_borrower(self) -> float:
143
+ if not self._investments:
144
+ return 0.0
145
+ return sum(
146
+ 1 for i in self._investments if i.is_minority_borrower
147
+ ) / len(self._investments)
148
+
149
+ @property
150
+ def pct_women_borrower(self) -> float:
151
+ if not self._investments:
152
+ return 0.0
153
+ return sum(
154
+ 1 for i in self._investments if i.is_women_borrower
155
+ ) / len(self._investments)
156
+
157
+ @property
158
+ def cost_per_job(self) -> float:
159
+ if self.total_jobs == 0:
160
+ return 0.0
161
+ return self.total_committed / self.total_jobs
162
+
163
+ def summary(self) -> None:
164
+ """Print a full portfolio summary."""
165
+ print(f"\nImpact Portfolio Summary — {self.name}")
166
+ print("=" * 55)
167
+ print(f"\nFINANCIAL METRICS")
168
+ print(f" Total Committed: ${self.total_committed/1e6:.2f}MM")
169
+ print(f" Total Outstanding: ${self.total_outstanding/1e6:.2f}MM")
170
+ print(f" Annual Income: ${self.annual_income/1e6:.2f}MM")
171
+ print(f" Weighted Avg Yield: {self.weighted_avg_rate*100:.2f}%")
172
+ print(f" Total Investments: {self.count()}")
173
+ print(f" Active: {len(self.active_investments)}")
174
+ print(f" Impaired: {len(self.impaired_investments)}")
175
+ print(f"\nIMPACT METRICS")
176
+ print(f" Jobs Created: {self.total_jobs_created:,}")
177
+ print(f" Jobs Retained: {self.total_jobs_retained:,}")
178
+ print(f" Total Jobs: {self.total_jobs:,}")
179
+ print(f" Affordable Units: {self.total_affordable_units:,}")
180
+ print(f" Community Sq Ft: {self.total_sq_ft:,.0f}")
181
+ if self.cost_per_job > 0:
182
+ print(f" Cost per Job: ${self.cost_per_job:,.0f}")
183
+ print(f"\nDEMOGRAPHICS")
184
+ print(f" % Low-Income Area: {self.pct_low_income_area*100:.1f}%")
185
+ print(f" % Minority Borrower: {self.pct_minority_borrower*100:.1f}%")
186
+ print(f" % Women Borrower: {self.pct_women_borrower*100:.1f}%")
187
+ print(f"\nGEOGRAPHIC REACH")
188
+ states = set(i.state for i in self._investments)
189
+ print(f" States: {len(states)}")
190
+ print(f" States: {', '.join(sorted(states))}")
191
+ print()
192
+
193
+ def to_dataframe(self) -> pd.DataFrame:
194
+ """Return all investments as a pandas DataFrame."""
195
+ rows = []
196
+ for i in self._investments:
197
+ row = {
198
+ "id": i.id,
199
+ "name": i.name,
200
+ "type": i.investment_type,
201
+ "sector": i.sector,
202
+ "amount": i.amount,
203
+ "outstanding": i.outstanding_balance,
204
+ "rate": i.interest_rate,
205
+ "state": i.state,
206
+ "borrower": i.borrower_name,
207
+ "borrower_type": i.borrower_type,
208
+ "status": i.status,
209
+ "date_closed": i.date_closed,
210
+ "maturity_date": i.maturity_date,
211
+ "low_income_area": i.is_low_income_area,
212
+ "minority_borrower": i.is_minority_borrower,
213
+ "women_borrower": i.is_women_borrower,
214
+ "rural": i.is_rural,
215
+ }
216
+ if i.impact:
217
+ row.update({
218
+ "jobs_created": i.impact.jobs_created,
219
+ "jobs_retained": i.impact.jobs_retained,
220
+ "affordable_units": i.impact.affordable_units,
221
+ "sq_ft": i.impact.sq_ft_community_space,
222
+ "businesses": i.impact.businesses_supported,
223
+ "patients": i.impact.patients_served,
224
+ "students": i.impact.students_served,
225
+ })
226
+ rows.append(row)
227
+ return pd.DataFrame(rows)
228
+
229
+ def sector_breakdown(self) -> pd.DataFrame:
230
+ """Return a sector-level summary DataFrame."""
231
+ df = self.to_dataframe()
232
+ return df.groupby("sector").agg(
233
+ count=("amount", "count"),
234
+ total_amount=("amount", "sum"),
235
+ avg_amount=("amount", "mean"),
236
+ ).reset_index().sort_values("total_amount", ascending=False)
237
+
238
+ def state_breakdown(self) -> pd.DataFrame:
239
+ """Return a state-level summary DataFrame."""
240
+ df = self.to_dataframe()
241
+ return df.groupby("state").agg(
242
+ count=("amount", "count"),
243
+ total_amount=("amount", "sum"),
244
+ ).reset_index().sort_values("total_amount", ascending=False)
@@ -0,0 +1,93 @@
1
+ """
2
+ Standardized impact report generator.
3
+ Produces CDFI Fund, GIIRS, and custom report formats.
4
+ """
5
+ import pandas as pd
6
+ from impactledger.models.portfolio import Portfolio
7
+
8
+
9
+ def cdfi_fund_report(portfolio: Portfolio) -> pd.DataFrame:
10
+ """
11
+ Generate a CDFI Fund-style impact report.
12
+ Matches the format used in CDFI Fund Annual Certification Reports.
13
+ """
14
+ df = portfolio.to_dataframe()
15
+
16
+ report = {
17
+ "Total Financing ($MM)": round(portfolio.total_committed / 1e6, 2),
18
+ "Number of Transactions": portfolio.count(),
19
+ "Avg Transaction Size ($)": round(portfolio.total_committed / max(portfolio.count(), 1)),
20
+ "% in Low-Income Areas": f"{portfolio.pct_low_income_area*100:.1f}%",
21
+ "% Minority Borrowers": f"{portfolio.pct_minority_borrower*100:.1f}%",
22
+ "% Women Borrowers": f"{portfolio.pct_women_borrower*100:.1f}%",
23
+ "Jobs Created": portfolio.total_jobs_created,
24
+ "Jobs Retained": portfolio.total_jobs_retained,
25
+ "Affordable Units Financed": portfolio.total_affordable_units,
26
+ "Community Sq Ft": round(portfolio.total_sq_ft),
27
+ "States Served": len(set(i.state for i in portfolio._investments)),
28
+ "Weighted Avg Interest Rate": f"{portfolio.weighted_avg_rate*100:.2f}%",
29
+ }
30
+
31
+ result = pd.DataFrame(
32
+ list(report.items()), columns=["Metric", "Value"]
33
+ )
34
+
35
+ print(f"\nCDFI Fund Impact Report — {portfolio.name}")
36
+ print("=" * 50)
37
+ print(result.to_string(index=False))
38
+ print()
39
+
40
+ return result
41
+
42
+
43
+ def sector_impact_report(portfolio: Portfolio) -> pd.DataFrame:
44
+ """
45
+ Generate a sector-level impact breakdown.
46
+ """
47
+ df = portfolio.to_dataframe()
48
+
49
+ impact_cols = ["jobs_created", "jobs_retained", "affordable_units",
50
+ "sq_ft", "patients", "students", "businesses"]
51
+ available = [c for c in impact_cols if c in df.columns]
52
+
53
+ agg_dict = {"amount": ["count", "sum"]}
54
+ for col in available:
55
+ agg_dict[col] = "sum"
56
+
57
+ result = df.groupby("sector").agg(agg_dict).reset_index()
58
+ result.columns = ["_".join(c).strip("_") for c in result.columns]
59
+ result = result.sort_values("amount_sum", ascending=False)
60
+
61
+ print(f"\nSector Impact Report — {portfolio.name}")
62
+ print("=" * 60)
63
+ print(result.to_string(index=False))
64
+ print()
65
+
66
+ return result
67
+
68
+
69
+ def watchlist_report(portfolio: Portfolio) -> pd.DataFrame:
70
+ """
71
+ Generate a watchlist of impaired investments.
72
+ """
73
+ impaired = portfolio.impaired_investments
74
+ if not impaired:
75
+ print(f"\nNo impaired investments in {portfolio.name}")
76
+ return pd.DataFrame()
77
+
78
+ rows = [{
79
+ "ID": i.id,
80
+ "Name": i.name,
81
+ "Status": i.status,
82
+ "Amount ($MM)": round(i.amount / 1e6, 2),
83
+ "Outstanding ($MM)": round(i.outstanding_balance / 1e6, 2),
84
+ "State": i.state,
85
+ "Sector": i.sector,
86
+ } for i in impaired]
87
+
88
+ df = pd.DataFrame(rows)
89
+ print(f"\nWatchlist Report — {portfolio.name}")
90
+ print("=" * 60)
91
+ print(df.to_string(index=False))
92
+ print()
93
+ return df
File without changes