"""
Adapt[us] — Unified Climate Adaptation Investment Platform
========================================================
The complete, single-file Panel application for the Adapt[us] climate adaptation venture fund.
Designed for both LP fundraising demos and institutional due diligence analysis.
QUICK START:
pixi run dev # Launch full application
panel serve app.py --show # Direct Panel serve
FEATURES:
🌡️ Climate scenario modeling with real-time market uplift
📊 Complete fund performance analysis with European waterfall
💰 Personalized LP returns with S&P 500 benchmarking
🎯 Investment pipeline with 12 target companies
🌍 Impact analysis with dual financial + climate returns
🔗 URL permalinking for scenario sharing
🎨 Custom Adaptus Material UI theme with Space Mono typography
FILE STRUCTURE (for AI navigation):
LINES SECTION DESCRIPTION
------- ----------------------------- ----------------------------------
32-126 📐 CONFIGURATION & THEMING Panel setup, Adaptus colors, CSS
128-235 🎛️ GLOBAL STATE MANAGEMENT UnifiedState class, parameters
237-410 🧮 CALCULATION ENGINE Financial models, IRR, waterfall
412-577 📊 DESIGN SYSTEM & DATA Colors, sectors, target companies
579-1102 🖼️ UI COMPONENTS View functions, charts, tables
1104-1167 🔗 URL STATE MANAGEMENT Permalink sharing functionality
1169-1300 🏗️ APPLICATION ASSEMBLY Template creation, content binding
KEY CLASSES:
UnifiedState: Complete parameter management for interactive controls
KEY FUNCTIONS:
compute_fund_metrics(): Core financial calculation engine (cached)
create_*_view(): UI component builders for each tab
setup_url_persistence(): Scenario sharing via URL parameters
create_unified_app(): Main application assembly
DATA STRUCTURES:
ADAPTUS: Brand color palette and theme configuration
SECTORS: Climate adaptation market data (5 sectors)
TARGET_COMPANIES: Investment pipeline (12 companies)
FUND_COMPARISON: Competitive landscape analysis
USAGE FOR AI AGENTS:
- Search "def create_" for UI components
- Search "# ===" for major sections
- Search "param\\." for interactive parameters
- Search "ADAPTUS\\[" for theme colors
- Search "@pn.depends" for reactive functions
Author: Darren Clifford (dc@aucap.vc)
Fund: Adapt[us] Climate Adaptation Venture Fund ($30M, Pre-seed to Series A)
"""
from functools import lru_cache
from typing import Dict, List, Optional, Sequence, Tuple, TypedDict
import numpy as np
import orjson
import pandas as pd
import panel as pn
import panel_material_ui as pmui
import param
import plotly.graph_objects as go
import plotly.io as pio
from pydantic import BaseModel, Field, field_validator
# Type imports for proper type hints (used by pyrefly)
# Note: pmui components return their own types, not base Panel types
# Configure Panel settings
pn.config.sizing_mode = "stretch_width"
# Note: console_output is read-only in newer Panel versions
# pn.config.console_output = "disable"
# Adaptus brand palette
ADAPTUS = dict(
primary="#107580",
accent="#7B3E7A",
teal_light="#2DAA9F",
bg="#f6f2e7",
surface="#FFFFFF",
text="#222222",
muted="#6A6A6A",
warn="#E4A11B",
danger="#C23B3B",
success="#1B9B8A",
cycle=["#7B3E7A", "#107580", "#2DAA9F", "#222222", "#E4A11B", "#1B9B8A"],
)
# Use Adaptus brand palette for consistency (defined early for pyodide compatibility)
COLORS = {
"primary": ADAPTUS["primary"], # Adaptus teal
"secondary": ADAPTUS["accent"], # Adaptus purple
"accent": ADAPTUS["teal_light"], # Light teal
"info": ADAPTUS["text"], # Dark text for info
"warn": ADAPTUS["warn"], # Adaptus warning orange
"success": ADAPTUS["success"], # Adaptus success teal
"danger": ADAPTUS["danger"], # Adaptus danger red
"background": ADAPTUS["bg"], # Adaptus cream background
"surface": ADAPTUS["surface"], # Pure white
"text": ADAPTUS["text"], # Dark gray text
"muted": ADAPTUS["muted"], # Muted gray
}
# Comprehensive Material UI theme configuration with light/dark modes
THEME_CONFIG = {
"light": {
"palette": {
"primary": {
"main": ADAPTUS["primary"],
"light": "#2DAA9F",
"dark": "#0A5960",
"contrastText": "#FFFFFF",
},
"secondary": {
"main": ADAPTUS["accent"],
"light": "#9C4B99",
"dark": "#5C2E5B",
"contrastText": "#FFFFFF",
},
"success": {"main": ADAPTUS["success"]},
"warning": {"main": ADAPTUS["warn"]},
"error": {"main": ADAPTUS["danger"]},
"info": {"main": ADAPTUS["teal_light"]},
"background": {
"default": ADAPTUS["bg"],
"paper": ADAPTUS["surface"],
},
"text": {
"primary": ADAPTUS["text"],
"secondary": ADAPTUS["muted"],
},
},
"typography": {
"fontFamily": "'Space Mono', monospace",
"fontSize": 13,
"fontWeight": 400,
"button": {
"fontSize": "0.875rem",
"fontWeight": 600,
"textTransform": "none",
},
# Responsive typography with mobile-first breakpoints
"h1": {
"fontSize": "2rem",
"fontWeight": 700,
"@media (min-width:600px)": {"fontSize": "2.5rem"},
"@media (min-width:900px)": {"fontSize": "3rem"},
},
"h2": {
"fontSize": "1.5rem",
"fontWeight": 700,
"@media (min-width:600px)": {"fontSize": "2rem"},
"@media (min-width:900px)": {"fontSize": "2.5rem"},
},
"h3": {
"fontSize": "1.25rem",
"fontWeight": 700,
"@media (min-width:600px)": {"fontSize": "1.75rem"},
},
"h4": {
"fontSize": "1.125rem",
"fontWeight": 600,
"@media (min-width:600px)": {"fontSize": "1.5rem"},
},
"h5": {
"fontSize": "1rem",
"fontWeight": 600,
"@media (min-width:600px)": {"fontSize": "1.25rem"},
},
"h6": {
"fontSize": "0.875rem",
"fontWeight": 600,
"@media (min-width:600px)": {"fontSize": "1rem"},
},
"body1": {
"fontSize": "0.875rem",
"@media (min-width:600px)": {"fontSize": "1rem"},
},
"body2": {
"fontSize": "0.8125rem",
"@media (min-width:600px)": {"fontSize": "0.875rem"},
},
},
"shape": {"borderRadius": 8},
"components": {
"MuiButtonBase": {
"defaultProps": {"disableRipple": False},
},
"MuiButton": {
"styleOverrides": {
"root": {
"fontFamily": "'Space Mono', monospace",
}
}
},
"MuiCard": {
"styleOverrides": {
"root": {
"fontFamily": "'Space Mono', monospace",
}
}
},
},
},
"dark": {
"palette": {
"primary": {
"main": "#2DAA9F",
"light": "#5FC9BF",
"dark": "#1B7A72",
"contrastText": "#FFFFFF",
},
"secondary": {
"main": "#9C4B99",
"light": "#B76FB5",
"dark": "#7B3E7A",
"contrastText": "#FFFFFF",
},
"success": {"main": "#3FBB9E"},
"warning": {"main": "#F4B336"},
"error": {"main": "#E05656"},
"info": {"main": "#5FC9BF"},
"background": {
"default": "#1a1a1a",
"paper": "#2d2d2d",
},
"text": {
"primary": "#f0f0f0",
"secondary": "#999999",
},
},
"typography": {
"fontFamily": "'Space Mono', monospace",
"fontSize": 13,
},
"shape": {"borderRadius": 8},
},
}
# Font & Icon setup - load from CDN to avoid MIME type issues
pn.config.css_files = [
"https://fonts.bunny.net/css?family=space-mono:400,700", # Space Mono from Bunny Fonts
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css", # FontAwesome 6 icons
]
# Simplified CSS - MUI theme now handles most styling
pn.config.raw_css = [
"""
.fa,.fas,.far,.fal,.fab{
font-family:'Font Awesome 6 Free','Font Awesome 6 Pro','Font Awesome 6 Brands'!important;
font-style:normal!important;display:inline-block!important
}
.fas{font-weight:900!important}.far,.fab{font-weight:400!important}.fal{font-weight:300!important}
""",
f"""
/* Adaptus CSS variables for custom utility classes */
:root {{
--ad-primary: {ADAPTUS["primary"]};
--ad-accent: {ADAPTUS["accent"]};
--ad-teal: {ADAPTUS["teal_light"]};
}}
/* Custom icon styling */
.adaptus-icon {{
color: var(--mui-palette-primary-main);
margin-right: 8px;
vertical-align: middle;
}}
[data-theme="dark"] .adaptus-icon {{
color: #2DAA9F;
}}
""",
]
# Setup Plotly Adaptus theme
tpl = pio.templates["plotly_white"]
tpl.layout = tpl.layout.update(
font=dict(family="Space Mono, monospace", size=13, color=ADAPTUS["text"]),
title=dict(
font=dict(family="Space Mono, monospace", size=20, color=ADAPTUS["accent"])
),
colorway=ADAPTUS["cycle"],
paper_bgcolor=ADAPTUS["bg"],
plot_bgcolor=ADAPTUS["surface"],
legend=dict(orientation="h", x=0, y=1.02, bgcolor="rgba(0,0,0,0)"),
xaxis=dict(
gridcolor="#E6E1D8",
zerolinecolor="#E6E1D8",
linecolor="#B8B2A7",
ticks="outside",
title_standoff=8,
),
yaxis=dict(
gridcolor="#E6E1D8",
zerolinecolor="#E6E1D8",
linecolor="#B8B2A7",
ticks="outside",
title_standoff=8,
),
)
pio.templates["adaptus"] = tpl
pio.templates.default = "adaptus"
# Global Plotly config for professional, clean charts
# Applied to all pn.pane.Plotly instances via config parameter
PLOTLY_CONFIG = {
"displayModeBar": "hover", # Only show toolbar on hover
"displaylogo": False, # Remove Plotly logo
"modeBarButtonsToRemove": ["lasso2d", "select2d"], # Remove unnecessary tools
"responsive": True, # Auto-resize with container
}
pn.extension(
"plotly",
"tabulator",
design="material",
global_css=[
f"""
:root {{
--design-primary-color: {ADAPTUS["primary"]};
--design-background-color: {ADAPTUS["bg"]};
--design-surface-color: {ADAPTUS["surface"]};
--design-background-text-color: {ADAPTUS["text"]};
}}
html,body,.bk-root {{ background: var(--design-background-color)!important;
color: var(--design-background-text-color)!important; font-family:'Space Mono',monospace!important; }}
a{{ color:{ADAPTUS["accent"]}; }} a:hover{{ color:#5C2E5B; }}
.bk-btn:not(.bk-btn-default){{ background:{ADAPTUS["primary"]}; border-color:{ADAPTUS["primary"]}; color:#fff; }}
.bk-card,.bk-panel,.bk-root .card{{ background:var(--design-surface-color); }}
"""
],
)
# =====================================================================================
# 1. PYDANTIC DATA MODELS
# - Structured data validation with Pydantic
# - Type-safe models for fund parameters and company data
# =====================================================================================
class CompanyData(BaseModel):
"""Target company data model."""
company: str = Field(..., min_length=1, description="Company name")
website: str = Field(..., pattern=r"^https?://", description="Company website URL")
stressor: str = Field(..., min_length=5, description="Climate stressor")
pain: str = Field(..., min_length=5, description="Economic pain point")
payer: str = Field(..., min_length=5, description="Specific payer with budget")
model: str = Field(..., min_length=10, description="Business/revenue model")
acceleration: str = Field(
..., min_length=10, description="Climate acceleration logic"
)
class FundParameters(BaseModel):
"""Fund modeling parameters."""
fund_size_m: float = Field(30.0, ge=10, le=100, description="Fund size in millions")
invest_period_years: int = Field(5, ge=3, le=7, description="Investment period")
harvest_years: int = Field(10, ge=8, le=15, description="Harvest period")
fee_years_1_10: float = Field(
0.02, ge=0.0, le=0.03, description="Management fee years 1-10"
)
fee_years_11_15: float = Field(
0.015, ge=0.0, le=0.03, description="Management fee years 11-15"
)
carry: float = Field(0.20, ge=0.0, le=0.30, description="Carry rate")
hurdle: float = Field(0.08, ge=0.0, le=0.15, description="Hurdle rate")
temperature: float = Field(2.5, ge=1.5, le=3.5, description="Climate scenario")
fee_basis_invest_period: str = Field(
"committed",
description="Fee basis during investment period (committed/called/nav)",
)
fee_basis_post_invest: str = Field(
"called", description="Fee basis post-investment period (called/nav)"
)
@field_validator("temperature")
@classmethod
def validate_temperature(cls, v: float) -> float:
"""Validate temperature bounds."""
if v < 1.5 or v > 3.5:
raise ValueError("Temperature must be between 1.5°C and 3.5°C")
return v
@property
def climate_demand_uplift(self) -> float:
"""Climate demand uplift from temperature."""
return 1.0 + ((self.temperature - 2.0) * 0.3)
# =====================================================================================
# 2. GLOBAL STATE MANAGEMENT
# - UnifiedState class with all interactive parameters
# - Climate-driven demand uplift property (temperature → market acceleration)
# - Fast orjson serialization for cached financial calculations
# =====================================================================================
class UnifiedState(param.Parameterized):
"""State management with climate, fund, portfolio, and LP parameters."""
# Climate scenario (drives everything)
temperature = param.Number(
default=2.5,
bounds=(1.5, 3.5),
step=0.1,
doc="Global climate warming scenario in degrees Celsius",
)
# Fund modeling parameters
fund_size_m = param.Number(default=30.0, bounds=(10, 100), step=1.0)
invest_period_years = param.Integer(default=5, bounds=(3, 7))
harvest_years = param.Integer(default=10, bounds=(8, 15))
fee_years_1_10 = param.Number(default=0.02, bounds=(0.0, 0.03), step=0.0025)
fee_years_11_15 = param.Number(default=0.015, bounds=(0.0, 0.03), step=0.0025)
carry = param.Number(default=0.20, bounds=(0.0, 0.30), step=0.01)
hurdle = param.Number(default=0.08, bounds=(0.0, 0.15), step=0.005)
fee_basis_invest_period = param.Selector(
default="committed", objects=["committed", "called", "nav"]
)
fee_basis_post_invest = param.Selector(default="called", objects=["called", "nav"])
# Portfolio construction parameters
check_size_m = param.Number(default=0.5, bounds=(0.1, 2.0), step=0.1)
followon_multiplier = param.Number(default=1.5, bounds=(1.0, 3.0), step=0.1)
demand_uplift = param.Number(default=1.0, bounds=(0.8, 1.5), step=0.05)
downside_floor = param.Boolean(default=False)
@property
def climate_demand_uplift(self):
"""Climate demand uplift from temperature."""
return 1.0 + ((self.temperature - 2.0) * 0.3)
def validate_fund_parameters(self) -> FundParameters:
"""Validate state via Pydantic."""
return FundParameters(
fund_size_m=self.fund_size_m,
invest_period_years=self.invest_period_years,
harvest_years=self.harvest_years,
fee_years_1_10=self.fee_years_1_10,
fee_years_11_15=self.fee_years_11_15,
carry=self.carry,
hurdle=self.hurdle,
temperature=self.temperature,
fee_basis_invest_period=self.fee_basis_invest_period,
fee_basis_post_invest=self.fee_basis_post_invest,
)
# Portfolio outcome buckets (editable)
buckets_df = param.DataFrame(
default=pd.DataFrame(
[
{
"name": "Big winners (10x+)",
"count": 2,
"avg_moic": 27.0,
"avg_hold_years": 10.0,
},
{
"name": "Moderate (5–10x)",
"count": 3,
"avg_moic": 8.1,
"avg_hold_years": 8.6,
},
{
"name": "Small (1–5x)",
"count": 2,
"avg_moic": 3.8,
"avg_hold_years": 8.9,
},
{
"name": "Losers (0–1x)",
"count": 9,
"avg_moic": 0.2,
"avg_hold_years": 3.1,
},
]
)
)
# Venture builder parameters
vb_enabled = param.Boolean(default=True)
vb_invested = param.Number(default=4.47, bounds=(0, 10), step=0.25)
vb_moic = param.Number(default=6.3, bounds=(1, 15), step=0.1)
vb_hold_years = param.Number(default=6.0, bounds=(3, 10), step=0.5)
# Warehouse parameters
wh_enabled = param.Boolean(default=True)
wh_cost_basis = param.Number(default=1.0, bounds=(0, 5), step=0.1)
wh_market_value = param.Number(default=2.75, bounds=(0, 10), step=0.1)
# LP-specific parameters
lp_investment = param.Number(
default=500_000, bounds=(100_000, 5_000_000), step=500_000
)
coinvest_enabled = param.Boolean(default=False)
def validate_state(self) -> bool:
"""Validate state, returns True if valid."""
try:
self.validate_fund_parameters()
return True
except Exception as e:
print(f"State validation failed: {e}")
return False
def to_engine_json(self) -> str:
"""Serialize state to JSON for compute_fund_metrics cache."""
d = {
"fund_size_m": self.fund_size_m,
"invest_period_years": self.invest_period_years,
"harvest_years": self.harvest_years,
"fee_schedule": {
"years_1_10": self.fee_years_1_10,
"years_11_15": self.fee_years_11_15,
"basis_invest_period": self.fee_basis_invest_period,
"basis_post_invest": self.fee_basis_post_invest,
},
"carry_terms": {"carry": self.carry, "hurdle": self.hurdle},
"buckets": self.buckets_df.to_dict("records"),
"check_size_m": self.check_size_m,
"followon_multiplier": self.followon_multiplier,
"demand_uplift": max(
self.demand_uplift, self.climate_demand_uplift
), # Use higher of manual or climate-driven
"venture_builder": {
"enabled": self.vb_enabled,
"invested": self.vb_invested,
"moic": self.vb_moic,
"hold_years": self.vb_hold_years,
},
"warehouse": {
"enabled": self.wh_enabled,
"cost_basis": self.wh_cost_basis,
"market_value": self.wh_market_value,
},
"downside_floor": self.downside_floor,
}
return orjson.dumps(d, option=orjson.OPT_SORT_KEYS).decode()
# Global state instance - manages all interactive parameters
state = UnifiedState()
# Hard-lock fee policy defaults to avoid accidental drift
state.fee_basis_invest_period = "committed"
state.fee_basis_post_invest = "called"
state.carry = 0.20
state.hurdle = 0.08
state.coinvest_enabled = False
state.temperature = 2.5
# =====================================================================================
# 2. CALCULATION ENGINE
# - NPV and IRR financial functions (no external dependencies)
# - compute_fund_metrics(): Core fund performance with European waterfall
# - Climate uplift integration and portfolio modeling
# =====================================================================================
def npv(rate: float, cashflows: List[float]) -> float:
"""NPV calculation, returns discounted cashflows."""
return sum(cf / ((1 + rate) ** t) for t, cf in enumerate(cashflows))
def irr(cashflows: List[float]) -> float:
"""IRR via bisection, returns decimal or NaN."""
if (
not cashflows
or all(cf >= 0 for cf in cashflows)
or all(cf <= 0 for cf in cashflows)
):
return float("nan")
low, high = -0.99, 5.0
f_low, f_high = npv(low, cashflows), npv(high, cashflows)
if f_low * f_high > 0:
for h in (10.0, 20.0, 50.0):
f_high = npv(h, cashflows)
if f_low * f_high <= 0:
high = h
break
else:
return float("nan")
for _ in range(120):
mid = 0.5 * (low + high)
f_mid = npv(mid, cashflows)
if abs(f_mid) < 1e-8:
return mid
if f_low * f_mid < 0:
high = mid
else:
low, f_low = mid, f_mid
return 0.5 * (low + high)
def safe_irr_pct(cashflows: List[float]) -> Optional[float]:
"""Safe IRR calculation returning percentage or None if undefined."""
# needs at least one negative and one positive flow
if (
not cashflows
or all(c >= 0 for c in cashflows)
or all(c <= 0 for c in cashflows)
):
return None
r = irr(cashflows)
return None if np.isnan(r) else r * 100.0
@lru_cache(maxsize=512)
def compute_fund_metrics(params_json: str) -> Tuple[pd.DataFrame, Dict, pd.DataFrame]:
p = orjson.loads(params_json)
fund_size_m = p["fund_size_m"]
invest_years = p["invest_period_years"]
harvest_years = p["harvest_years"]
years = invest_years + harvest_years
timeline = list(range(years + 1))
fee10 = p["fee_schedule"]["years_1_10"]
fee1511 = p["fee_schedule"]["years_11_15"]
basis_invest = p["fee_schedule"].get("basis_invest_period", "committed")
basis_post = p["fee_schedule"].get("basis_post_invest", "called")
carry = p["carry_terms"]["carry"]
hurdle = p["carry_terms"]["hurdle"]
buckets = p["buckets"]
check_size_m = p["check_size_m"]
followon = p["followon_multiplier"]
demand = p["demand_uplift"]
downside = p.get("downside_floor", False)
vb = p["venture_builder"]
wh = p["warehouse"]
# ---- Build INVEST and PROCEEDS (same shapes as before) ----
invest = np.zeros(years + 1)
total_companies = sum(b["count"] for b in buckets)
total_invested = total_companies * check_size_m * followon
if invest_years > 0 and total_invested > 0:
annual_equity = -total_invested / invest_years
for y in range(1, invest_years + 1):
invest[y] += annual_equity
if vb["enabled"] and vb["invested"] > 0 and invest_years > 0:
vb_annual = -vb["invested"] / invest_years
for y in range(1, invest_years + 1):
invest[y] += vb_annual
if wh["enabled"] and wh["cost_basis"] > 0:
invest[0] += -wh["cost_basis"]
proceeds = np.zeros(years + 1)
for b in buckets:
moic = b["avg_moic"] * demand
hold = int(round(b["avg_hold_years"]))
if downside:
moic = min(moic, 3.0)
hold = min(years, hold + 2)
value = b["count"] * (check_size_m * followon) * moic
proceeds[min(years, max(1, hold))] += value
if vb["enabled"] and vb["invested"] > 0:
proceeds[min(years, max(1, int(round(vb["hold_years"]))))] += (
vb["invested"] * vb["moic"]
)
if wh["enabled"] and wh["market_value"] > 0:
proceeds[min(years, 3)] += wh["market_value"] - wh["cost_basis"]
# ---- Waterfall state ----
contrib = 0.0 # outstanding capital (incl. fees) to accrue pref on
total_called = 0.0 # all calls to date (invest + fees)
pref_acc = 0.0 # accrued, unpaid preferred return
total_pref_accrued = 0.0
total_lp_dist = 0.0 # cumulative distributions to LP
carry_paid_cum = 0.0
gross_cf = np.zeros(years + 1)
net_cf = np.zeros(years + 1)
rows = []
def fee_rate_for_year(y: int) -> float:
return fee10 if 1 <= y <= 10 else (fee1511 if 11 <= y <= 15 else 0.0)
def fee_base_for_year(y: int, called_so_far: float, contrib_start: float) -> float:
basis = basis_invest if y <= invest_years else basis_post
if basis == "committed":
return fund_size_m
if basis == "called":
return called_so_far
if basis == "nav":
# NOTE: contrib_start includes fees. Many LPAs define NAV as invested
# cost excluding fees. If your LPA specifies that, track a separate
# invested_outstanding balance and return that instead.
return contrib_start
return fund_size_m
for y in timeline:
# Start-of-year state for fee base
called_so_far = total_called
contrib_start = contrib
# ---- Fees for this year (charged at period y) ----
r = fee_rate_for_year(y)
base = fee_base_for_year(y, called_so_far, contrib_start)
fee_y = -r * base # outflow
# ---- Calls this year (fees + invest[y]) ----
calls_this_year = 0.0
if fee_y < 0:
calls_this_year += -fee_y
if invest[y] < 0:
calls_this_year += -invest[y]
if calls_this_year > 0:
contrib += calls_this_year
total_called += calls_this_year
net_cf[y] += -calls_this_year
# ---- Distributions this year ----
dist = proceeds[y]
row = {
"Year": y,
"Calls": calls_this_year,
"Dist_Principal": 0.0,
"Dist_Pref": 0.0,
"Dist_LP_AfterCarry": 0.0,
"Dist_Carry": 0.0,
}
# 1) Return of capital
if dist > 0 and contrib > 0:
pay_prin = min(dist, contrib)
dist -= pay_prin
contrib -= pay_prin
net_cf[y] += pay_prin
row["Dist_Principal"] = pay_prin
total_lp_dist += pay_prin
# 2) Pay preferred return
if dist > 0 and pref_acc > 0:
pay_pref = min(dist, pref_acc)
dist -= pay_pref
pref_acc -= pay_pref
net_cf[y] += pay_pref
row["Dist_Pref"] = pay_pref
total_lp_dist += pay_pref
# 3) GP catch-up on cumulative profits above pref (include prior carry)
if dist > 0:
profits_above_pref = max(
0.0,
(total_lp_dist + carry_paid_cum + dist)
- total_called
- total_pref_accrued,
)
carry_should_be = carry * profits_above_pref
new_carry = max(0.0, carry_should_be - carry_paid_cum)
pay_carry = min(dist, new_carry)
dist -= pay_carry
carry_paid_cum += pay_carry
row["Dist_Carry"] = pay_carry
# 4) LP gets the remainder
if dist > 0:
net_cf[y] += dist
row["Dist_LP_AfterCarry"] = dist
total_lp_dist += dist
# Gross CF bookkeeping (for display parity)
gross_cf[y] = fee_y + invest[y] + proceeds[y]
# 5) Accrue preferred return for next period on outstanding principal
if y < years:
pref_to_accrue = contrib * hurdle
pref_acc += pref_to_accrue
total_pref_accrued += pref_to_accrue
rows.append(row)
breakdown_df = pd.DataFrame(rows)
# ---- Summaries (unchanged interface) ----
pic_net = -net_cf[net_cf < 0].sum()
dist_net = net_cf[net_cf > 0].sum()
nav_net = max(0.0, -net_cf.cumsum()[-1])
pic_gross = -gross_cf[gross_cf < 0].sum()
dist_gross = gross_cf[gross_cf > 0].sum()
nav_gross = max(0.0, -gross_cf.cumsum()[-1])
_net_irr = safe_irr_pct(net_cf.tolist())
summary = {
"TVPI": (dist_net + nav_net) / pic_net if pic_net > 0 else 0.0,
"DPI": dist_net / pic_net if pic_net > 0 else 0.0,
"NetIRR": _net_irr, # may be None
"GrossTVPI": (dist_gross + nav_gross) / pic_gross if pic_gross > 0 else 0.0,
"CarryPaid": carry_paid_cum,
}
main_df = pd.DataFrame(
{
"Year": timeline,
"GrossCF": gross_cf,
"NetCF": net_cf,
"CumulativeNet": net_cf.cumsum(),
}
)
# ---- Sanity checks (same spirit, adapted to new state) ----
warnings: list[str] = []
if summary["DPI"] > summary["TVPI"]:
warnings.append(f"DPI ({summary['DPI']:.2f}) > TVPI ({summary['TVPI']:.2f})")
total_calls_check = breakdown_df["Calls"].sum()
total_principal_paid = breakdown_df["Dist_Principal"].sum()
total_pref_paid = breakdown_df["Dist_Pref"].sum()
total_lp_after_carry = breakdown_df["Dist_LP_AfterCarry"].sum()
total_proceeds = (
total_principal_paid + total_pref_paid + total_lp_after_carry + carry_paid_cum
)
profit_above_pref = total_proceeds - total_calls_check - total_pref_paid
max_carry_allowed = max(0.0, carry * profit_above_pref)
if carry_paid_cum > max_carry_allowed * 1.01:
warnings.append(
f"Carry {carry_paid_cum:.2f}M > {carry * 100:.0f}% of profits above pref {max_carry_allowed:.2f}M"
)
# No carry before capital + pref fully returned (cumulative)
cum_calls = cum_prin = cum_pref = 0.0
for _, r in breakdown_df.iterrows():
cum_calls += r["Calls"]
cum_prin += r["Dist_Principal"]
cum_pref += r["Dist_Pref"]
if r["Dist_Carry"] > 1e-2 and (cum_prin + cum_pref) + 1e-2 < cum_calls:
warnings.append(
f"Year {int(r['Year'])}: carry before capital+pref returned"
)
break
irr_check = summary["NetIRR"] is not None and summary["NetIRR"] < -100
if summary["TVPI"] < 0 or irr_check or summary["TVPI"] > 100:
irr_str = (
f"{summary['NetIRR']:.1f}%" if summary["NetIRR"] is not None else "N/A"
)
warnings.append(
f"Unrealistic metrics: TVPI {summary['TVPI']:.2f}, IRR {irr_str}"
)
for w in warnings:
print(f"⚠️ Waterfall Warning: {w}")
return main_df, summary, breakdown_df
def create_validated_fund_params(state: UnifiedState) -> str:
"""
Create validated fund parameters using Pydantic and orjson.
Provides data validation and fast serialization for fund calculations.
"""
try:
# Validate parameters using Pydantic
validated_params = state.validate_fund_parameters()
# Create complete parameter dict
d = {
"fund_size_m": validated_params.fund_size_m,
"invest_period_years": validated_params.invest_period_years,
"harvest_years": validated_params.harvest_years,
"fee_schedule": {
"years_1_10": validated_params.fee_years_1_10,
"years_11_15": validated_params.fee_years_11_15,
"basis_invest_period": validated_params.fee_basis_invest_period,
"basis_post_invest": validated_params.fee_basis_post_invest,
},
"carry_terms": {
"carry": validated_params.carry,
"hurdle": validated_params.hurdle,
},
"buckets": state.buckets_df.to_dict("records"),
"check_size_m": state.check_size_m,
"followon_multiplier": state.followon_multiplier,
"demand_uplift": validated_params.climate_demand_uplift,
"venture_builder": {
"enabled": state.vb_enabled,
"invested": state.vb_invested,
"moic": state.vb_moic,
"hold_years": state.vb_hold_years,
},
"warehouse": {
"enabled": state.wh_enabled,
"cost_basis": state.wh_cost_basis,
"market_value": state.wh_market_value,
},
"downside_floor": state.downside_floor,
}
# Use orjson for fast serialization
return orjson.dumps(d, option=orjson.OPT_SORT_KEYS).decode()
except Exception as e:
print(f"Warning: Parameter validation failed: {e}")
# Fallback to original method
return state.to_engine_json()
# =====================================================================================
# 3. DESIGN SYSTEM & DATA
# - ADAPTUS brand color palette with CSS variables
# - SECTORS: Climate adaptation market data (5 sectors, sources cited)
# - TARGET_COMPANIES: Investment pipeline (12 companies with framework)
# - FUND_COMPARISON: Competitive landscape vs other funds
# =====================================================================================
class SectorData(TypedDict):
"""Sector market data."""
current: float
future: float
# Climate adaptation market data (sources: GCA 2024, Market.us 2025, Grand View Research)
# Used for: Climate thesis visualization, market size projections, sector examples
SECTORS: Dict[str, SectorData] = {
"Smart Water Mgmt": {"current": 19.0, "future": 62.0},
"Cold Chain Logistics": {"current": 368.0, "future": 1300.0},
"Personal Cooling": {"current": 14.0, "future": 25.0},
"Glass Coatings": {"current": 2.0, "future": 8.0},
"Sustainable Tourism": {"current": 4000.0, "future": 13000.0},
}
# Target companies with Pydantic validation
# Framework: Climate Stressor → Economic Pain → Specific Payer → Business Model → Climate Acceleration
# Categories: Water Stress (4), Heat Management (3), Food Security (3), Infrastructure (2)
_target_companies_data = [
{
"company": "Brekland",
"website": "https://www.brekland.com",
"stressor": "Late spring frost",
"pain": "Crop loss, deductibles",
"payer": "Orchard and specialty-crop growers",
"model": "Seasonal consumable per acre via distributors",
"acceleration": "More cold snaps around bloom raise loss odds",
},
{
"company": "ConnectedFresh",
"website": "https://www.connectedfresh.com",
"stressor": "Heat waves, grid volatility in cold chains",
"pain": "Spoilage, fines, energy waste",
"payer": "Restaurants, groceries, processors, cold storage",
"model": "Sensors plus SaaS compliance and alerts",
"acceleration": "Hotter days and insurer demands shorten payback",
},
{
"company": "Mexar",
"website": "https://mexar.co",
"stressor": "High-heat days, OSHA heat rule",
"pain": "Injuries, claims, lost productivity",
"payer": "Employers with outdoor workforces",
"model": "Per-employee subscription, cooling gear plus compliance analytics",
"acceleration": "More WBGT red-flag days make this compliance spend",
},
{
"company": "Orca Water is Life",
"website": "https://www.orcawater.life",
"stressor": "Drought, rising water tariffs",
"pain": "Non-revenue water, damage, penalties",
"payer": "Property owners, campuses, utilities",
"model": "Hardware plus SaaS, optional savings share",
"acceleration": "Deeper drought raises marginal water cost",
},
{
"company": "RCOAST",
"website": "https://www.r-coast.com",
"stressor": "Sea-level rise, storm surge, erosion",
"pain": "Asset loss, higher premiums, project delays",
"payer": "Municipalities, DOTs, ports, HOAs, insurers",
"model": "Mapping and monitoring as a service, annual contracts",
"acceleration": "More severe storms make yearly surveys mandatory",
},
{
"company": "Senecio Robotics",
"website": "https://www.senecio-robotics.com",
"stressor": "Expanding mosquito ranges, outbreaks",
"pain": "Public health costs, tourism hits",
"payer": "Health ministries, cities, vector districts",
"model": "SIT as a service, build-operate facilities and releases",
"acceleration": "Longer breeding seasons improve SIT economics",
},
{
"company": "SmartAgri Labs",
"website": "https://smartagrilabs.com",
"stressor": "Weather volatility, hybrid mismatch",
"pain": "Yield loss, wasted inputs",
"payer": "Growers, ag retailers, seed distributors",
"model": "SaaS per farm or per acre, embedded recs",
"acceleration": "More variability increases value of local picks",
},
{
"company": "Sunphade",
"website": "https://sunphade.com",
"stressor": "Heat and glare, rising cooling loads",
"pain": "HVAC costs, comfort complaints",
"payer": "Schools, commercial building owners",
"model": "Materials sale plus install, warranty, service",
"acceleration": "More cooling-degree days compress paybacks",
},
{
"company": "ThermoShade",
"website": "https://getthermoshade.com",
"stressor": "Unsafe outdoor heat",
"pain": "Liability, unusable outdoor areas",
"payer": "Schools, cities, multifamily, hospitality",
"model": "Product plus install and service, potential OpEx contracts",
"acceleration": "More extreme heat turns shade into required spend",
},
{
"company": "Huma (ag inputs)",
"website": "https://huma.us",
"stressor": "Drought, salinity, heat stress",
"pain": "Reduced yields, fertilizer efficiency loss",
"payer": "Growers via input budgets",
"model": "Recurring consumables per acre",
"acceleration": "Tighter water makes biostimulants must-have",
},
{
"company": "HydroHammer",
"website": "https://www.hydrohammer.co.uk",
"stressor": "Unreliable power, variable flows",
"pain": "Fuel and pump OPEX, water access gaps",
"payer": "Farms, rural water schemes, NGOs",
"model": "Equipment plus service, fuel-savings payback",
"acceleration": "More outages and fuel costs improve ROI",
},
{
"company": "Undesert",
"website": "https://www.undesert.com",
"stressor": "Water scarcity, brine disposal limits",
"pain": "No irrigation water, disposal fees",
"payer": "Municipalities, industry, ag in arid regions",
"model": "Systems plus water-as-a-service, project finance",
"acceleration": "Prolonged drought makes reuse essential",
},
]
# Validate and create TARGET_COMPANIES with Pydantic for data integrity
TARGET_COMPANIES = [
CompanyData(**company).model_dump() for company in _target_companies_data
]
# Competitive landscape analysis for fund positioning
# Used in: Competitive view for "Why Adapt[us]?" differentiation
# Compares: Thesis, stage, support model, fees, track record, differentiation
FUND_COMPARISON = {
"Adapt[us]": {
"thesis": "Climate Adaptation",
"stage": "Pre-seed to A",
"support": "Fund + Builder",
"fee_carry": "2%/1.5% | 20%",
"track_record": "7x, 51% IRR",
"differentiation": "Only adaptation fund w/ builder. 2.5°C+ base.",
},
"Lowercarbon Capital": {
"thesis": "Mitigation",
"stage": "Pre-seed to Seed",
"support": "Sci/Ops",
"fee_carry": "2% | 20%",
"track_record": "Undisclosed",
"differentiation": "Carbon-neg, Gates backing",
},
"Breakthrough Energy": {
"thesis": "Mitigation",
"stage": "Early to Growth",
"support": "Tech/Policy",
"fee_carry": "Undisclosed",
"track_record": "Impact-first",
"differentiation": "Deep-tech, patient cap",
},
"Sequoia Capital": {
"thesis": "Tech Growth",
"stage": "Seed to Growth",
"support": "Network/Adv",
"fee_carry": "2.5% | 25%",
"track_record": "30%+ IRR",
"differentiation": "Tier-1 brand, unicorns",
},
}
# =====================================================================================
# 4. UI COMPONENTS
# Organized by function: Hero → Analysis Views → Technical Views
# All components use @pn.depends for reactivity and ADAPTUS theme
# =====================================================================================
# --- Hero & Branding Components ---
def create_lp_hero_section():
"""LP hero with thesis, track record, and unique positioning."""
return pmui.Markdown(
"""
## <i class="fas fa-globe-americas adaptus-icon"></i>$9T Climate Adaptation Opportunity
**2.5°C+ warming is inevitable.** We invest in businesses people **must buy** as climate volatility rises.
**Track**: 7x, 51% IRR | **Fund + Builder** | **Target**: 40% A→B grad
*Adaptation gets ~5% of climate finance. We're changing that.*
""",
styles={
"background": f"linear-gradient(135deg, {COLORS['primary']} 0%, {COLORS['secondary']} 50%, {COLORS['accent']} 100%)",
"color": "white",
"padding": "20px",
"border-radius": "8px",
"margin-bottom": "15px",
"font-size": "16px",
"text-align": "center",
},
)
# --- Core Analysis Views ---
def create_climate_view():
"""Interactive climate thesis with temperature-driven market projections."""
@pn.depends(state.param.temperature)
def _view(temp):
"""Climate view by temperature."""
mult = state.climate_demand_uplift # Use the property for consistency
names = list(SECTORS.keys())
current = [d["current"] for d in SECTORS.values()]
future = [d["future"] for d in SECTORS.values()]
projected = [c + (f - c) * mult for c, f in zip(current, future)]
fig = go.Figure()
fig.add_trace(
go.Bar(
name="2024 Market", x=names, y=current, marker_color=COLORS["accent"]
)
)
fig.add_trace(
go.Bar(
name=f"Projected @ {temp:.1f}°C",
x=names,
y=projected,
marker_color=COLORS["primary"],
)
)
fig.update_layout(
barmode="group",
title="Adaptation Market Growth vs. Climate Scenario",
yaxis_title="Market Size ($B)",
height=350,
template="adaptus", # Use custom Adaptus theme
)
return pmui.Column(
pmui.Markdown(
f"""
## Climate-Proof Thesis
**2.5°C+ warming drives predictable demand.**
Market: $1.4T → $9.0T (2024-2050).
> **Uplift: {mult:.2f}x** | **Projected: ${sum(projected):,.0f}B**
""",
styles={
"background": COLORS["surface"],
"padding": "15px",
"border-radius": "8px",
"border": f"1px solid {COLORS['accent']}",
},
),
pn.pane.Plotly(fig, sizing_mode="stretch_width", config=PLOTLY_CONFIG),
)
return _view
def create_pipeline_view():
"""Pipeline with 12 targets using Stressor→Pain→Payer→Model→Acceleration."""
pipeline_df = pd.DataFrame(TARGET_COMPANIES)
# Prepare display columns with professional URL column
display_df = pipeline_df[
["company", "website", "stressor", "pain", "payer", "model", "acceleration"]
].copy()
display_df["Site"] = display_df["website"].apply(
lambda x: f'<a href="{x}" target="_blank"><i class="fas fa-external-link-alt" style="color: {COLORS["primary"]}"></i></a>'
)
display_df = display_df[
["company", "Site", "stressor", "pain", "payer", "model", "acceleration"]
]
display_df.columns = [
"Company",
"url",
"Climate Stressor",
"Economic Pain",
"Payer",
"Business Model",
"Climate Acceleration",
]
return pmui.Column(
pmui.Markdown(
"""
## <i class="fas fa-bullseye adaptus-icon"></i>Pipeline: Thesis in Practice
**12 targets:** Stressor → Pain → Payer → Model → Acceleration
""",
styles={"font-size": "18px", "font-weight": "bold"},
),
# Three Playbooks Framework from pitch deck
pmui.Row(
pmui.Markdown(
"""
### 📋 A. Resilience Infrastructure
Real climate causality, infra-like returns, longer paybacks
*Examples: flood defense, water reuse*
""",
styles={
"background": COLORS["surface"],
"padding": "12px",
"border-radius": "6px",
"border": f"1px solid {COLORS['success']}",
},
),
pmui.Markdown(
"""
### 🚀 B. Demand Shifts (Venture)
Clear stressor→payer link, venture upside
*Examples: NRW SaaS, cold-chain telemetry, heat safety*
""",
styles={
"background": COLORS["surface"],
"padding": "12px",
"border-radius": "6px",
"border": f"1px solid {COLORS['warn']}",
},
),
pmui.Markdown(
"""
### 🔧 C. Builder Capability (Orthogonal)
Measured interventions to compress time-to-milestone
*We only "build where we invest"*
""",
styles={
"background": COLORS["surface"],
"padding": "12px",
"border-radius": "6px",
"border": f"1px solid {COLORS['accent']}",
},
),
sizing_mode="stretch_width",
),
pn.widgets.Tabulator(
display_df,
disabled=True,
show_index=False,
sizing_mode="stretch_width", # Make table expand to full width
height=520, # Increased height to show all 12 companies without scrolling
pagination=None, # No pagination - show all companies
# No theme specified - use default for better contrast
configuration={
"layout": "fitColumns", # Fit columns to available width
"height": "auto", # Auto-size height based on content
"maxHeight": 520, # Maximum height before scrolling
"columnDefaults": {
"headerSort": False,
"resizable": True,
"tooltip": True, # Show full text on hover
},
"columns": [
{"field": "Company", "minWidth": 120, "widthGrow": 1},
{
"field": "url",
"width": 70,
"formatter": "html",
"widthGrow": 0,
"headerSort": False,
},
{"field": "Climate Stressor", "minWidth": 150, "widthGrow": 2},
{"field": "Economic Pain", "minWidth": 150, "widthGrow": 2},
{"field": "Payer", "minWidth": 160, "widthGrow": 2},
{"field": "Business Model", "minWidth": 180, "widthGrow": 3},
{"field": "Climate Acceleration", "minWidth": 180, "widthGrow": 3},
],
},
styles={
"background": COLORS["surface"],
"border": f"2px solid {COLORS['primary']}",
"border-radius": "8px",
},
),
pmui.Row(
# Column 1: Climate Categories
pmui.Markdown(
"""
### <i class="fas fa-thermometer-half adaptus-icon"></i>Climate Categories<br>
**Water Stress**: Brekland, Orca, Undesert, HydroHammer<br>
**Heat Management**: Mexar, Sunphade, ThermoShade<br>
**Food Security**: ConnectedFresh, SmartAgri, Huma<br>
**Infrastructure**: RCOAST, Senecio Robotics
""",
styles={
"background": COLORS["surface"],
"padding": "15px",
"border-radius": "8px",
"border": f"1px solid {COLORS['accent']}",
},
),
# Column 2: Business Model Mix
pmui.Markdown(
"""
### <i class="fas fa-dollar-sign adaptus-icon"></i>Business Model Mix
**SaaS/Subscription**: 50% (recurring revenue)
**Hardware + Service**: 33% (sticky recurring)
**Consumables**: 17% (repeat purchase)
**Pipeline Quality**: Clear payer ID, budget authority, measurable ROI
""",
styles={
"background": COLORS["surface"],
"padding": "15px",
"border-radius": "8px",
"border": f"1px solid {COLORS['accent']}",
},
),
# Column 3: Scoring Framework
pmui.Markdown(
"""
### <i class="fas fa-chart-bar adaptus-icon"></i>Scoring Framework
**Systematic evaluation of each company:**
- **Stressor/threshold**: 20%
- **Payer/budget clarity**: 25%
- **Margin path ≥50%**: 20%
- **Capex/payback**: 10%
- **GTM wedge**: 10%
- **Regulator/insurer pull**: 10%
- **Execution risk**: 5%
**Target**: 40% A→B graduation vs 21% industry average
""",
styles={
"background": COLORS["surface"],
"padding": "15px",
"border-radius": "8px",
"border": f"1px solid {COLORS['accent']}",
},
),
sizing_mode="stretch_width",
),
# Investment Framework Summary
pmui.Markdown(
"""
### 🔬 Investment Framework Summary
Every company demonstrates our core thesis: **businesses people must buy as climate volatility increases**.
- **Not hope-to-buy** (mitigation/carbon credits) | **Not nice-to-buy** (sustainability) | **Must-buy under stress**
**Result**: Predictable demand acceleration + systematic evaluation = superior risk-adjusted returns.
""",
styles={
"background": COLORS["surface"],
"padding": "15px",
"border-radius": "8px",
"border": f"2px solid {COLORS['primary']}",
"text-align": "center",
},
),
)
def create_returns_view():
"""
Create personalized LP returns analysis.
Core LP decision-making view featuring:
- Investment amount input with real-time updates
- Co-investment rights toggle for additional upside
- Personal return metrics (TVPI, IRR, total proceeds)
- J-curve visualization vs S&P 500 benchmark
- European waterfall calculations for LP's exact share
Calculates the LP's proportional share of fund returns based on
their commitment relative to the total fund size.
Returns:
pn.Row: Two-column layout with controls and analysis
"""
@pn.depends(
state.param.temperature, state.param.lp_investment, state.param.coinvest_enabled
)
def _view(temperature, lp_investment, coinvest):
"""Calculate and display personalized LP returns and J-curve."""
main_df, summary, breakdown = compute_fund_metrics(state.to_engine_json())
share = (
(lp_investment / (state.fund_size_m * 1_000_000))
if state.fund_size_m > 0
else 0.0
)
lp_calls = breakdown["Calls"] * share
lp_dists = (
breakdown["Dist_Principal"]
+ breakdown["Dist_Pref"]
+ breakdown["Dist_LP_AfterCarry"]
) * share
# Calculate net LP cashflows (negative calls, positive distributions)
lp_net_annual = lp_dists - lp_calls
# Co-investment model - integrate into cashflows
total_inv = lp_investment
co_amt = 0.0
coinvest_exit_year = 5 # Co-invest typically exits around year 5
if coinvest:
co_amt = lp_investment * 0.2
total_inv += co_amt
# Add co-investment cashflows to the LP's annual cashflows
# Convert co_amt from dollars to millions to match lp_net_annual units
co_amt_m = co_amt / 1_000_000
# Investment in year 0 (negative cashflow)
lp_net_annual_with_coinvest = lp_net_annual.copy()
lp_net_annual_with_coinvest.iloc[0] -= co_amt_m
# Exit in year 5 (positive cashflow at 8x)
if len(lp_net_annual_with_coinvest) > coinvest_exit_year:
lp_net_annual_with_coinvest.iloc[coinvest_exit_year] += co_amt_m * 8.0
else:
lp_net_annual_with_coinvest = lp_net_annual
# Calculate LP's personal IRR and TVPI from actual cashflows (flow-true)
lp_net_cashflows = lp_net_annual_with_coinvest.tolist()
lp_irr = (
irr(lp_net_cashflows) * 100 if not np.isnan(irr(lp_net_cashflows)) else 0.0
)
# Flow-true TVPI from actual LP cashflows (not weighted blend)
neg_cf = -sum(cf for cf in lp_net_cashflows if cf < 0)
pos_cf = sum(cf for cf in lp_net_cashflows if cf > 0)
lp_tvpi = (pos_cf / neg_cf) if neg_cf > 0 else 0.0
# Calculate cumulative cashflows (for J-curve)
lp_cum = lp_net_annual_with_coinvest.cumsum()
total_proceeds = neg_cf * lp_tvpi * 1_000_000 # Convert M back to $
# Results card with LP-specific metrics
results_card = pmui.Card(
pmui.Row(
pn.indicators.Number(
name="Your Investment",
value=total_inv,
format="${value:,.0f}",
font_size="18pt",
),
pn.indicators.Number(
name="Expected Proceeds",
value=total_proceeds,
format="${value:,.0f}",
font_size="18pt",
),
pn.indicators.Number(
name="Your Multiple",
value=lp_tvpi,
format="{value:.2f}x",
font_size="18pt",
),
pn.indicators.Number(
name="Your IRR",
value=lp_irr,
format="{value:.1f}%",
font_size="18pt",
),
),
title="Your Personal Return Summary",
header_background=COLORS["primary"],
header_color="white",
)
# Enhanced J-curve vs S&P 500 calculation
years = breakdown["Year"].to_numpy()
# S&P 500: Lump sum investment that grows at 10% annually
sp_initial_investment = total_inv # Use total investment (including co-invest)
sp_cumulative = np.array(
[
sp_initial_investment * ((1.10**y) - 1)
if y > 0
else -sp_initial_investment
for y in years
]
)
fig = go.Figure()
# Add LP returns line with debugging
# Note: lp_cum is in millions, convert to thousands for chart display
fig.add_trace(
go.Scatter(
x=years,
y=lp_cum * 1000, # Convert millions to thousands (M → K)
name="Adapt[us]",
line=dict(color=COLORS["primary"], width=4),
fill="tozeroy",
hovertemplate="Year: %{x}<br>Value: $%{y:.0f}K<extra></extra>",
)
)
# Add S&P 500 comparison
fig.add_trace(
go.Scatter(
x=years,
y=sp_cumulative
/ 1000, # sp_cumulative is in dollars, convert to thousands
name="S&P 500 (10%/year)",
line=dict(color=COLORS["muted"], dash="dash", width=3),
hovertemplate="Year: %{x}<br>Value: $%{y:.0f}K<extra></extra>",
)
)
fig.add_hline(
y=0,
line_dash="dash",
line_color=COLORS["muted"],
annotation_text="Breakeven",
)
# Dynamic title based on co-investment
chart_title = "Your Investment J-Curve vs S&P 500"
if coinvest:
chart_title += f" (Fund + ${co_amt:,.0f} Co-invest)"
fig.update_layout(
title=chart_title,
yaxis_title="Cumulative Value ($K)",
height=400,
hovermode="x unified",
template="adaptus",
)
return pmui.Column(
results_card,
pn.pane.Plotly(fig, sizing_mode="stretch_width", config=PLOTLY_CONFIG),
)
return _view
# --- Technical Analysis Views ---
def create_fund_modeling_view():
"""
Create comprehensive fund modeling view for analysts.
Features complete control over fund parameters:
- Scenario presets (Cautious/Base/Aggressive)
- Fund structure controls (size, timeline, fees)
- Portfolio construction (check size, follow-on, success rates)
- Venture builder economics (debt, equity, conversion terms)
- Editable portfolio buckets (Tabulator interface)
- Real-time European waterfall calculations
This view exposes all the underlying assumptions that drive
fund performance for detailed due diligence analysis.
Returns:
pn.Row: Controls sidebar and analysis dashboard
"""
# Scenario presets with Material UI
presets = pmui.RadioButtonGroup(
name="Scenario Presets",
options=["Cautious", "Base", "Aggressive"],
value="Base",
)
@pn.depends(presets.param.value, watch=True)
def on_preset(preset) -> None:
"""
Update portfolio assumptions based on selected scenario preset.
Preset scenarios:
- Cautious: Conservative returns, more losers (downside case)
- Base: Current default assumptions (base case)
- Aggressive: Higher returns, more winners (upside case)
"""
if preset == "Cautious":
state.demand_uplift = 0.9
state.buckets_df = pd.DataFrame(
[
{
"name": "Big",
"count": 1,
"avg_moic": 20.0,
"avg_hold_years": 11.0,
},
{"name": "Mod", "count": 3, "avg_moic": 6.0, "avg_hold_years": 9.0},
{
"name": "Small",
"count": 2,
"avg_moic": 3.0,
"avg_hold_years": 9.0,
},
{
"name": "Loss",
"count": 10,
"avg_moic": 0.1,
"avg_hold_years": 4.0,
},
]
)
elif preset == "Aggressive":
state.demand_uplift = 1.15
state.buckets_df = pd.DataFrame(
[
{
"name": "Big",
"count": 3,
"avg_moic": 30.0,
"avg_hold_years": 9.0,
},
{"name": "Mod", "count": 3, "avg_moic": 9.0, "avg_hold_years": 8.0},
{
"name": "Small",
"count": 2,
"avg_moic": 4.0,
"avg_hold_years": 8.0,
},
{
"name": "Loss",
"count": 8,
"avg_moic": 0.3,
"avg_hold_years": 3.0,
},
]
)
else: # Base
state.demand_uplift = 1.0
state.buckets_df = UnifiedState.param.buckets_df.default
# Portfolio bucket editor with Adaptus styling
bucket_editor = pn.widgets.Tabulator(
value=state.buckets_df,
show_index=False,
sizing_mode="stretch_width",
editors={
"name": None,
"count": {"type": "number", "min": 0},
"avg_moic": {"type": "number", "min": 0},
"avg_hold_years": {"type": "number", "min": 1},
},
height=200,
layout="fit_columns",
configuration={"layout": "fitColumns", "columnDefaults": {"resizable": True}},
styles={
"background": COLORS["surface"],
"border": f"2px solid {COLORS['primary']}",
"border-radius": "8px",
},
)
bucket_editor.link(state, value="buckets_df")
# Fund controls with Material UI components
controls = pmui.Column(
presets,
pmui.Accordion(
(
"Fund Structure",
pmui.Column(
pmui.FloatSlider.from_param(
state.param.fund_size_m, name="Fund Size ($M)"
),
pmui.IntSlider.from_param(
state.param.invest_period_years,
name="Investment Period (Years)",
),
pmui.IntSlider.from_param(
state.param.harvest_years, name="Harvest Period (Years)"
),
),
),
(
"Fees & Carry",
pmui.Column(
pmui.FloatSlider.from_param(
state.param.fee_years_1_10, name="Management Fee (Years 1-10)"
),
pmui.FloatSlider.from_param(
state.param.fee_years_11_15, name="Management Fee (Years 11-15)"
),
pmui.FloatSlider.from_param(state.param.carry, name="Carry Rate"),
pmui.FloatSlider.from_param(state.param.hurdle, name="Hurdle Rate"),
),
),
(
"Portfolio",
pmui.Column(
pmui.FloatSlider.from_param(
state.param.check_size_m, name="Check Size ($M)"
),
pmui.FloatSlider.from_param(
state.param.followon_multiplier, name="Follow-on Multiplier"
),
pmui.FloatSlider.from_param(
state.param.demand_uplift, name="Demand Uplift"
),
pmui.Switch.from_param(
state.param.downside_floor, name="Downside Floor"
),
pmui.Markdown("**Portfolio Buckets**"),
bucket_editor,
),
),
(
"Venture Builder",
pmui.Column(
pmui.Switch.from_param(
state.param.vb_enabled, name="Venture Builder Enabled"
),
pmui.FloatSlider.from_param(
state.param.vb_invested, name="VB Investment ($M)"
),
pmui.FloatSlider.from_param(
state.param.vb_moic, name="VB Expected MOIC"
),
pmui.Divider(),
pmui.Switch.from_param(
state.param.wh_enabled, name="Warehouse Enabled"
),
pmui.FloatSlider.from_param(
state.param.wh_cost_basis, name="Warehouse Cost ($M)"
),
pmui.FloatSlider.from_param(
state.param.wh_market_value, name="Warehouse Value ($M)"
),
),
),
active=[0, 2],
toggle=True,
),
width=350,
)
# Analysis view
@pn.depends(
state.param.temperature, state.param.fund_size_m, state.param.demand_uplift
)
def _analysis(temperature, fund_size, demand_uplift):
"""Generate fund performance analysis with KPIs and visualizations."""
main_df, summary, breakdown = compute_fund_metrics(state.to_engine_json())
# KPI indicators
# IRR handling: show N/A if not yet defined
ni = summary["NetIRR"]
net_irr_name = "Net IRR" if ni is not None else "Net IRR (N/A yet)"
net_irr_val = ni if ni is not None else 0
net_irr_fmt = "{value:.1f}%" if ni is not None else ""
kpis = pmui.Row(
pn.indicators.Number(
name="Net TVPI",
value=summary["TVPI"],
format="{value:.2f}x",
font_size="18pt",
),
pn.indicators.Number(
name=net_irr_name,
value=net_irr_val,
format=net_irr_fmt,
font_size="18pt",
),
pn.indicators.Number(
name="DPI",
value=summary["DPI"],
format="{value:.2f}x",
font_size="18pt",
),
pn.indicators.Number(
name="Carry Paid",
value=summary["CarryPaid"],
format="${value:.1f}M",
font_size="18pt",
),
)
# Cashflow chart with Adaptus styling
fig_cf = go.Figure(
go.Bar(
x=main_df["Year"], y=main_df["NetCF"], marker_color=COLORS["primary"]
)
)
fig_cf.update_layout(
title="Net Cash Flow to LPs ($M)",
height=280,
yaxis_title="$M",
template="adaptus",
)
# Cumulative chart with Adaptus styling
fig_cum = go.Figure(
go.Scatter(
x=main_df["Year"],
y=main_df["CumulativeNet"],
mode="lines+markers",
line=dict(color=COLORS["secondary"], width=3),
)
)
fig_cum.add_hline(y=0, line_dash="dash", line_color=COLORS["muted"])
fig_cum.update_layout(
title="Cumulative Net Cash Flow (J-Curve)",
height=280,
yaxis_title="$M",
template="adaptus",
)
# DPI Bridge Table from pitch deck
dpi_bridge_data = [
{
"Scenario": "Down-case",
"Gross MOIC": "2.5×",
"Mgmt Fees": "(0.35×)",
"Builder Line Net to LPs": "+0.10×",
"Carry": "(0.18×)",
"Net DPI": "2.07×",
},
{
"Scenario": "Base",
"Gross MOIC": "3.5×",
"Mgmt Fees": "(0.35×)",
"Builder Line Net to LPs": "+0.20×",
"Carry": "(0.27×)",
"Net DPI": "3.08×",
},
{
"Scenario": "Up-case",
"Gross MOIC": "4.0×",
"Mgmt Fees": "(0.35×)",
"Builder Line Net to LPs": "+0.25×",
"Carry": "(0.31×)",
"Net DPI": "3.59×",
},
]
dpi_bridge_df = pd.DataFrame(dpi_bridge_data)
return pmui.Column(
kpis,
pmui.Row(
pn.pane.Plotly(fig_cf, config=PLOTLY_CONFIG),
pn.pane.Plotly(fig_cum, config=PLOTLY_CONFIG),
),
pmui.Markdown(
"### Gross→Net DPI Bridge (Illustrative)",
styles={"font-weight": "bold", "margin-top": "20px"},
),
pn.widgets.Tabulator(
dpi_bridge_df,
show_index=False,
disabled=True,
sizing_mode="stretch_width",
height=150,
configuration={
"layout": "fitColumns",
"columnDefaults": {"headerSort": False},
},
styles={
"background": COLORS["surface"],
"border": f"2px solid {COLORS['primary']}",
"border-radius": "8px",
},
),
sizing_mode="stretch_width",
)
return pmui.Row(controls, _analysis, sizing_mode="stretch_width")
def create_impact_view():
"""
Create impact analysis showing dual financial + climate returns.
Demonstrates the adaptation advantage with:
- Lives improved calculation using eQALY methodology
- Market impact from climate acceleration
- Climate hedge matrix comparing adaptation vs mitigation funds
- Fund track record in context of impact + returns
Key message: Unlike mitigation funds that fight climate change,
adaptation funds benefit from its acceleration, creating a
natural hedge against climate risk.
Returns:
function: Panel depends function that updates with investment parameters
"""
@pn.depends(state.param.temperature, state.param.lp_investment)
def _view(temperature, lp_investment):
"""Generate impact analysis content with climate hedge matrix."""
mult = state.climate_demand_uplift
current = [d["current"] for d in SECTORS.values()]
future = [d["future"] for d in SECTORS.values()]
projected = [c + (f - c) * mult for c, f in zip(current, future)]
total_new_market = sum(p - c for p, c in zip(projected, current))
_, summary, _ = compute_fund_metrics(state.to_engine_json())
# Impact calculations
revenue_per_dollar = 3.0
lives_per_revenue = 0.15
total_lives = lp_investment * revenue_per_dollar * lives_per_revenue / 10.0
# IRR display handling
irr_display = (
f"{summary['NetIRR']:.1f}% IRR"
if summary["NetIRR"] is not None
else "IRR N/A yet"
)
return pmui.Markdown(
f"""
# Climate Impact Investment Analysis
## Fund + Builder Model Impact
**Your Investment**: ${lp_investment:,.0f} in climate adaptation
**Expected Multiple**: {summary["TVPI"]:.2f}x ({irr_display})
**Lives Improved**: {total_lives:,.0f} people (eQALY methodology)
## Market Impact at {temperature:.1f}°C
**Market Acceleration**: {mult:.2f}x faster growth than baseline
**New Market Creation**: +${total_new_market:.0f}B across 5 adaptation sectors
---
### <i class="fas fa-bullseye adaptus-icon"></i>The Adaptation Advantage: Climate Hedge Matrix
| Scenario | Mitigation Funds | **Adaptation Funds** |
|----------|------------------|---------------------|
| Low warming (1.5-2.0°C) | Rebounds | **Solid returns** |
| High warming (2.5-3.5°C) | Slower policy, lags | **Out-performance** |
**Key Insight**: Unlike mitigation funds that **fight** climate change,
adaptation funds **benefit** from its acceleration, creating a natural hedge.
---
### 🚫 What We Won't Do (Investment Discipline)
- Pure carbon-credit arbitrage
- Heavy capex without service spine
- Consumer "green premium" plays
- Policy-only demand with fragile incentives
**Focus**: Only businesses with clear stressor→payer causality and budget authority.
""",
styles={
"background": COLORS["surface"],
"padding": "20px",
"border-radius": "10px",
"border": f"1px solid {COLORS['accent']}",
},
)
return _view
def create_competitive_view():
"""
Create competitive analysis and fund positioning view.
Shows why Adapt[us] is uniquely positioned with:
- Comparative analysis table vs other climate + tech funds
- Key differentiators (2.5°C base case, Fund + Builder model)
- Track record and proven operator credentials
- Fund terms and structure details
This view helps investors understand the competitive landscape
and why Adapt[us] has defensible market positioning.
Returns:
pn.Column: Static competitive analysis with tables and differentiators
"""
comparison_df = pd.DataFrame.from_dict(FUND_COMPARISON, orient="index")
return pmui.Column(
pmui.Markdown(
"""
## Why Adapt[us]?
""",
styles={"font-size": "18px", "font-weight": "bold"},
),
pn.widgets.Tabulator(
comparison_df,
disabled=True,
show_index=False,
sizing_mode="stretch_width",
layout="fit_data_stretch",
height=200,
configuration={
"layout": "fitColumns",
"columnDefaults": {"headerSort": False, "resizable": True},
},
styles={
"background": COLORS["surface"],
"border": f"2px solid {COLORS['primary']}",
"border-radius": "8px",
},
),
pmui.Row(
pmui.Markdown(
"""
### Key Differentiators
- **2.5°C+ Base Case**: We invest where demand grows with inevitable warming
- **Fund + Builder**: Only back what we can help build (40% Series B rate vs 21%)
- **Climate Hedge**: Portfolio out-performs in high warming scenarios
- **Proven Operators**: Darren Clifford (ex-McKinsey), 20+ year track record
""",
styles={
"background": COLORS["surface"],
"padding": "15px",
"border-radius": "8px",
"border": f"1px solid {COLORS['accent']}",
},
),
pmui.Markdown(
"""
### Fund Terms & Timeline
- **Size**: $30M (Pre-seed to Series A)
- **Term**: 15 years + two 2-year extensions
- **Fees**: 2% (years 1-10), 1.5% (extensions)
- **Carry**: 20% over 8% hurdle (European waterfall)
- **Minimum**: $50K, accredited only
- **First Close**: ≥$10M by March 31, 2026
""",
styles={
"background": COLORS["surface"],
"padding": "15px",
"border-radius": "8px",
"border": f"1px solid {COLORS['accent']}",
},
),
sizing_mode="stretch_width",
),
)
# =====================================================================================
# 5. URL STATE MANAGEMENT
# - Scenario sharing via URL query parameters
# - Automatic state persistence and restoration
# - Enables bookmarking and link sharing of specific configurations
# =====================================================================================
def setup_url_persistence():
"""
Setup URL state persistence for scenario sharing and bookmarking.
Enables users to:
- Share specific scenarios via URL
- Bookmark interesting parameter combinations
- Persist state across browser sessions
- Deep-link to specific fund configurations
Watches all state parameters and encodes non-default values
in the URL query string. On page load, restores state from URL.
"""
def _load_url_state() -> None:
"""
Load parameter values from URL query string on page load.
Parses query parameters and restores state for scenario sharing.
Handles buckets_df JSON deserialization and type conversion.
"""
if not pn.state.location:
return
qs = dict(pn.state.location.query_params)
if "buckets_df" in qs:
try:
# Use orjson for faster JSON parsing
buckets_data = orjson.loads(qs.pop("buckets_df"))
state.buckets_df = pd.DataFrame(buckets_data)
except (ValueError, KeyError, orjson.JSONDecodeError):
pass
for k, v in qs.items():
if not hasattr(state, k):
continue
try:
cur_val = getattr(state, k)
if isinstance(cur_val, bool):
setattr(state, k, v.lower() in ("true", "1"))
elif isinstance(cur_val, (int, np.integer)):
setattr(state, k, int(v))
elif isinstance(cur_val, float):
setattr(state, k, float(v))
else:
setattr(state, k, v)
except (ValueError, TypeError, AttributeError):
continue
def _save_url_state(*events) -> None:
"""
Save current parameter values to URL query string when state changes.
Watches for parameter changes and updates browser URL with non-default
values. Enables scenario sharing and bookmarking.
"""
if not pn.state.location:
return
from urllib.parse import urlencode
defaults = UnifiedState()
out: dict[bytes | str, Sequence[object]] = {}
for name in state.param:
if name.startswith("_") or name == "name":
continue
val = getattr(state, name)
if name == "buckets_df":
if not val.equals(defaults.buckets_df):
# Use orjson for faster serialization
out[name] = orjson.dumps(val.to_dict("records")).decode()
elif val != getattr(defaults, name):
out[name] = str(val)
pn.state.location.search = "?" + urlencode(out, doseq=True)
_load_url_state()
for p in state.param:
state.param.watch(_save_url_state, p)
# =====================================================================================
# 6. APPLICATION ASSEMBLY
# - Template creation and content binding
# - Header, sidebar, and main content coordination
# - Material UI template with Adaptus theming
# =====================================================================================
def create_unified_app():
"""
Create the complete unified Adapt[us] investment platform.
Focused interface for LP engagement featuring:
- Climate scenario modeling with market projections
- Complete fund modeling with European waterfall
- Investment pipeline with target companies
- Personal LP returns analysis
- Impact analysis and competitive positioning
"""
# Setup URL persistence
setup_url_persistence()
# Configure component defaults for theme inheritance
pmui.Page.param.theme_config.default = THEME_CONFIG
pn.widgets.Tabulator.param.theme.default = "materialize"
# Create main content with Container pattern for better viewport control
main_tabs = pmui.Tabs(
("CLIMATE THESIS", create_climate_view()),
("FUND MODELING", create_fund_modeling_view()),
("PIPELINE", create_pipeline_view()),
("LP RETURNS", create_returns_view()),
("IMPACT", create_impact_view()),
("COMPETITIVE", create_competitive_view()),
active=0,
theme_config=THEME_CONFIG,
)
# Main content container with proper scrolling and viewport
main_content = pmui.Container(
pmui.Column(create_lp_hero_section(), main_tabs),
width_option="xl",
disable_gutters=True,
sx={
"height": "calc(100vh - 80px)",
"overflow-y": "auto",
"padding": "16px",
"font-family": "'Space Mono', monospace",
"& .bk-root": {"width": "100% !important", "max-width": "none !important"},
},
theme_config=THEME_CONFIG,
)
# Dynamic sidebar with climate control and scenario info
@pn.depends(
state.param.temperature, state.param.lp_investment, state.param.coinvest_enabled
)
def sidebar_content(temperature, investment, coinvest):
"""Generate dynamic sidebar content with climate control."""
return pmui.Column(
pn.Spacer(height=10),
# Climate Controls Section
pmui.Typography(
'<h6><i class="fas fa-thermometer-half adaptus-icon"></i>Climate Scenario</h6>'
),
pmui.Card(
pmui.FloatSlider.from_param(
state.param.temperature,
name="Temperature (°C)",
width=250,
color="primary",
),
theme_config=THEME_CONFIG,
),
pn.Spacer(height=10),
# Investment Controls Section
pmui.Typography(
'<h6><i class="fas fa-dollar-sign adaptus-icon"></i>Your Investment</h6>'
),
pmui.Card(
pmui.NumberInput.from_param(
state.param.lp_investment, name="Commitment ($)", step=500000
),
pmui.Switch.from_param(
state.param.coinvest_enabled, name="Co-Investment Rights"
),
theme_config=THEME_CONFIG,
),
pn.Spacer(height=10),
# Current Scenario Section
pmui.Typography(
'<h6><i class="fas fa-chart-line adaptus-icon"></i>Current Scenario</h6>'
),
pmui.Card(
pmui.Markdown(f"""
**Climate**: {temperature}°C warming
**Investment**: ${investment:,.0f}
**Co-invest**: {"Yes" if coinvest else "No"}
"""),
theme_config=THEME_CONFIG,
),
pn.Spacer(height=10),
# Fund Details Section
pmui.Typography(
'<h6><i class="fas fa-university adaptus-icon"></i>Fund Details</h6>'
),
pmui.Card(
pmui.Markdown("""
**Size**: $30M | **Track Record**: 7x TVPI, 51% IRR
**Terms**: 20% carry, 8% hurdle | **Minimum**: $50K
**Target Close**: $10M by March 2026
"""),
theme_config=THEME_CONFIG,
),
pn.Spacer(height=10),
# Contact Section
pmui.Typography(
'<h6><i class="fas fa-envelope adaptus-icon"></i>Contact</h6>'
),
pmui.Card(
pmui.Markdown("""
**Darren Clifford**
Managing Partner
📧 [dc@aucap.vc](mailto:dc@aucap.vc)
📞 +1 713 373 7324
"""),
theme_config=THEME_CONFIG,
),
width=280,
)
# Assemble complete application with PMUI Page pattern
app = pmui.Page(
title="Adapt[us] Climate Adaptation Investment Platform",
sidebar=[sidebar_content],
main=[main_content],
sidebar_width=320,
theme_toggle=True, # Enable built-in Panel theme toggle
theme_config=THEME_CONFIG,
)
return app
# =====================================================================================
# 7. APPLICATION ENTRY POINT
# - Multiple execution contexts supported
# - Direct Python execution, Panel serve, and module import
# =====================================================================================
# Create and serve the unified application
if __name__ == "__main__":
# Direct Python execution: python app.py
create_unified_app().servable()
elif __name__.startswith("bokeh"):
# Panel serve execution: panel serve app.py
create_unified_app().servable()
else:
# Module import: import app; app.unified_app
unified_app = create_unified_app().servable()
# =====================================================================================
# END OF FILE
#
# Total lines: ~2500
# Key entry points:
# - create_unified_app(): Main application builder
# - UnifiedState: Parameter management
# - compute_fund_metrics(): Financial calculations
# - create_*_view(): Individual tab components
# - ADAPTUS/COLORS: Theme configuration
# =====================================================================================