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.
- impact_ledger-0.1.0.dist-info/METADATA +138 -0
- impact_ledger-0.1.0.dist-info/RECORD +13 -0
- impact_ledger-0.1.0.dist-info/WHEEL +5 -0
- impact_ledger-0.1.0.dist-info/top_level.txt +1 -0
- impactledger/__init__.py +17 -0
- impactledger/analysis/__init__.py +0 -0
- impactledger/data/__init__.py +0 -0
- impactledger/data/schema.py +156 -0
- impactledger/models/__init__.py +0 -0
- impactledger/models/investment.py +118 -0
- impactledger/models/portfolio.py +244 -0
- impactledger/models/report.py +93 -0
- impactledger/utils/__init__.py +0 -0
|
@@ -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 @@
|
|
|
1
|
+
impactledger
|
impactledger/__init__.py
ADDED
|
@@ -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
|