impact-ledger 0.1.0__tar.gz
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/PKG-INFO +138 -0
- impact_ledger-0.1.0/README.md +127 -0
- impact_ledger-0.1.0/impact_ledger.egg-info/PKG-INFO +138 -0
- impact_ledger-0.1.0/impact_ledger.egg-info/SOURCES.txt +20 -0
- impact_ledger-0.1.0/impact_ledger.egg-info/dependency_links.txt +1 -0
- impact_ledger-0.1.0/impact_ledger.egg-info/requires.txt +2 -0
- impact_ledger-0.1.0/impact_ledger.egg-info/top_level.txt +1 -0
- impact_ledger-0.1.0/impactledger/__init__.py +17 -0
- impact_ledger-0.1.0/impactledger/analysis/__init__.py +0 -0
- impact_ledger-0.1.0/impactledger/data/__init__.py +0 -0
- impact_ledger-0.1.0/impactledger/data/schema.py +156 -0
- impact_ledger-0.1.0/impactledger/models/__init__.py +0 -0
- impact_ledger-0.1.0/impactledger/models/investment.py +118 -0
- impact_ledger-0.1.0/impactledger/models/portfolio.py +244 -0
- impact_ledger-0.1.0/impactledger/models/report.py +93 -0
- impact_ledger-0.1.0/impactledger/utils/__init__.py +0 -0
- impact_ledger-0.1.0/pyproject.toml +18 -0
- impact_ledger-0.1.0/setup.cfg +4 -0
- impact_ledger-0.1.0/setup.py +11 -0
- impact_ledger-0.1.0/tests/test_portfolio.py +80 -0
- impact_ledger-0.1.0/tests/test_report.py +28 -0
- impact_ledger-0.1.0/tests/test_schema.py +74 -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,127 @@
|
|
|
1
|
+
# impact-ledger 📊
|
|
2
|
+
|
|
3
|
+
**Open source impact investment portfolio tracker for CDFIs, private debt, and community development finance.**
|
|
4
|
+
|
|
5
|
+
Track loans, NMTC deals, grants, and equity investments alongside social impact metrics —
|
|
6
|
+
jobs created, affordable units financed, borrower demographics, and geographic reach.
|
|
7
|
+
Generate standardized CDFI Fund-style reports in Python.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why impact-ledger?
|
|
12
|
+
|
|
13
|
+
Impact funds tracking private debt, community facilities, and social impact metrics
|
|
14
|
+
currently have two options: custom Excel spreadsheets or $50k+/year proprietary CRM software.
|
|
15
|
+
impact-ledger is the open source alternative — standardized, auditable, and free.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
pip install impact-ledger
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
from impactledger import loan, nmtc, grant, add_impact, Portfolio
|
|
28
|
+
from impactledger import cdfi_fund_report, sector_impact_report
|
|
29
|
+
|
|
30
|
+
# Create a portfolio
|
|
31
|
+
p = Portfolio(name="CDFI Impact Fund I", vintage_year=2022)
|
|
32
|
+
|
|
33
|
+
# Add a loan with impact metrics
|
|
34
|
+
inv = loan(
|
|
35
|
+
name="Southside Health Center",
|
|
36
|
+
amount=2_000_000,
|
|
37
|
+
interest_rate=0.045,
|
|
38
|
+
date_closed="2023-01-15",
|
|
39
|
+
maturity_date="2033-01-15",
|
|
40
|
+
state="IL",
|
|
41
|
+
borrower_name="Southside Health Inc",
|
|
42
|
+
borrower_type="nonprofit",
|
|
43
|
+
sector="healthcare",
|
|
44
|
+
is_low_income_area=True,
|
|
45
|
+
is_minority_borrower=True,
|
|
46
|
+
)
|
|
47
|
+
add_impact(inv, jobs_created=25, jobs_retained=40, patients_served=5000)
|
|
48
|
+
p.add(inv)
|
|
49
|
+
|
|
50
|
+
# Add an NMTC deal
|
|
51
|
+
deal = nmtc(
|
|
52
|
+
name="Detroit Manufacturing NMTC",
|
|
53
|
+
amount=8_000_000,
|
|
54
|
+
date_closed="2022-06-01",
|
|
55
|
+
maturity_date="2029-06-01",
|
|
56
|
+
state="MI",
|
|
57
|
+
borrower_name="Detroit Advanced Mfg Co",
|
|
58
|
+
sector="small_business",
|
|
59
|
+
is_low_income_area=True,
|
|
60
|
+
)
|
|
61
|
+
add_impact(deal, jobs_created=45, jobs_retained=60)
|
|
62
|
+
p.add(deal)
|
|
63
|
+
|
|
64
|
+
# Portfolio summary
|
|
65
|
+
p.summary()
|
|
66
|
+
|
|
67
|
+
# Generate reports
|
|
68
|
+
cdfi_fund_report(p)
|
|
69
|
+
sector_impact_report(p)
|
|
70
|
+
|
|
71
|
+
# Export to DataFrame
|
|
72
|
+
df = p.to_dataframe()
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Investment Types Supported
|
|
77
|
+
|
|
78
|
+
- loan - Private debt / direct lending
|
|
79
|
+
- equity - Equity investment
|
|
80
|
+
- grant - Grant or forgivable loan
|
|
81
|
+
- nmtc - New Markets Tax Credit investment
|
|
82
|
+
- guarantee - Loan guarantee
|
|
83
|
+
- bond - Bond or debenture
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Impact Metrics Tracked
|
|
88
|
+
|
|
89
|
+
- Jobs created and retained
|
|
90
|
+
- Affordable housing units financed
|
|
91
|
+
- Community square footage
|
|
92
|
+
- Businesses supported
|
|
93
|
+
- Patients served
|
|
94
|
+
- Students served
|
|
95
|
+
- Borrower demographics (minority, women, low-income)
|
|
96
|
+
- Geographic reach (state, census tract, rural/urban)
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Reports
|
|
101
|
+
|
|
102
|
+
- cdfi_fund_report() - CDFI Fund Annual Certification Report format
|
|
103
|
+
- sector_impact_report() - Impact breakdown by sector
|
|
104
|
+
- watchlist_report() - Impaired and watch-list investments
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Running Tests
|
|
109
|
+
|
|
110
|
+
PYTHONPATH=. pytest tests/ -v
|
|
111
|
+
|
|
112
|
+
27 tests across all modules.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Who This Is For
|
|
117
|
+
|
|
118
|
+
- CDFI loan officers tracking portfolio performance and impact
|
|
119
|
+
- Impact fund managers reporting to LPs and board
|
|
120
|
+
- Researchers studying community development finance outcomes
|
|
121
|
+
- Anyone replacing a bespoke Excel impact tracker with Python
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT 2026 Jaypatel1511
|
|
@@ -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,20 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
impact_ledger.egg-info/PKG-INFO
|
|
5
|
+
impact_ledger.egg-info/SOURCES.txt
|
|
6
|
+
impact_ledger.egg-info/dependency_links.txt
|
|
7
|
+
impact_ledger.egg-info/requires.txt
|
|
8
|
+
impact_ledger.egg-info/top_level.txt
|
|
9
|
+
impactledger/__init__.py
|
|
10
|
+
impactledger/analysis/__init__.py
|
|
11
|
+
impactledger/data/__init__.py
|
|
12
|
+
impactledger/data/schema.py
|
|
13
|
+
impactledger/models/__init__.py
|
|
14
|
+
impactledger/models/investment.py
|
|
15
|
+
impactledger/models/portfolio.py
|
|
16
|
+
impactledger/models/report.py
|
|
17
|
+
impactledger/utils/__init__.py
|
|
18
|
+
tests/test_portfolio.py
|
|
19
|
+
tests/test_report.py
|
|
20
|
+
tests/test_schema.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "impact-ledger"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Open source impact investment portfolio tracker for CDFIs, private debt, and community development finance"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pandas>=1.4.0",
|
|
14
|
+
"numpy>=1.21.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/Jaypatel1511/impact-ledger"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from impactledger import Portfolio
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_portfolio_count(sample_portfolio):
|
|
7
|
+
assert sample_portfolio.count() == 3
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_total_committed(sample_portfolio):
|
|
11
|
+
assert sample_portfolio.total_committed == pytest.approx(10_500_000)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_add_duplicate_raises(sample_portfolio, sample_loan):
|
|
15
|
+
with pytest.raises(ValueError, match="already exists"):
|
|
16
|
+
sample_portfolio.add(sample_loan)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_filter_state(sample_portfolio):
|
|
20
|
+
il = sample_portfolio.filter_state("IL")
|
|
21
|
+
assert len(il) == 1
|
|
22
|
+
assert il[0].state == "IL"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_filter_type(sample_portfolio):
|
|
26
|
+
loans = sample_portfolio.filter_type("loan")
|
|
27
|
+
assert len(loans) == 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_filter_sector(sample_portfolio):
|
|
31
|
+
health = sample_portfolio.filter_sector("healthcare")
|
|
32
|
+
assert len(health) == 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_weighted_avg_rate(sample_portfolio):
|
|
36
|
+
assert 0 < sample_portfolio.weighted_avg_rate < 0.10
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_total_jobs(sample_portfolio):
|
|
40
|
+
assert sample_portfolio.total_jobs_created == 70
|
|
41
|
+
assert sample_portfolio.total_jobs_retained == 100
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_affordable_units(sample_portfolio):
|
|
45
|
+
assert sample_portfolio.total_affordable_units == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_pct_low_income_area(sample_portfolio):
|
|
49
|
+
assert sample_portfolio.pct_low_income_area == pytest.approx(1.0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_pct_minority_borrower(sample_portfolio):
|
|
53
|
+
assert sample_portfolio.pct_minority_borrower == pytest.approx(2/3, rel=1e-3)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_to_dataframe(sample_portfolio):
|
|
57
|
+
df = sample_portfolio.to_dataframe()
|
|
58
|
+
assert isinstance(df, pd.DataFrame)
|
|
59
|
+
assert len(df) == 3
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_sector_breakdown(sample_portfolio):
|
|
63
|
+
df = sample_portfolio.sector_breakdown()
|
|
64
|
+
assert isinstance(df, pd.DataFrame)
|
|
65
|
+
assert len(df) == 3
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_state_breakdown(sample_portfolio):
|
|
69
|
+
df = sample_portfolio.state_breakdown()
|
|
70
|
+
assert isinstance(df, pd.DataFrame)
|
|
71
|
+
assert len(df) == 3
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_summary_runs(sample_portfolio):
|
|
75
|
+
sample_portfolio.summary()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_remove(sample_portfolio, sample_loan):
|
|
79
|
+
sample_portfolio.remove(sample_loan.id)
|
|
80
|
+
assert sample_portfolio.count() == 2
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from impactledger.models.report import (
|
|
4
|
+
cdfi_fund_report, sector_impact_report, watchlist_report
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_cdfi_fund_report(sample_portfolio):
|
|
9
|
+
df = cdfi_fund_report(sample_portfolio)
|
|
10
|
+
assert isinstance(df, pd.DataFrame)
|
|
11
|
+
assert "Metric" in df.columns
|
|
12
|
+
assert "Value" in df.columns
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_sector_impact_report(sample_portfolio):
|
|
16
|
+
df = sector_impact_report(sample_portfolio)
|
|
17
|
+
assert isinstance(df, pd.DataFrame)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_watchlist_empty(sample_portfolio):
|
|
21
|
+
df = watchlist_report(sample_portfolio)
|
|
22
|
+
assert isinstance(df, pd.DataFrame)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_watchlist_with_impaired(sample_portfolio, sample_loan):
|
|
26
|
+
sample_loan.status = "watch"
|
|
27
|
+
df = watchlist_report(sample_portfolio)
|
|
28
|
+
assert len(df) >= 1
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from impactledger.data.schema import Investment, ImpactMetrics
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_investment_created(sample_loan):
|
|
6
|
+
assert sample_loan.name == "Southside Health Center"
|
|
7
|
+
assert sample_loan.amount == 2_000_000
|
|
8
|
+
assert sample_loan.investment_type == "loan"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_invalid_type_raises():
|
|
12
|
+
with pytest.raises(ValueError, match="investment_type"):
|
|
13
|
+
Investment(
|
|
14
|
+
id="TEST01",
|
|
15
|
+
name="Bad",
|
|
16
|
+
investment_type="invalid",
|
|
17
|
+
sector="healthcare",
|
|
18
|
+
amount=1_000_000,
|
|
19
|
+
date_closed="2023-01-01",
|
|
20
|
+
maturity_date="2033-01-01",
|
|
21
|
+
interest_rate=0.05,
|
|
22
|
+
state="IL",
|
|
23
|
+
borrower_name="Test",
|
|
24
|
+
borrower_type="nonprofit",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_invalid_sector_raises():
|
|
29
|
+
with pytest.raises(ValueError, match="sector"):
|
|
30
|
+
Investment(
|
|
31
|
+
id="TEST01",
|
|
32
|
+
name="Bad",
|
|
33
|
+
investment_type="loan",
|
|
34
|
+
sector="invalid_sector",
|
|
35
|
+
amount=1_000_000,
|
|
36
|
+
date_closed="2023-01-01",
|
|
37
|
+
maturity_date="2033-01-01",
|
|
38
|
+
interest_rate=0.05,
|
|
39
|
+
state="IL",
|
|
40
|
+
borrower_name="Test",
|
|
41
|
+
borrower_type="nonprofit",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_negative_amount_raises():
|
|
46
|
+
with pytest.raises(ValueError, match="amount must be positive"):
|
|
47
|
+
Investment(
|
|
48
|
+
id="TEST01",
|
|
49
|
+
name="Bad",
|
|
50
|
+
investment_type="loan",
|
|
51
|
+
sector="healthcare",
|
|
52
|
+
amount=-1_000_000,
|
|
53
|
+
date_closed="2023-01-01",
|
|
54
|
+
maturity_date="2033-01-01",
|
|
55
|
+
interest_rate=0.05,
|
|
56
|
+
state="IL",
|
|
57
|
+
borrower_name="Test",
|
|
58
|
+
borrower_type="nonprofit",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_impact_metrics(sample_loan):
|
|
63
|
+
assert sample_loan.impact.jobs_created == 25
|
|
64
|
+
assert sample_loan.impact.jobs_retained == 40
|
|
65
|
+
assert sample_loan.impact.total_jobs == 65
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_annual_income(sample_loan):
|
|
69
|
+
expected = 2_000_000 * 0.045
|
|
70
|
+
assert sample_loan.annual_income == pytest.approx(expected)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_amount_mm(sample_loan):
|
|
74
|
+
assert sample_loan.amount_mm == pytest.approx(2.0)
|