from __future__ import annotations
import datetime
import logging
from dataclasses import dataclass
from typing import Any, TypedDict
import numpy as np
import pandas as pd
# ===================================================================
# FRAMEWORK-SPECIFIC IMPORTS (Panel/UI Layer)
# ===================================================================
# These imports are only needed for the Panel UI layer.
# The domain layer below has zero dependencies on these.
import panel as pn
import panel_material_ui as pmui
import plotly.graph_objects as go
import plotly.io as pio
from dateutil.relativedelta import relativedelta
# Suppress known panel-material-ui watchfiles bug in development
# This is a library bug where _watching_esm is a bool instead of an event object
# See: https://github.com/panel-extensions/panel-material-ui/issues
_logger = logging.getLogger("panel_material_ui.base")
_logger.setLevel(logging.ERROR) # Suppress AttributeError warnings from _watch_esm
# Suppress "Dropping a patch" warnings from Bokeh - these occur when components
# update rapidly and are harmless but create noise in logs
_bokeh_logger = logging.getLogger("bokeh.server.protocol")
_bokeh_logger.setLevel(logging.ERROR) # Suppress patch dropping warnings
pn.extension("plotly", "tabulator", design="material", notifications=True)
pn.config.sizing_mode = "stretch_width"
# Add CSS to prevent flicker and ensure smooth SPA transitions
pn.config.raw_css.append("""
/* Prevent layout shifts during reactive updates */
.pn-param-function {
min-height: 50px;
will-change: contents;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
/* Smooth opacity transitions - prevent flicker */
.pn-param-function > * {
transition: opacity 0.15s ease-in-out;
opacity: 1;
}
/* Hide loading spinner immediately to prevent flicker on fast updates */
.pn-loading {
min-height: inherit;
opacity: 0;
transition: opacity 0.1s ease-in-out;
pointer-events: none;
}
/* Show loading only after delay */
.pn-loading.pn-loading-active {
opacity: 1;
transition-delay: 0.2s;
}
/* Smooth transitions for cards - prevent visual flicker */
.MuiCard-root {
transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
will-change: transform, box-shadow;
}
/* Stable container heights - prevent layout shifts */
.pn-container {
contain: layout style paint;
}
/* Prevent text selection flicker during updates */
.pn-param-function * {
user-select: none;
-webkit-user-select: none;
}
/* Smooth chart updates */
.plotly {
transition: opacity 0.15s ease-in-out;
}
/* Prevent widget value flicker */
.bk-input, .bk-slider {
transition: none !important;
}
/* Tighter Tabulator table styling */
.tabulator-cell {
padding: 2px 4px !important;
line-height: 1.2 !important;
}
/* Material Icons in Tabulator cells */
.tabulator-cell .material-icons {
font-size: 18px !important;
vertical-align: middle;
}
/* Prevent text wrapping in Tabulator cells */
.tabulator-cell-nowrap {
white-space: nowrap !important;
overflow: hidden;
text-overflow: ellipsis;
}
/* Tighter header styling */
.tabulator-header {
padding: 4px 2px !important;
}
.tabulator-header-cell {
padding: 4px 2px !important;
font-size: 9px !important;
}
""")
# Ensure Material Icons are available (matching app_single.py / brand/mui.py)
if "https://fonts.googleapis.com/css2?family=Material+Icons" not in (
pn.config.css_files or []
):
pn.config.css_files = pn.config.css_files or []
pn.config.css_files.extend(
[
"https://fonts.googleapis.com/css2?family=Material+Icons",
"https://fonts.googleapis.com/css2?family=Material+Icons+Outlined",
]
)
# ===================================================================
# LAYER 1: CONFIG / CONSTANTS / COPY
# ===================================================================
# This layer contains all configuration, constants, and user-facing text.
# Design rules:
# - Only primitives, dicts, lists, dataclasses
# - No Panel types
# - All strings that appear in UI should be constants here
# - To change copy or behavior, edit this section first
# -------------------------------------------------------------------
# Color Palette - Centralized Color Definitions
# -------------------------------------------------------------------
COLORS = {
# Primary brand colors
"primary": "#107580", # Main brand color (teal)
"secondary": "#7B3E7A", # Secondary/accent color (purple)
"accent": "#2DAA9F", # Accent color (light teal)
# Semantic colors
"success": "#1B9B8A", # Success/green
"warning": "#E4A11B", # Warning/orange
"error": "#C23B3B", # Error/red
"info": "#2DAA9F", # Info (same as accent)
# Neutral colors
"background": "#f6f2e7", # Page background
"surface": "#FFFFFF", # Card/surface background
"text": "#222222", # Primary text color
"muted": "#6A6A6A", # Muted/secondary text
}
# -------------------------------------------------------------------
# Widget Option Constants
# -------------------------------------------------------------------
SCENARIO_OPTIONS = ["Cautious", "Base", "Aggressive"]
TEMP_OPTIONS = ["1.5°C", "2.0°C", "2.7°C"]
LP_COMMITMENT_OPTIONS = ["$500K", "$2.5M", "$5M"]
DEAL_CATEGORY_OPTIONS = ["Resilience", "Repair & Recovery", "Demand Adaptation"]
PRODUCT_MODEL_OPTIONS = [
"Hardware",
"Hardware+Software",
"Service",
"Pureplay Software",
]
CAPITAL_PATH_OPTIONS = ["Venture Growth", "Venture Infrastructure", "Seed Strap"]
# -------------------------------------------------------------------
# Scenario Configuration Table
# -------------------------------------------------------------------
# All scenario-specific fund parameters. Used by create_fund_config_from_scenario.
SCENARIO_CONFIGS = {
"Base": {
"fund_size_m": 30.0,
"loss_ratio": 0.25,
"base_exit_multiple": 5.0,
"num_companies": 15,
"initial_check_m": 0.5,
"hurdle_rate": 0.08,
},
"Cautious": {
"fund_size_m": 20.0,
"loss_ratio": 0.35,
"base_exit_multiple": 4.0,
"num_companies": 12,
"initial_check_m": 0.4,
"hurdle_rate": 0.05,
},
"Aggressive": {
"fund_size_m": 50.0,
"loss_ratio": 0.20,
"base_exit_multiple": 6.0,
"num_companies": 15, # Same as base
"initial_check_m": 0.6,
"hurdle_rate": 0.08,
},
}
# -------------------------------------------------------------------
# Scoring Weights
# -------------------------------------------------------------------
SCORING_WEIGHTS = {
"team": 20, # Team (20 pts): Technical & domain strength, execution, grit & mission
"market": 15, # Market (15 pts): Market size & growth, business model strength
"thesis": 25, # Adaptation thesis fit (25 pts): Climate-driven demand, time to pull
"buyer": 25, # Buyer self-interest & traction (25 pts): ROI/payback, traction, tech risk
"moat": 10, # Technical moat (10 pts): Part of market defensibility & competitive advantage
"quality": 5, # Quality of life / human capital (5 pts): Health, safety, livelihoods
}
# -------------------------------------------------------------------
# User-Facing Text Constants
# -------------------------------------------------------------------
# Hero titles, section headings, tooltips, etc.
HERO_TITLES = {
"climate": "$310B Annual Climate Adaptation Finance Gap",
"returns": None, # Dynamic based on model state
"impact": None, # Dynamic based on model state
}
HERO_SUBTITLES = {
"climate": "Communities need infrastructure for a hotter world. Capital has been slower to move into adaptation than mitigation. We back demand adaptation solutions that help communities operate under higher averages, as well as during extreme events. Investing in adaptation is backing the continuity of essential systems.",
"returns": None, # Dynamic
"impact": "Investing in adaptation is backing the continuity of essential systems.",
}
SECTION_TITLES = {
"pipeline": "12 Climate-Proof Investments",
"deal_evaluation": "Deal Evaluation Framework",
"sector_growth": "Sector Growth Trajectory (Billions)",
"adaptation_vs_mitigation": "Adaptation vs Mitigation Positioning",
"fund_comparison": "How We Compare to Climate + Tech Funds",
}
# -------------------------------------------------------------------
# Static Data: Portfolio Companies
# -------------------------------------------------------------------
# This data is used by the domain engine but defined here in config layer
# for easy updates without touching business logic.
_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",
},
]
# -------------------------------------------------------------------
# Static Data: Sectors
# -------------------------------------------------------------------
_sectors_data = [
("Weather Intelligence", 2.5, 12.0),
("Wind-Resistant Building", 15.0, 65.0),
("Flood-Resistant Materials", 8.0, 42.0),
("Fire-Resistant Components", 3.0, 18.0),
("Backup Power Systems", 22.0, 95.0),
]
# -------------------------------------------------------------------
# Static Data: Fund Comparison
# -------------------------------------------------------------------
FUND_COMPARISON = [
{
"name": "Adapt[us]",
"thesis": "Climate Adaptation",
"stage": "Pre-seed to A",
"support": "Fund + Builder",
"feeCarry": "2% | 20% @ 1.8x",
"trackRecord": "5.2x, 21% IRR",
"differentiation": "Only adaptation fund w/ builder. 2.5°C+ base.",
},
{
"name": "Lowercarbon Capital",
"thesis": "Mitigation",
"stage": "Pre-seed to Seed",
"support": "Sci/Ops",
"feeCarry": "2% | 20%",
"trackRecord": "Undisclosed",
"differentiation": "Carbon-neg, Gates backing",
},
{
"name": "Breakthrough Energy",
"thesis": "Mitigation",
"stage": "Early to Growth",
"support": "Tech/Policy",
"feeCarry": "Undisclosed",
"trackRecord": "Impact-first",
"differentiation": "Deep-tech, patient cap",
},
{
"name": "Sequoia Capital",
"thesis": "Tech Growth",
"stage": "Seed to Growth",
"support": "Network/Adv",
"feeCarry": "2.5% | 25%",
"trackRecord": "30%+ IRR",
"differentiation": "Tier-1 brand, unicorns",
},
]
# -------------------------------------------------------------------
# Static Data: References and Methodology
# -------------------------------------------------------------------
REFS_MD = """**Primary Sources:**
- **GIC (2025).** "Sizing the Inevitable Investment Opportunity: Climate Adaptation." ThinkSpace, April 2025.
- Global TAM: $1T → $4T annual revenues by 2050
- Investment value: $2T → $9T (enterprise value)
- $2T incremental from global warming alone
- Only ±4% scenario variation through 2050
- **±4% variation across scenarios → Low policy risk**: Demand varies only ±4% across scenarios (1.4°C to >4°C), indicating low policy risk regardless of climate scenario
- Climate Bonds Initiative. Climate Bonds Resilience Taxonomy (CBRT). 1,400+ adaptation solutions.
- IPCC AR6 (2021). Climate scenarios: SSP1-1.9 (1.4°C), SSP2-4.5 (2.7°C), SSP5-8.5 (>4°C).
**Additional Sources:**
- UNEP Adaptation Gap Report 2023
- World Bank Climate & Development Report 2022
- Munich Re NatCatSERVICE database (climate loss data)
- EM-DAT: 55% of damages from storms, 30% from floods (2000-2024)
**Key Market Insights (GIC 2025):**
- Weather Intelligence: 16x growth to $40B by 2050 (fastest-growing)
- Wind-Resistant Building: $40B → $650B (storms = 55% of climate damages)
- Flood-Resistant Materials: → $680B by 2050 (floods = 30% of losses)
- Adaptation revenues 61% above baseline by 2050 vs. industry forecasts
- Growth accelerates post-2040 as physical risks intensify
**Methodology:**
Climate demand uplift uses GIC 4-factor methodology: frequency × intensity × exposure × passthrough
- Based on GIC finding: 61% revenue uplift at 2.7°C by 2050
- Calibrated to match GIC ThinkSpace (2025) findings
- Sector-specific factors account for varying climate impacts across adaptation markets
**European Waterfall Structure:**
1. Return of capital to LPs (100% until Called Capital returned)
2. LP preferred return to 1.8x MOIC (80% gain on capital)
3. GP catch-up to 20% of profits above hurdle
4. 80/20 split on remaining profits (20% carry to GP)
**Portfolio Construction:**
- Initial check size: $0.5M average
- Follow-on reserve: 1.5x initial check
- Target: 16 companies over 7-year investment period (Excel model)
- Assumes standard venture power law distribution
- Fund life: 10 years total (7 year invest + 3 year harvest)"""
# ===================================================================
# LAYER 2: DOMAIN ENGINE (Panel-Free)
# ===================================================================
# This layer contains pure business logic with ZERO framework dependencies.
# Design rules:
# - Zero imports of pn, pmui, plotly, pio
# - Only depend on: numpy, pandas, datetime, dateutil, logging, builtins
# - All inputs/outputs are plain Python types, dataclasses, or pandas objects
# - No CSS, no styling, no UI concepts
#
# Everything "economics + math" belongs here.
# If you ever replatform, this whole block should move unchanged.
# -------------------------------------------------------------------
# Domain Types (Data Structures)
# -------------------------------------------------------------------
class FundSummaryMetrics(TypedDict):
TVPI: float
DPI: float
RVPI: float
NetIRR: float | None
GrossIRR: float | None
GrossTVPI: float
CarryPaid: float
TotalInvested: float
TotalProceeds: float
TotalFees: float
TotalCarry: float
NetDistributions: float
class FundMetrics(TypedDict):
"""Simplified metrics from VCFundEngine (v4 structure)."""
tvpi: float
dpi: float
rvpi: float
net_irr: float
gross_moic: float
paid_in: float
distributed: float
carry_paid: float
class LPReturnResult(TypedDict):
tvpi: float
irr_pct: float | None
total_invested: float
total_proceeds: float
years: list[int]
lp_cum_k: list[float]
sp_cum_k: list[float]
outperformance_pct: float
class WaterfallBreakdownRow(TypedDict):
year: int
calls: float
fees: float
invest: float
proceeds: float
distPrincipal: float
distPref: float
distLpAfterCarry: float
distCarry: float
netCF: float
cumulativeCalled: float
@dataclass
class FundConfig:
"""
Configuration for the fund run (unified spine from v4).
Internal currency units are in MILLIONS.
LP Commitment is stored in DOLLARS for precise scaling.
"""
# Structure
fund_size_m: float = 30.0
management_fee: float = 0.02
fee_years: int = 10
carry: float = 0.20
hurdle_rate: float = 0.08 # 8% IRR Preferred Return (can be converted to MOIC)
start_date: datetime.date = datetime.date(2026, 1, 1)
# Portfolio Construction
num_companies: int = 15
initial_check_m: float = 0.5
invest_period_years: int = 7
fund_term_years: int = 10 # invest_period_years + harvest_years
# Performance Assumptions
loss_ratio: float = 0.25 # % of companies that return 0
base_exit_multiple: float = 5.0 # blended avg for winners (pre-climate)
climate_uplift_factor: float = 1.0 # Scaler from temperature
# LPs
lp_commitment_usd: float = 1_000_000.0
@dataclass
class CompanyData:
company: str
website: str
stressor: str
pain: str
payer: str
model: str
acceleration: str
@dataclass
class SectorData:
name: str
current: float
future: float
# -------------------------------------------------------------------
# Static Data Instantiation (from Layer 1 config)
# -------------------------------------------------------------------
# Create dataclass instances from Layer 1 config data
TARGET_COMPANIES = [CompanyData(**company) for company in _target_companies_data]
SECTORS = [SectorData(name, current, future) for name, current, future in _sectors_data]
# -------------------------------------------------------------------
# Domain Functions: Pure Business Logic
# -------------------------------------------------------------------
# All functions here are pure: no side effects, no framework dependencies.
# They take simple Python types and return simple Python types.
def xirr(dates, amounts):
"""
Robust XIRR calculation using Newton-Raphson (from v4).
Handles sign change checks correctly to avoid premature 0.0 return.
"""
amounts = np.array(amounts, dtype=float)
# Guard: Need at least one negative and one positive cash flow
if not ((amounts < 0).any() and (amounts > 0).any()):
return 0.0
# Filter out pure zero rows to avoid issues
mask = amounts != 0
if not mask.any():
return 0.0
dates = np.array([pd.to_datetime(d) for d in dates])[mask]
amounts = amounts[mask]
if len(amounts) < 2:
return 0.0
d0 = dates[0]
days = np.array([(d - d0).days for d in dates])
def xnpv(r):
return np.sum(amounts / (1 + r) ** (days / 365.25))
def xnpv_prime(r):
return -np.sum(amounts * (days / 365.25) / (1 + r) ** (days / 365.25 + 1))
r = 0.1
for _ in range(50):
v = xnpv(r)
if abs(v) < 1e-5:
return r
d = xnpv_prime(r)
if d == 0:
return r
r -= v / d
if r <= -1.0:
r = -0.99
return r
# Climate Demand Multiplier (Pure Function)
def climate_demand_multiplier_for_portfolio(temp_c: float) -> float:
"""
Climate demand multiplier calibrated to GIC (2025) methodology.
Calibrated to: 1.61× at 2.7°C (61% revenue uplift per GIC ThinkSpace 2025).
Piecewise linear function with breakpoints at key IPCC scenario temperatures.
Breakpoints (adjustable for future calibration):
- 1.5°C (SSP1-1.9 baseline): 1.00×
- 2.0°C (intermediate): 1.25×
- 2.5°C (intermediate): 1.45×
- 2.7°C (SSP2-4.5, GIC calibration): 1.61×
- 3.5°C (SSP5-8.5 upper range): 2.00×
To adjust breakpoints:
1. Update breakpoint temperatures and values in the if/elif chain
2. Recalculate slopes: slope = (value_end - value_start) / (temp_end - temp_start)
3. Ensure 2.7°C segment hits 1.61× for GIC alignment
4. Verify smooth transitions between segments
"""
# Segment 1: Baseline to 2.0°C (0.5× per °C)
if temp_c <= 1.5:
return 1.0
if temp_c <= 2.0:
return 1.0 + (temp_c - 1.5) * 0.5 # 1.5°C→1.0×, 2.0°C→1.25×
# Segment 2: 2.0°C to 2.5°C (0.4× per °C)
if temp_c <= 2.5:
return 1.25 + (temp_c - 2.0) * 0.4 # 2.0°C→1.25×, 2.5°C→1.45×
# Segment 3: 2.5°C to 2.7°C (0.8× per °C) - calibrated to GIC target
if temp_c <= 2.7:
return 1.45 + (temp_c - 2.5) * 0.8 # 2.5°C→1.45×, 2.7°C→1.61× ✅
# Segment 4: 2.7°C to 3.5°C (0.4875× per °C)
if temp_c <= 3.5:
return 1.61 + (temp_c - 2.7) * 0.4875 # 2.7°C→1.61×, 3.5°C→2.00×
# Cap at 3.5°C for extreme scenarios
return 2.0
# -------------------------------------------------------------------
# Domain Engine: Core Business Logic
# -------------------------------------------------------------------
# VCFundEngine encapsulates all fund modeling logic.
# It has zero dependencies on UI frameworks.
class VCFundEngine:
"""
Core Economic Engine (from v4).
Logic Flow:
1. Generate Portfolio (Deals with explicit Dates, Investment, Proceeds).
2. Generate Ledger (Quarterly Cashflows: Calls, Fees, Distributions).
3. Run Waterfall (Capital -> Pref -> 80/20 Split).
4. Calculate Metrics (TVPI, IRR, etc.).
"""
def __init__(self, config: FundConfig):
self.c = config
self.portfolio_df: pd.DataFrame | None = None
self.cashflow_df: pd.DataFrame | None = None
self.metrics: FundMetrics | None = None
self._run()
def _run(self):
self._generate_portfolio()
self._generate_cashflows()
self._run_waterfall()
def _generate_portfolio(self):
"""Create explicit companies with entry/exit dates and multiples."""
quarters_invest = self.c.invest_period_years * 4
deals = []
# Deterministic RNG for stable demo
rng = np.random.default_rng(42)
# Calculate available capital logic
est_total_fees = self.c.fund_size_m * self.c.management_fee * self.c.fee_years
investable_capital = self.c.fund_size_m - est_total_fees
# Allocation strategy
initial_allocation = self.c.num_companies * self.c.initial_check_m
remaining_for_followon = max(0, investable_capital - initial_allocation)
avg_follow_on = remaining_for_followon / (
self.c.num_companies * 0.5
) # Assume 50% get follow-on
for i in range(self.c.num_companies):
# Entry Date
q_entry = int((i / self.c.num_companies) * quarters_invest)
entry_date = self.c.start_date + relativedelta(months=q_entry * 3)
# Company Info - use TARGET_COMPANIES from v3
name = (
TARGET_COMPANIES[i].company
if i < len(TARGET_COMPANIES)
else f"PortCo {i + 1}"
)
# Outcome Logic
is_loss = i < (self.c.num_companies * self.c.loss_ratio)
invested = self.c.initial_check_m
proceeds = 0.0
exit_date = entry_date + relativedelta(years=rng.integers(4, 8))
if is_loss:
multiple = 0.0
# Small zombie follow-on?
if rng.random() > 0.7 and avg_follow_on > 0:
invested += avg_follow_on * 0.2
else:
# Base Multiple (Lognormal distribution)
raw_multiple = rng.lognormal(
mean=np.log(self.c.base_exit_multiple), sigma=0.8
)
# CLIMATE UPLIFT
final_multiple = raw_multiple * self.c.climate_uplift_factor
# Winners get full follow-on
if avg_follow_on > 0:
invested += avg_follow_on
proceeds = invested * final_multiple
multiple = final_multiple
deals.append(
{
"Company": name,
"Entry Date": entry_date,
"Exit Date": exit_date,
"Invested": invested,
"Proceeds": proceeds,
"MOIC": multiple,
}
)
self.portfolio_df = pd.DataFrame(deals).sort_values("Entry Date")
def _generate_cashflows(self):
"""Convert portfolio deals into a quarterly fund ledger."""
end_date = self.c.start_date + relativedelta(years=self.c.fund_term_years)
quarters = pd.date_range(start=self.c.start_date, end=end_date, freq="QE")
cf = pd.DataFrame(index=quarters)
cf["Calls"] = 0.0
cf["Distributions"] = 0.0
cf["Fees"] = 0.0
# 1. Fees: management_fee% of Fund Size (Committed Capital) / 4
q_fee = (self.c.fund_size_m * self.c.management_fee) / 4.0
for q in quarters:
years_elapsed = (q.date() - self.c.start_date).days / 365.25
if years_elapsed < self.c.fee_years:
cf.loc[q, "Fees"] = q_fee
# 2. Deal Flows
for _, deal in self.portfolio_df.iterrows():
# Map to nearest quarter
entry_idx = quarters[quarters >= pd.Timestamp(deal["Entry Date"])]
if not entry_idx.empty:
cf.loc[entry_idx[0], "Calls"] += deal["Invested"]
exit_idx = quarters[quarters >= pd.Timestamp(deal["Exit Date"])]
if not exit_idx.empty:
cf.loc[exit_idx[0], "Distributions"] += deal["Proceeds"]
# 3. Net Calculation
cf["Total_Out"] = cf["Calls"] + cf["Fees"]
self.cashflow_df = cf
def _run_waterfall(self):
"""
Waterfall Logic:
1. Return of Capital (100% to LP until Paid In returned).
2. Preferred Return (8% annual compounding on called capital).
3. Surplus Split (80% LP / 20% GP on remaining).
"""
cf = self.cashflow_df
cumulative_called = 0.0
cumulative_distributed = 0.0 # Total distributions (LP + GP)
cumulative_lp_dist = 0.0 # Track LP specific for Hurdle check
gp_carry_paid = 0.0
lp_flows = []
for idx, row in cf.iterrows():
call = row["Total_Out"]
dist_available = row["Distributions"]
cumulative_called += call
to_lp = 0.0
to_gp = 0.0
remaining = dist_available
if remaining > 0:
# --- Bucket 1: Return of Capital ---
# How much capital is unreturned?
unreturned_capital = max(0.0, cumulative_called - cumulative_lp_dist)
pay_capital = min(remaining, unreturned_capital)
to_lp += pay_capital
remaining -= pay_capital
cumulative_lp_dist += pay_capital
# --- Bucket 2: Preferred Return (8% IRR proxy) ---
if remaining > 0:
# Estimate accrued hurdle: Called * (1.08 ^ Years)
years = max(0, (idx - cf.index[0]).days / 365.25)
hurdle_target = cumulative_called * (
(1 + self.c.hurdle_rate) ** years
)
# How much pref is still owed?
pref_owed = max(0.0, hurdle_target - cumulative_lp_dist)
pay_pref = min(remaining, pref_owed)
to_lp += pay_pref
remaining -= pay_pref
cumulative_lp_dist += pay_pref
# --- Bucket 3: Surplus Split (80/20) ---
if remaining > 0:
carry = remaining * self.c.carry
lp_share = remaining - carry
to_gp += carry
to_lp += lp_share
remaining = 0
lp_net = to_lp - call
lp_flows.append(lp_net)
gp_carry_paid += to_gp
cumulative_distributed += to_lp + to_gp
cf["LP_Net_Flow"] = lp_flows
# Metrics Aggregation
total_in = cf["Total_Out"].sum()
total_dist = cf["Distributions"].sum()
self.metrics = {
"tvpi": total_dist / total_in if total_in > 0 else 0.0,
"dpi": (total_dist - gp_carry_paid) / total_in if total_in > 0 else 0.0,
"rvpi": 0.0, # Assuming liquidation at term end
"gross_moic": total_dist / cf["Calls"].sum()
if cf["Calls"].sum() > 0
else 0.0,
"net_irr": xirr(cf.index, lp_flows) * 100.0,
"paid_in": total_in,
"distributed": total_dist,
"carry_paid": gp_carry_paid,
}
def get_lp_results(self) -> dict:
"""
Scale fund results to specific LP commitment.
Returns dictionary with 'lp_cumulative' and 'sp_cumulative' series.
"""
# Fund metrics are in Millions. LP Commit is in Dollars.
# Share = (LP Dollars / 1e6) / Fund Millions
share_pct = (self.c.lp_commitment_usd / 1_000_000.0) / self.c.fund_size_m
cf = self.cashflow_df
# Convert Millions -> Dollars for output
lp_net_cf_dollars = cf["LP_Net_Flow"] * 1_000_000.0 * share_pct
lp_cum = lp_net_cf_dollars.cumsum()
# S&P 500 Equivalent (Simple 8% annual)
dates = cf.index
# Simulate S&P calls same as fund calls (in Dollars)
calls_dollars = cf["Total_Out"] * 1_000_000.0 * share_pct
sp_value = 0.0
sp_curve = []
prev_date = dates[0]
for d, call_amt in zip(dates, calls_dollars, strict=False):
days = (d - prev_date).days
# Grow existing balance
sp_value = sp_value * (1.08 ** (days / 365.25))
# Add new contribution
sp_value += call_amt
sp_curve.append(sp_value)
prev_date = d
return {
"dates": dates,
"lp_cumulative": lp_cum,
"sp_cumulative": sp_curve,
"excess_profit_vs_sp": lp_cum.iloc[-1]
- (sp_curve[-1] - calls_dollars.sum()),
}
# ===================================================================
# LAYER 3: VIEW MODEL (Clean API)
# ===================================================================
# This layer provides a clean API between UI and domain engine.
# Design rules:
# - These are the ONLY functions UI code calls
# - UI never calls VCFundEngine directly
# - Inputs: simple types (strings, floats) matching widget values
# - Outputs: TypedDicts, DataFrames, lists - safe to reuse anywhere
# - Read from Layer 1 config constants (SCENARIO_CONFIGS, etc.)
#
# This is the seam where Panel (or any framework) talks to "the model".
# -------------------------------------------------------------------
# Input Parsing Helpers (Framework-Agnostic)
# -------------------------------------------------------------------
# These are simple parsing functions used by view model and UI layers.
def parse_temperature(temp_str: str) -> float:
"""Parse temperature string to float."""
return float(temp_str.replace("°C", ""))
def parse_lp_investment(commit_str: str) -> float:
"""Parse commitment string to investment amount."""
if commit_str == "$500K":
return 500_000.0
if commit_str == "$5M":
return 5_000_000.0
return 2_500_000.0
def create_fund_config_from_scenario(
scenario: str, temp: float, lp_commitment_usd: float
) -> FundConfig:
"""
Create FundConfig from scenario name and parameters.
Uses SCENARIO_CONFIGS from Layer 1 (config layer) to avoid hardcoding.
This is part of the view model API - UI calls this, not VCFundEngine directly.
Args:
scenario: Scenario name (must be in SCENARIO_CONFIGS)
temp: Temperature in Celsius
lp_commitment_usd: LP commitment in USD
Returns:
FundConfig instance ready for VCFundEngine
"""
uplift = climate_demand_multiplier_for_portfolio(temp)
# Get base config from Layer 1
scenario_config = SCENARIO_CONFIGS.get(scenario, SCENARIO_CONFIGS["Base"])
return FundConfig(
fund_size_m=scenario_config["fund_size_m"],
management_fee=0.02,
fee_years=10,
carry=0.20,
hurdle_rate=scenario_config["hurdle_rate"],
start_date=datetime.date(2026, 1, 1),
num_companies=scenario_config["num_companies"],
initial_check_m=scenario_config["initial_check_m"],
invest_period_years=7,
fund_term_years=10,
loss_ratio=scenario_config["loss_ratio"],
base_exit_multiple=scenario_config["base_exit_multiple"],
climate_uplift_factor=uplift,
lp_commitment_usd=lp_commitment_usd,
)
async def compute_lp_returns(
lp_investment: float,
engine: VCFundEngine,
summary: FundSummaryMetrics,
) -> LPReturnResult:
"""Compute LP returns using engine.get_lp_results() (v4 approach)."""
# Update engine config with LP commitment
engine.c.lp_commitment_usd = lp_investment
lp_res = engine.get_lp_results()
# Convert to LPReturnResult format
years = list(range(len(lp_res["dates"])))
return {
"tvpi": summary["TVPI"],
"irr_pct": summary["NetIRR"],
"total_invested": summary["TotalInvested"]
* (lp_investment / (engine.c.fund_size_m * 1_000_000.0)),
"total_proceeds": summary["TotalProceeds"]
* (lp_investment / (engine.c.fund_size_m * 1_000_000.0)),
"years": years,
"lp_cum_k": [v / 1000.0 for v in lp_res["lp_cumulative"].tolist()],
"sp_cum_k": [v / 1000.0 for v in lp_res["sp_cumulative"]],
"outperformance_pct": (
(lp_res["lp_cumulative"].iloc[-1] - lp_res["sp_cumulative"][-1])
/ abs(lp_res["sp_cumulative"][-1])
* 100.0
if lp_res["sp_cumulative"][-1] != 0
else 0.0
),
}
async def get_model_state(
scenario: str, temp_str: str, lp_commitment_usd: float = 2_500_000.0
):
"""
Centralized helper to compute all model state from inputs.
This is the main entry point for UI code. UI should call this function,
not VCFundEngine directly. This function:
1. Parses inputs (strings to floats)
2. Creates FundConfig using create_fund_config_from_scenario
3. Instantiates VCFundEngine
4. Converts engine results to UI-friendly formats
Args:
scenario: Scenario name (from SCENARIO_OPTIONS in Layer 1)
temp_str: Temperature string (from TEMP_OPTIONS in Layer 1)
lp_commitment_usd: LP commitment in USD
Returns:
Tuple of (temp, uplift, engine, main_df, summary, breakdown)
- temp: float temperature
- uplift: float climate multiplier
- engine: VCFundEngine instance
- main_df: DataFrame with annual cashflows
- summary: FundSummaryMetrics dict
- breakdown: list of WaterfallBreakdownRow dicts
"""
temp = parse_temperature(temp_str)
config = create_fund_config_from_scenario(scenario, temp, lp_commitment_usd)
engine = VCFundEngine(config)
# Convert engine metrics to FundSummaryMetrics format
m = engine.metrics
summary: FundSummaryMetrics = {
"TVPI": m["tvpi"],
"DPI": m["dpi"],
"RVPI": m["rvpi"],
"NetIRR": m["net_irr"] if m["net_irr"] != 0.0 else None,
"GrossIRR": None, # Not computed in engine
"GrossTVPI": m["gross_moic"],
"CarryPaid": m["carry_paid"],
"TotalInvested": engine.cashflow_df["Calls"].sum(),
"TotalProceeds": m["distributed"],
"TotalFees": engine.cashflow_df["Fees"].sum(),
"TotalCarry": m["carry_paid"],
"NetDistributions": m["distributed"] - m["carry_paid"],
}
# Create breakdown from quarterly cashflows (aggregate to annual)
cf = engine.cashflow_df
gross_cf = []
net_cf = []
breakdown: list[WaterfallBreakdownRow] = []
# Aggregate quarterly to annual
annual_cf = cf.resample("YE").agg(
{
"Calls": "sum",
"Fees": "sum",
"Distributions": "sum",
"Total_Out": "sum",
"LP_Net_Flow": "sum",
}
)
cumulative_called = 0.0
total_lp_dist = 0.0
total_carry_paid = 0.0
for y, (_idx, row) in enumerate(annual_cf.iterrows()):
calls = row["Calls"]
fees = row["Fees"]
proceeds = row["Distributions"]
total_out = row["Total_Out"]
lp_net = row["LP_Net_Flow"]
cumulative_called += total_out
# Estimate distributions (simplified from waterfall)
dist_principal = max(0.0, min(proceeds, cumulative_called - total_lp_dist))
total_lp_dist += dist_principal
remaining = proceeds - dist_principal
dist_pref = 0.0
dist_carry = 0.0
dist_lp_profit = 0.0
if remaining > 0:
# Simplified hurdle calculation
years_elapsed = y + 1
hurdle_target = cumulative_called * (1.08 ** (years_elapsed / 10.0))
pref_owed = max(0.0, hurdle_target - total_lp_dist)
dist_pref = min(remaining, pref_owed)
total_lp_dist += dist_pref
remaining -= dist_pref
if remaining > 0:
profit_above = max(0.0, total_lp_dist + remaining - hurdle_target)
dist_carry = min(remaining, profit_above * 0.2)
total_carry_paid += dist_carry
remaining -= dist_carry
dist_lp_profit = remaining
total_lp_dist += remaining
gross_cf.append(-total_out + proceeds)
net_cf.append(lp_net)
breakdown.append(
{
"year": y,
"calls": total_out,
"fees": fees,
"invest": calls,
"proceeds": proceeds,
"distPrincipal": dist_principal,
"distPref": dist_pref,
"distLpAfterCarry": dist_lp_profit,
"distCarry": dist_carry,
"netCF": lp_net,
"cumulativeCalled": cumulative_called,
}
)
main_df = pd.DataFrame(
{
"Year": list(range(len(gross_cf))),
"GrossCF": gross_cf,
"NetCF": net_cf,
"CumulativeNet": np.cumsum(net_cf),
}
)
return (
temp,
config.climate_uplift_factor,
engine,
main_df,
summary,
breakdown,
)
# ===================================================================
# LAYER 4: PANEL UI (Thin Presentation Layer)
# ===================================================================
# This layer contains Panel-specific UI code.
# To port to another framework, replace this entire section.
#
# Framework Porting Guide:
# - Panel widgets (pmui.*, pn.widgets.*) → Your framework's widgets
# - Panel reactive decorators (@pn.depends) → Your framework's reactivity
# - Panel layout (pmui.Column, pmui.Row) → Your framework's layout
# - Plotly charts (pn.pane.Plotly) → Your framework's chart component
#
# The domain layer (Layer 2) and view model (Layer 3) provide all business logic.
# This layer only wires UI components to the view model API.
# -------------------------------------------------------------------
# 4a. Panel Bootstrap & Configuration
# -------------------------------------------------------------------
# Color Helper Functions (Panel-specific, uses Layer 1 COLORS)
def get_color(name: str) -> str:
"""
Get color value by name from centralized COLORS dictionary (Layer 1).
Args:
name: Color name (e.g., "primary", "accent", "success")
Returns:
Hex color string (e.g., "#107580")
"""
return COLORS.get(name, COLORS["primary"])
def hex_to_rgba(hex_color: str, alpha: float = 1.0) -> str:
"""
Convert hex color to rgba string with specified alpha.
Args:
hex_color: Hex color string (e.g., "#107580" or "107580")
alpha: Alpha value between 0.0 and 1.0
Returns:
RGBA color string (e.g., "rgba(16, 117, 128, 0.3)")
"""
hex_color = hex_color.lstrip("#")
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
return f"rgba({r}, {g}, {b}, {alpha})"
# Plotly Configuration (Panel-specific)
tpl = pio.templates["plotly_white"]
tpl.layout = tpl.layout.update(
font={"family": "Roboto, sans-serif", "size": 12},
colorway=[
get_color("accent"),
get_color("muted"),
get_color("success"),
get_color("warning"),
get_color("error"),
],
paper_bgcolor=get_color("surface"),
plot_bgcolor=get_color("surface"),
margin={"t": 40, "r": 20, "b": 40, "l": 50},
)
pio.templates["adaptus_light"] = tpl
pio.templates.default = "adaptus_light"
PLOTLY_CONFIG = {
"displayModeBar": "hover",
"displaylogo": False,
"modeBarButtonsToRemove": ["lasso2d", "select2d"],
"responsive": True,
}
# -------------------------------------------------------------------
# 4b. Widget Definitions
# -------------------------------------------------------------------
# All global widgets in one section, using Layer 1 constants for options.
scenario_radio = pmui.RadioButtonGroup(
name="Scenario",
options=SCENARIO_OPTIONS, # From Layer 1
value="Base",
)
scenario_radio.sizing_mode = "stretch_width"
temp_radio = pmui.RadioButtonGroup(
name="Global Warming",
options=TEMP_OPTIONS, # From Layer 1
value="2.0°C",
)
temp_radio.sizing_mode = "stretch_width"
commit_radio = pmui.RadioButtonGroup(
name="LP Commitment",
options=LP_COMMITMENT_OPTIONS, # From Layer 1
value="$2.5M",
)
commit_radio.sizing_mode = "stretch_width"
# Deal scorer widgets
category_select = pmui.Select(
name="Category",
options=DEAL_CATEGORY_OPTIONS, # From Layer 1
value="Demand Adaptation",
)
model_select = pmui.Select(
name="Product Model",
options=PRODUCT_MODEL_OPTIONS, # From Layer 1
value="Hardware+Software",
)
path_select = pmui.Select(
name="Capital Path",
options=CAPITAL_PATH_OPTIONS, # From Layer 1
value="Venture Infrastructure",
)
lever_strategy = pmui.Switch(label="Strategy", value=False)
lever_gtm = pmui.Switch(label="GTM", value=False)
lever_ai = pmui.Switch(label="AI", value=False)
score_sliders = {
"team": pn.widgets.IntSlider(name="Team", start=1, end=5, step=1, value=4),
"market": pn.widgets.IntSlider(name="Market", start=1, end=5, step=1, value=4),
"thesis": pn.widgets.IntSlider(
name="Adaptation Fit", start=1, end=5, step=1, value=5
),
"buyer": pn.widgets.IntSlider(
name="Buyer & Traction", start=1, end=5, step=1, value=4
),
"moat": pn.widgets.IntSlider(
name="Technical Moat", start=1, end=5, step=1, value=3
),
"quality": pn.widgets.IntSlider(
name="Quality of Life", start=1, end=5, step=1, value=3
),
}
# Note: SCORING_WEIGHTS is defined in Layer 1 (config layer)
# -------------------------------------------------------------------
# 4c. UI Component Helpers
# -------------------------------------------------------------------
# Reusable Panel UI components. For other frameworks, replace with equivalents.
def HeroCard(title: str, subtitle: str, color1: str, color2: str) -> pmui.Card:
return pmui.Card(
pmui.Column(
pmui.Typography(
title, variant="h4", sx={"fontWeight": 700, "mb": 1, "color": "white"}
),
pmui.Typography(
subtitle, variant="h6", sx={"opacity": 0.9, "color": "white"}
),
sx={"py": 4},
),
sx={
"background": f"linear-gradient(135deg, {color1} 0%, {color2} 100%)",
"mb": 3,
"borderRadius": 2,
"boxShadow": 2,
},
)
def KPICard(
label: str, value: str, subtext: str | None = None, tooltip: str | None = None
) -> pmui.Card:
"""Create a compact, subtle KPI card."""
# Build label row with optional tooltip
label_items = [
pmui.Typography(
label,
variant="caption",
sx={
"color": "text.secondary",
"fontWeight": 400,
"fontSize": "0.7rem",
},
)
]
if tooltip:
label_items.append(pn.widgets.TooltipIcon(value=tooltip, margin=(0, 2)))
label_row = pmui.Row(
*label_items,
sx={"alignItems": "center", "mb": 0.25, "gap": 0.5},
)
# Value display - smaller and more subtle
value_component = pmui.Typography(
value,
variant="h6",
sx={"fontWeight": 600, "lineHeight": 1.1, "mb": 0.25 if subtext else 0},
)
# Build content column
content = [label_row, value_component]
if subtext:
content.append(
pmui.Typography(
subtext,
variant="caption",
sx={"color": "text.secondary", "lineHeight": 1.2, "fontSize": "0.7rem"},
)
)
return pmui.Card(
pmui.Column(*content, sx={"alignItems": "flex-start"}),
variant="outlined",
sx={
"flex": 1,
"borderRadius": 1,
"boxShadow": "none",
"p": 0.75,
"display": "flex",
"flexDirection": "column",
"minHeight": "auto",
"borderColor": "divider",
},
)
def styled_plotly(fig: go.Figure, height=320) -> pn.pane.Plotly:
fig.update_layout(
template="adaptus_light",
margin={"t": 40, "r": 20, "b": 40, "l": 50},
font={"size": 12, "family": "Roboto, sans-serif"},
hovermode="x unified" if isinstance(fig, go.Figure) else "closest",
)
return pn.pane.Plotly(
fig, height=height, sizing_mode="stretch_width", config=PLOTLY_CONFIG
)
def create_combined_fund_charts(
breakdown: list[dict[str, Any]], summary: FundSummaryMetrics, main_df: pd.DataFrame
) -> pn.pane.Plotly:
"""Create combined figure with annual net CF, J-curve, and waterfall charts.
Layout: 2 plots above (side by side), 1 wide plot below.
"""
from plotly.subplots import make_subplots
# Create subplot figure: 2 rows, 2 cols
# Top row: 2 plots side by side
# Bottom row: 1 plot spanning both columns
fig = make_subplots(
rows=2,
cols=2,
subplot_titles=(
"Annual Net CF",
"Cumulative J-Curve",
"Waterfall Breakdown: Gross → Net DPI",
),
row_heights=[0.4, 0.6], # Top row 40%, bottom row 60%
column_widths=[0.5, 0.5], # Equal width columns
specs=[
[{"type": "bar"}, {"type": "scatter"}], # Top row: bar and line
[
{"type": "bar", "colspan": 2},
None,
], # Bottom row: waterfall spans both columns
],
vertical_spacing=0.15,
horizontal_spacing=0.1,
)
# 1. Annual Net CF chart (top left)
fig.add_trace(
go.Bar(
x=main_df["Year"],
y=main_df["NetCF"],
name="Net CF",
marker_color=get_color("primary"),
hovertemplate="Year %{x}<br>Net CF: $ %{y:.2f}M<extra></extra>",
showlegend=False,
),
row=1,
col=1,
)
# 2. Cumulative J-Curve chart (top right)
fig.add_trace(
go.Scatter(
x=main_df["Year"],
y=main_df["CumulativeNet"],
mode="lines+markers",
name="Cumulative",
line={"color": get_color("accent"), "width": 2},
marker={"size": 6},
hovertemplate="Year %{x}<br>Cumulative: $ %{y:.2f}M<extra></extra>",
showlegend=False,
),
row=1,
col=2,
)
# Add horizontal line at y=0 for J-curve
fig.add_hline(
y=0,
line_dash="dash",
line_color=get_color("muted"),
row=1,
col=2,
)
# 3. Waterfall chart (bottom, full width) - proper waterfall with cascading bars
breakdown_df = pd.DataFrame(breakdown)
return_of_capital = breakdown_df["distPrincipal"].sum()
hurdle_payment = breakdown_df["distPref"].sum()
carry_paid = summary.get("TotalCarry", 0)
lp_profit = breakdown_df["distLpAfterCarry"].sum()
gross_proceeds = summary["TotalProceeds"]
fees_estimate = summary["TotalFees"]
x_labels = [
"Gross Proceeds",
"Management Fees",
"Return of Capital",
"LP Hurdle (1.8x)",
"LP Profit Share",
"GP Carry (20%)",
]
y_values = [
gross_proceeds,
-fees_estimate,
-return_of_capital,
-hurdle_payment,
lp_profit,
-carry_paid,
]
# Measure: "absolute" for first value (starting point), "relative" for changes
measure = ["absolute", "relative", "relative", "relative", "relative", "relative"]
fig.add_trace(
go.Waterfall(
orientation="v",
measure=measure,
x=x_labels,
y=y_values,
textposition="outside",
text=[f"${abs(v):.2f}M" if abs(v) >= 0.01 else "" for v in y_values],
connector={"line": {"color": get_color("muted"), "width": 2}},
increasing={"marker": {"color": get_color("success")}},
decreasing={"marker": {"color": get_color("error")}},
totals={"marker": {"color": get_color("primary")}},
hovertemplate="%{x}<br>Value: $ %{y:.2f}M<extra></extra>",
showlegend=False,
),
row=2,
col=1,
)
# Update axes labels
fig.update_xaxes(title_text="Year", row=1, col=1)
fig.update_yaxes(title_text="$M", row=1, col=1)
fig.update_xaxes(title_text="Year", row=1, col=2)
fig.update_yaxes(title_text="$M", row=1, col=2)
fig.update_xaxes(title_text="", row=2, col=1, tickangle=-45)
fig.update_yaxes(
title_text="$M",
row=2,
col=1,
range=None, # Auto range with padding
)
# Calculate cumulative positions for waterfall to determine proper range
cumulative = 0
positions = [cumulative]
for val in y_values:
cumulative += val
positions.append(cumulative)
max_pos = max(positions) if positions else 100
min_pos = min(positions) if positions else 0
# Update layout with increased top margin for waterfall text
fig.update_layout(
template="adaptus_light",
font={"size": 12, "family": "Roboto, sans-serif"},
height=700, # Total height for combined figure
margin={"t": 80, "b": 100, "l": 50, "r": 20}, # Increased top margin for text
showlegend=False,
)
# Add padding to waterfall y-axis to prevent text cutoff (15% padding at top)
fig.update_yaxes(
range=[min(min_pos * 1.1, 0), max_pos * 1.15],
row=2,
col=1,
)
return pn.pane.Plotly(
fig, height=700, sizing_mode="stretch_width", config=PLOTLY_CONFIG
)
def make_tabulator(
value: pd.DataFrame,
height: int,
*,
columns: dict | None = None,
styles: dict | None = None,
config_overrides: dict | None = None,
disabled: bool = True,
) -> pn.widgets.Tabulator:
"""
Factory function for creating consistently styled Tabulator widgets.
Matches the implementation from app_single.py competitive view.
Args:
value: DataFrame to display
height: Table height in pixels
columns: Optional column configuration dict
styles: Optional style overrides dict
config_overrides: Optional configuration overrides dict
disabled: Whether table is disabled (default True)
Returns:
Configured Tabulator widget
"""
base_config = {
"layout": "fitDataFill", # Scale content to fit all columns
"responsiveLayout": False, # Disable responsive collapse to show all columns
"rowHeight": 25, # Very tight row height (default is ~40px, this makes ~2x rows visible)
"columnDefaults": {
"headerSort": False,
"resizable": True,
"formatter": "plaintext",
"cssClass": "tabulator-cell-nowrap", # Prevent text wrapping
},
}
if config_overrides:
# Deep merge: update nested dicts properly
merged_config = base_config.copy()
for key, val in config_overrides.items():
if key == "columnDefaults" and isinstance(val, dict):
merged_config["columnDefaults"].update(val)
else:
merged_config[key] = val
else:
merged_config = base_config
base_styles = {
"background": get_color("surface"),
"border": f"2px solid {get_color('primary')}",
"borderRadius": "8px",
"fontSize": "9px", # Even smaller font for tighter display
"whiteSpace": "nowrap", # Prevent text wrapping
"overflowX": "auto", # Allow horizontal scroll if needed
"padding": "2px 4px", # Very tight padding (vertical 2px, horizontal 4px)
}
merged_styles = base_styles.copy()
if styles:
merged_styles.update(styles)
return pn.widgets.Tabulator(
value=value,
show_index=False,
disabled=disabled,
height=height,
theme="materialize",
configuration=merged_config,
styles=merged_styles,
)
# -------------------------------------------------------------------
# Sidebar: Controls with Fund Terms and Contact
# -------------------------------------------------------------------
# Reactive Climate Impact section (simplified for sidebar)
@pn.depends(temp_radio.param.value)
def _climate_impact_section(temp_str: str) -> pmui.Column:
"""Create climate impact section for sidebar - chips with values, no green card."""
temp = parse_temperature(temp_str)
uplift = climate_demand_multiplier_for_portfolio(temp)
return pmui.Column(
pmui.Row(
pmui.Chip(
object=f"{temp:.1f}°C",
icon="thermostat",
color="warning",
variant="filled",
),
pmui.Chip(
object="Climate Impact",
icon="eco",
color="success",
variant="outlined",
size="small",
),
sx={"gap": 1, "mb": 2, "flexWrap": "wrap"},
),
pmui.Column(
pmui.Row(
pmui.Column(
pmui.Row(
pmui.Typography(
"Demand Uplift", variant="caption", sx={"mb": 0.5}
),
sx={"alignItems": "center", "justifyContent": "center"},
),
pmui.Typography(
f"{uplift:.2f}×",
variant="h5",
sx={"fontWeight": 700},
),
),
pmui.Column(
pmui.Row(
pmui.Typography(
"Portfolio Value", variant="caption", sx={"mb": 0.5}
),
pn.widgets.TooltipIcon(
value=f"Derived from GIC 4-factor climate model, function of temperature only. Demand multiplier calculated using GIC 4-factor methodology: frequency × intensity × exposure × passthrough. Calibrated to match GIC finding of 61% revenue uplift at 2.7°C by 2050. Based on GIC ThinkSpace (2025) 'Sizing the Inevitable Investment Opportunity: Climate Adaptation.' Portfolio value impact from climate demand uplift. {uplift:.2f}× represents a +{(uplift - 1.0) * 100:.0f}% increase in portfolio value due to climate impacts.",
margin=(0, 4),
),
sx={"alignItems": "center", "justifyContent": "center"},
),
pmui.Typography(
f"+{(uplift - 1.0) * 100:.0f}%",
variant="h5",
sx={"fontWeight": 700, "color": "success.main"},
),
),
sx={"gap": 2, "justifyContent": "space-between"},
),
sx={"mb": 2},
),
)
# Stable Climate Impact section pane
_climate_impact_section_pane = pn.pane.ParamFunction(
_climate_impact_section,
loading_indicator=False,
defer_load=False,
)
# Reactive Fund Terms based on scenario
@pn.depends(scenario_radio.param.value)
def _fund_terms_menu(scenario: str) -> pmui.MenuList:
"""Create Fund Terms MenuList with icons, reactive to scenario."""
# Use default temp and LP commitment for display purposes
config = create_fund_config_from_scenario(scenario, 2.0, 2_500_000.0)
harvest_years = config.fund_term_years - config.invest_period_years
# Convert hurdle_rate (IRR) to approximate MOIC for display
# 8% IRR over ~7 years ≈ 1.8x MOIC, 5% ≈ 1.4x
hurdle_moic = 1.8 if config.hurdle_rate >= 0.08 else 1.4
return pmui.MenuList(
items=[
{
"label": "Fund Size",
"secondary": f"${config.fund_size_m:.0f}M",
"icon": "account_balance",
"selectable": False,
},
{
"label": "Investment Period",
"secondary": f"{config.invest_period_years}y invest | {harvest_years}y harvest",
"icon": "schedule",
"selectable": False,
},
{
"label": "Management Fees",
"secondary": f"{config.management_fee * 100:.1f}%",
"icon": "payments",
"selectable": False,
},
{
"label": "Carried Interest",
"secondary": f"{config.carry * 100:.0f}% over {hurdle_moic:.1f}× hurdle",
"icon": "trending_up",
"selectable": False,
},
],
dense=True,
)
# Static Contact block (matches app.py)
_contact_block = pmui.Column(
pmui.Typography("Contact", variant="h6", sx={"mt": 3}),
pmui.Typography("Darren Clifford", variant="subtitle1", sx={"fontWeight": 600}),
pmui.MenuList(
items=[
{"label": "dc@aucap.vc", "icon": "email", "selectable": False},
{
"label": "+1 713 373 7324",
"icon": "phone",
"selectable": False,
},
],
dense=True,
),
pmui.Button(
label="Schedule a Call",
icon="calendar_today",
variant="contained",
color="primary",
href="mailto:dc@aucap.vc?subject=Adaptus Investment Discussion",
sx={"mt": 2},
),
pmui.Typography(
"Contact us to discuss your investment. We're seeking aligned LPs who understand climate acceleration creates predictable, budget-backed demand.",
variant="caption",
sx={"fontStyle": "italic", "mt": 2},
),
)
# Stable Fund Terms pane
_fund_terms_pane = pn.pane.ParamFunction(
_fund_terms_menu,
loading_indicator=False,
defer_load=False,
)
sidebar_controls = pmui.Column(
pmui.Typography("Scenario Presets", variant="h6"),
scenario_radio,
pmui.Typography(
"Presets compute fund structure, portfolio, fees, and carry. Temperature is independent.",
variant="body2",
sx={"fontStyle": "italic", "mb": 2},
),
_climate_impact_section_pane,
pmui.Divider(sx={"my": 2}),
pmui.Column(
pmui.Typography(
"GLOBAL WARMING",
variant="caption",
sx={
"fontSize": "0.75rem",
"fontWeight": 600,
"textTransform": "uppercase",
"mb": 1,
},
),
temp_radio,
sx={"mb": 3},
),
pmui.Column(
pmui.Typography(
"LP COMMITMENT",
variant="caption",
sx={
"fontSize": "0.75rem",
"fontWeight": 600,
"textTransform": "uppercase",
"mb": 1,
},
),
commit_radio,
sx={"mb": 3},
),
pmui.Divider(sx={"my": 2}),
pmui.Typography("Fund Terms", variant="h6", sx={"mt": 2}),
_fund_terms_pane,
_contact_block,
sx={"gap": 2},
)
# -------------------------------------------------------------------
# Tab 1: Climate Thesis
# -------------------------------------------------------------------
# Static hero card (doesn't need to be reactive)
# Uses Layer 1 constants for text
_climate_hero = HeroCard(
HERO_TITLES["climate"],
HERO_SUBTITLES["climate"],
get_color("primary"),
get_color("accent"),
)
# Reactive components for climate thesis tab
@pn.depends(temp_radio.param.value)
def _climate_scenario_cards(temp_str: str) -> pmui.Row:
"""Only the scenario cards need to update when temp changes."""
temp = parse_temperature(temp_str)
uplift = climate_demand_multiplier_for_portfolio(temp)
return pmui.Row(
pmui.Card(
pmui.Column(
pmui.Typography(
"SCENARIO",
variant="caption",
sx={"color": "text.secondary", "mb": 0.5},
),
pmui.Typography(f"{temp:.1f}°C", variant="h4", sx={"fontWeight": 700}),
pmui.Typography(
"Current trajectory implies significant volatility.",
variant="body2",
sx={"mt": 1},
),
),
variant="outlined",
sx={
"background": get_color("surface"),
"boxShadow": "none",
"borderRadius": 1,
"p": 2,
"flex": 1,
},
),
pmui.Card(
pmui.Column(
pmui.Typography(
"DEMAND MULTIPLIER",
variant="caption",
sx={"color": "text.secondary", "mb": 0.5},
),
pmui.Typography(f"{uplift:.2f}×", variant="h4", sx={"fontWeight": 700}),
pmui.Typography(
"Portfolio-wide demand uplift vs 2024 baseline.",
variant="body2",
sx={"mt": 1},
),
),
variant="outlined",
sx={
"background": get_color("surface"),
"boxShadow": "none",
"borderRadius": 1,
"p": 2,
"flex": 1,
},
),
pmui.Card(
pmui.Column(
pmui.Typography(
"THESIS",
variant="caption",
sx={"color": "text.secondary", "mb": 0.5},
),
pmui.Typography(
"Clear Problem",
variant="body2",
sx={"fontWeight": 700, "mt": 1},
),
pmui.Typography(
"keyboard_arrow_down",
sx={
"textAlign": "center",
"my": 0.5,
"color": "text.secondary",
"fontFamily": "Material Icons",
"fontSize": "24px",
},
),
pmui.Typography(
"Clear Payer",
variant="body2",
sx={"fontWeight": 700},
),
),
variant="outlined",
sx={
"background": get_color("surface"),
"boxShadow": "none",
"borderRadius": 1,
"p": 2,
"flex": 1,
},
),
sx={"gap": 2},
)
@pn.depends(
category_select.param.value,
model_select.param.value,
path_select.param.value,
lever_strategy.param.value,
lever_gtm.param.value,
lever_ai.param.value,
*(s.param.value for s in score_sliders.values()),
)
def _climate_deal_recommendation(
category: str,
model: str,
path: str,
lever_strategy_val: bool,
lever_gtm_val: bool,
lever_ai_val: bool,
*score_values: int,
) -> pmui.Card:
"""Reactive function that takes widget values as explicit arguments."""
# Map score values to their keys
score_keys = list(score_sliders.keys())
scores = dict(zip(score_keys, score_values, strict=False))
# Calculate weighted score (0-100 scale)
weighted_total = sum(
(scores[key] / 5.0) * SCORING_WEIGHTS[key] for key in score_keys
)
weighted_total = round(weighted_total)
# Must-pass criteria (expanded from v2)
must_pass_category = category == "Demand Adaptation"
must_pass_model = model != "Pureplay Software" # Not pureplay software
must_pass_path = path != "Venture Growth" # Not venture growth profile
lever_count = sum([lever_strategy_val, lever_gtm_val, lever_ai_val])
must_pass_levers = lever_count >= 2
all_must_pass = all(
[
must_pass_category,
must_pass_model,
must_pass_path,
must_pass_levers,
]
)
if not all_must_pass:
if not must_pass_category:
recommendation = "OUTSIDE FOCUS"
elif not must_pass_model or not must_pass_path:
recommendation = "WEAK FIT"
else:
recommendation = "WEAK FIT"
elif weighted_total >= 75:
recommendation = "STRONG YES"
elif weighted_total >= 60:
recommendation = "YES"
elif weighted_total >= 45:
recommendation = "MAYBE"
else:
recommendation = "NO"
rec_color = get_color("error")
if "YES" in recommendation:
rec_color = get_color("success")
elif recommendation == "MAYBE":
rec_color = get_color("warning")
return pmui.Column(
pmui.Typography(
"RECOMMENDATION",
variant="overline",
sx={"opacity": 0.7, "mb": 1.5, "letterSpacing": 0.5},
),
pmui.Typography(
recommendation,
variant="h4",
sx={"mb": 1, "fontWeight": 700, "color": rec_color},
),
pmui.Typography(
f"{weighted_total} / 100",
variant="h6",
sx={"mb": 2.5, "opacity": 0.8},
),
pmui.Divider(sx={"my": 2}),
pmui.Column(
pmui.Chip(
object="Demand Adaptation",
icon="check_circle" if must_pass_category else "cancel",
color="success" if must_pass_category else "error",
size="small",
variant="outlined",
sx={"mb": 1, "width": "100%", "justifyContent": "flex-start"},
),
pmui.Chip(
object="Not pureplay software",
icon="check_circle" if must_pass_model else "cancel",
color="success" if must_pass_model else "error",
size="small",
variant="outlined",
sx={"mb": 1, "width": "100%", "justifyContent": "flex-start"},
),
pmui.Chip(
object="Not venture growth",
icon="check_circle" if must_pass_path else "cancel",
color="success" if must_pass_path else "error",
size="small",
variant="outlined",
sx={"mb": 1, "width": "100%", "justifyContent": "flex-start"},
),
pmui.Chip(
object="Builder fit (2+ levers)",
icon="check_circle" if must_pass_levers else "cancel",
color="success" if must_pass_levers else "error",
size="small",
variant="outlined",
sx={"width": "100%", "justifyContent": "flex-start"},
),
sx={"width": "100%"},
),
sx={"width": "100%"},
)
# -------------------------------------------------------------------
# 4d. Reactive Functions
# -------------------------------------------------------------------
# All @pn.depends functions. Each should:
# 1. Read widget values
# 2. Call view model function(s) once (Layer 3)
# 3. Slice needed data from returned state
# 4. Return Panel layout components
# No domain logic here - all business logic is in Layers 2-3.
# -------------------------------------------------------------------
# 4e. Tab Functions
# -------------------------------------------------------------------
# These assemble reactive components into tab layouts.
def climate_thesis_tab() -> pmui.Column:
"""Main tab structure - mostly static, with reactive components inserted."""
# Use ParamFunction wrappers for stable containers
deal_rec_pane = pn.pane.ParamFunction(
_climate_deal_recommendation,
loading_indicator=False,
defer_load=False,
)
deal_rec_pane.sizing_mode = "stretch_both"
deal_rec_pane.width = 280 # Fixed width for recommendation card
# Static framework card
framework = pmui.Card(
pmui.Typography(
"Three Ways to Invest in Climate Adaptation",
variant="subtitle1",
sx={"mb": 2, "fontWeight": 600},
),
pmui.Row(
pmui.Card(
pmui.Typography(
"1. Resilience (preparing before events)",
variant="subtitle1",
sx={"color": "text.secondary", "fontWeight": 600},
),
pmui.Typography(
"Loss-avoidance business case, often public budgets.",
variant="caption",
),
variant="outlined",
sx={"boxShadow": "none", "borderRadius": 8, "flex": 1, "p": 1.5},
),
pmui.Card(
pmui.Typography(
"2. Repair & Recovery (responding after events)",
variant="subtitle1",
sx={"color": "text.secondary", "fontWeight": 600},
),
pmui.Typography(
"Intermittent revenue, harder to underwrite.",
variant="caption",
),
variant="outlined",
sx={"boxShadow": "none", "borderRadius": 8, "flex": 1, "p": 1.5},
),
pmui.Card(
pmui.Typography(
"3. Demand Adaptation (adjusting to higher averages)",
variant="subtitle1",
sx={"color": get_color("primary"), "fontWeight": 700},
),
pmui.Typography(
"Our focus: infrastructure and services that help communities operate in a hotter, drier, or wetter normal.",
variant="caption",
),
variant="outlined",
sx={
"background": "rgba(34, 197, 94, 0.1)", # success.main with opacity
"borderColor": get_color("success"),
"boxShadow": "none",
"borderRadius": 8,
"flex": 1,
"p": 1.5,
},
),
sx={"gap": 2, "flexWrap": "wrap"},
),
sx={"boxShadow": 2, "borderRadius": 8, "p": 2},
)
# Deal evaluator form - Material UI layout system
# Structure: Card > Row > [Column (form), Column (recommendation)]
deal_form = pmui.Card(
pmui.Row(
# Left Column: Form inputs
pmui.Column(
# Row 1: Category and Product Model
pmui.Row(
pmui.Column(
pmui.Row(
pmui.Typography(
"Category",
variant="caption",
sx={"fontWeight": 600},
),
pn.widgets.TooltipIcon(
value="Must be 'Demand Adaptation' to pass. Resilience and Repair & Recovery are outside our focus.",
margin=(0, 4),
),
sx={"alignItems": "center", "mb": 1},
),
category_select,
sx={"flex": 1},
),
pmui.Column(
pmui.Row(
pmui.Typography(
"Product Model",
variant="caption",
sx={"fontWeight": 600},
),
pn.widgets.TooltipIcon(
value="Pureplay Software is excluded. We prefer Hardware, Hardware+Software, or Service models.",
margin=(0, 4),
),
sx={"alignItems": "center", "mb": 1},
),
model_select,
sx={"flex": 1},
),
sx={"gap": 2, "mb": 3},
),
# Row 2: Capital Path and Builder Levers
pmui.Row(
pmui.Column(
pmui.Row(
pmui.Typography(
"Capital Path",
variant="caption",
sx={"fontWeight": 600},
),
pn.widgets.TooltipIcon(
value="Venture Growth profile is excluded. We prefer Venture Infrastructure or Seed Strap paths.",
margin=(0, 4),
),
sx={"alignItems": "center", "mb": 1},
),
path_select,
sx={"flex": 1},
),
pmui.Column(
pmui.Row(
pmui.Typography(
"Builder Levers (need 2+)",
variant="caption",
sx={"fontWeight": 600},
),
pn.widgets.TooltipIcon(
value="We need at least 2 builder levers we can pull: Strategy, GTM, or AI. This ensures we can add value beyond capital.",
margin=(0, 4),
),
sx={"alignItems": "center", "mb": 1},
),
pmui.Row(lever_strategy, lever_gtm, lever_ai, sx={"gap": 1}),
sx={"flex": 1},
),
sx={"gap": 2, "mb": 3},
),
# Divider
pmui.Divider(sx={"my": 2}),
# Scores section
pmui.Column(
pmui.Row(
pmui.Typography(
"Scores (1–5)",
variant="subtitle2",
sx={"fontWeight": 600},
),
pn.widgets.TooltipIcon(
value="Each dimension is scored 1-5, then weighted: Team (20 pts), Market (15 pts) + Technical Moat (10 pts) = 25 pts, Adaptation Fit (25 pts), Buyer & Traction (25 pts), Quality of Life (5 pts). Formula: (score / 5) × weight, summed to 0-100 total. Scoring scale: 5=Strong (clear evidence), 4=Good (solid evidence), 3=Moderate (some signals), 2=Weak (limited evidence), 1=Minimal (no evidence).",
margin=(0, 4),
),
sx={"alignItems": "center", "mb": 2},
),
pmui.Row(
*[
pmui.Column(
pmui.Typography(
name.upper(),
variant="caption",
sx={
"fontWeight": 500,
"mb": 0.5,
"fontSize": "0.75rem",
},
),
slider,
sx={"flex": 1, "minWidth": 90},
)
for name, slider in score_sliders.items()
],
sx={"gap": 1.5},
),
),
sx={"flex": 1, "pr": 4},
),
# Right Column: Recommendation (integrated, not nested card)
pmui.Column(
deal_rec_pane,
sx={
"width": 300,
"minWidth": 280,
"pl": 4,
"borderLeft": "1px solid rgba(0, 0, 0, 0.12)",
},
),
sx={"p": 3, "alignItems": "flex-start"},
),
sx={"boxShadow": 2, "borderRadius": 1, "mb": 4},
)
# Fund comparison table - using same table as competitive tab
# Convert list format to dict format for DataFrame.from_dict
fund_comparison_dict = {
item["name"]: {k: v for k, v in item.items() if k != "name"}
for item in FUND_COMPARISON
}
# Rename keys to match app_single.py format (camelCase to snake_case)
for fund_data in fund_comparison_dict.values():
if "feeCarry" in fund_data:
fund_data["fee_carry"] = fund_data.pop("feeCarry")
if "trackRecord" in fund_data:
fund_data["track_record"] = fund_data.pop("trackRecord")
comparison_df = pd.DataFrame.from_dict(fund_comparison_dict, orient="index")
comp_table = pmui.Column(
pmui.Typography(
"How We Compare to Climate + Tech Funds", variant="h6", sx={"mb": 1}
),
make_tabulator(
comparison_df,
height=200,
config_overrides={
"layout": "fitDataFill",
"responsiveLayout": False,
"rowHeight": 25, # Very tight rows (2x density - 2 rows per item)
"columnDefaults": {
"headerSort": False,
"formatter": "plaintext",
"cssClass": "tabulator-cell-nowrap",
},
},
styles={
"whiteSpace": "nowrap",
"overflowX": "auto",
},
),
sx={"mb": 2, "width": "100%", "mt": 6},
)
return pmui.Column(
_climate_hero,
framework,
pmui.Row(
pmui.Typography(
"Deal Evaluation Framework",
variant="h5",
sx={"fontWeight": 700},
),
pn.widgets.TooltipIcon(
value="We use a structured 0-100 score built from five pillars: Team (20 pts), Market (25 pts), Adaptation Thesis Fit (25 pts), Buyer & Traction (25 pts), Quality of Life (5 pts). Each dimension is scored 1-5, then weighted: (score / 5) × weight, summed to 0-100 total. Scoring scale: 5=Strong (clear evidence), 4=Good (solid evidence), 3=Moderate (some signals), 2=Weak (limited evidence), 1=Minimal (no evidence). If mitigation-only (e.g., carbon capture, nuclear SMR, generic EV infrastructure) with no adaptation angle, Adaptation Thesis Fit scores minimum. Do well by doing good—adaptation creates both. This rubric provides comparable, explainable scores across sectors.",
margin=(0, 4),
),
sx={"alignItems": "center", "mt": 6, "mb": 2},
),
# Single unified card: Form inputs + Recommendation
deal_form,
comp_table,
pmui.Typography(
"Sector Growth Trajectory (Billions)",
variant="h5",
sx={"mb": 2, "mt": 4, "fontWeight": 700},
),
pmui.Card(
_impact_sectors_chart,
variant="outlined",
sx={"p": 2, "mb": 4, "minHeight": 450},
),
_impact_comp_table,
)
# -------------------------------------------------------------------
# Tab 2: Pipeline
# -------------------------------------------------------------------
def pipeline_tab() -> pmui.Column:
# Use authoritative company data from app_single.py
df = pd.DataFrame([c.__dict__ for c in TARGET_COMPANIES])
display_df = df[
["company", "website", "stressor", "pain", "payer", "model", "acceleration"]
].copy()
# Add website link column with Material Icon (HTML format for Tabulator html formatter)
display_df["url"] = display_df["website"].apply(
lambda x: f'<a href="{x}" target="_blank" rel="noopener noreferrer" style="text-decoration: none; color: {get_color("accent")}; display: flex; align-items: center; justify-content: center;"><span class="material-icons" style="font-size: 18px;">open_in_new</span></a>'
)
display_df = display_df[
["company", "url", "stressor", "pain", "payer", "model", "acceleration"]
]
display_df.columns = [
"Company",
"url",
"Climate Stressor",
"Economic Pain",
"Payer",
"Business Model",
"Climate Acceleration",
]
playbooks_cards = pn.Row(
pmui.Card(
pmui.Column(
pmui.Row(
pmui.Typography(
"Resilience Infra",
variant="h6",
sx={"fontWeight": 600, "mb": 0.5},
),
pn.widgets.TooltipIcon(
value="Real causality, infra-like returns. Direct climate link. Measurable damage. Climate creates new budget. Payer identified, ROI calculable. Ex: flood defense, water reuse.",
margin=(0, 4),
),
sx={"alignItems": "center", "justifyContent": "space-between"},
),
pmui.Typography(
"Real causality, infra-like returns (e.g. flood defense, water reuse).",
variant="body2",
sx={"color": "text.secondary"},
),
),
variant="outlined",
sx={
"boxShadow": "none",
"borderRadius": 1,
"p": 2,
"bgcolor": get_color("background"),
},
),
pmui.Card(
pmui.Column(
pmui.Row(
pmui.Typography(
"Demand Shifts", variant="h6", sx={"fontWeight": 600, "mb": 0.5}
),
pn.widgets.TooltipIcon(
value="Clear stressor → payer, venture upside. Climate creates budget-backed demand. Clear stressor-payer link. Payer identified, ROI calculable. Ex: NRW SaaS, cold-chain, heat.",
margin=(0, 4),
),
sx={"alignItems": "center", "justifyContent": "space-between"},
),
pmui.Typography(
"Clear stressor → payer, venture upside (e.g. NRW SaaS, cold-chain, heat).",
variant="body2",
sx={"color": "text.secondary"},
),
),
variant="outlined",
sx={
"boxShadow": "none",
"borderRadius": 1,
"p": 2,
"bgcolor": get_color("background"),
},
),
pmui.Card(
pmui.Column(
pmui.Row(
pmui.Typography(
"Builder", variant="h6", sx={"fontWeight": 600, "mb": 0.5}
),
pn.widgets.TooltipIcon(
value="Compress time-to-milestone by working hands-on with teams. Co-create with portfolio. Reduce risk, accelerate. Only build where we invest.",
margin=(0, 4),
),
sx={"alignItems": "center", "justifyContent": "space-between"},
),
pmui.Typography(
"Compress time-to-milestone by working hands-on with teams.",
variant="body2",
sx={"color": "text.secondary"},
),
),
variant="outlined",
sx={
"boxShadow": "none",
"borderRadius": 1,
"p": 2,
"bgcolor": get_color("background"),
},
),
sizing_mode="stretch_width",
)
playbooks_intro = pmui.Typography(
"We structure deals with a Stressor → Pain → Payer → Model → Acceleration lens and three playbooks:",
variant="body1",
sx={"mb": 2, "fontWeight": 600},
)
playbooks = pn.Column(
playbooks_intro,
pn.Row(
playbooks_cards,
sizing_mode="stretch_width",
),
sizing_mode="stretch_width",
margin=(0, 0, 32, 0), # mb: 4 equivalent
)
strategy = pmui.Card(
pmui.Column(
pmui.Typography(
"Portfolio Strategy",
variant="h6",
sx={"fontWeight": 700, "mb": 2},
),
pmui.Row(
pmui.Column(
pmui.Typography(
"Climate Exposure",
variant="subtitle2",
sx={"fontWeight": 600, "mb": 1, "color": "text.secondary"},
),
pmui.Typography(
"Water: 33% (4)",
variant="body2",
sx={"mb": 0.5},
),
pmui.Typography(
"Heat: 25% (3)",
variant="body2",
sx={"mb": 0.5},
),
pmui.Typography(
"Food: 25% (3)",
variant="body2",
sx={"mb": 0.5},
),
pmui.Typography(
"Infra: 17% (2)",
variant="body2",
),
sx={"flex": 1, "pr": 2},
),
pmui.Column(
pmui.Typography(
"Business Models",
variant="subtitle2",
sx={"fontWeight": 600, "mb": 1, "color": "text.secondary"},
),
pmui.Typography(
"50% SaaS (recurring)",
variant="body2",
sx={"mb": 0.5},
),
pmui.Typography(
"33% HW+Service (sticky)",
variant="body2",
sx={"mb": 0.5},
),
pmui.Typography(
"17% Consumables (repeat)",
variant="body2",
),
sx={"flex": 1, "pr": 2},
),
pmui.Column(
pmui.Typography(
"Scoring",
variant="subtitle2",
sx={"fontWeight": 600, "mb": 1, "color": "text.secondary"},
),
pmui.Typography(
"Payer clarity: 25%",
variant="body2",
sx={"mb": 0.5},
),
pmui.Typography(
"Stressor link: 20%",
variant="body2",
sx={"mb": 0.5},
),
pmui.Typography(
"Margin ≥50%: 20%",
variant="body2",
sx={"mb": 0.5},
),
pmui.Typography(
"Budget authority 40% A—B vs 21% industry",
variant="body2",
),
sx={"flex": 1},
),
sx={"gap": 3, "flexWrap": "wrap"},
),
pmui.Typography(
"4 critical sectors",
variant="caption",
sx={"mt": 2, "fontStyle": "italic", "color": "text.secondary"},
),
),
variant="outlined",
sx={"boxShadow": 2, "borderRadius": 2, "p": 3, "mt": 4},
)
return pmui.Column(
pmui.Typography(
"12 Climate-Proof Investments",
variant="h4",
sx={"fontWeight": 700, "mb": 2},
),
pmui.Row(
pmui.Typography(
"Our portfolio focuses on climate adaptation solutions with clear budget authority and recurring revenue models. Each company addresses a specific climate stressor with measurable pain points and identified budget holders.",
variant="body1",
sx={"mb": 3, "color": "text.secondary", "flex": 1},
),
pn.widgets.TooltipIcon(
value="Key Insights: All companies share common characteristics: Budget Authority (Clear payer with existing budget line), Climate Acceleration (Value proposition strengthens as climate impacts intensify), Recurring Revenue (SaaS, consumables, or service contracts), Measurable Pain (Quantifiable economic loss or compliance requirement).",
margin=(0, 4),
),
sx={"alignItems": "flex-start", "mb": 3},
),
playbooks,
pmui.Row(
pmui.Typography("Example Portfolio", variant="h5", sx={"fontWeight": 700}),
pn.widgets.TooltipIcon(
value="These companies represent an example approach pipeline focus. Each solution becomes more valuable as climate impacts intensify.",
margin=(0, 4),
),
sx={"alignItems": "center", "mb": 2, "gap": 0},
),
make_tabulator(
display_df,
height=500,
disabled=False,
config_overrides={
"layout": "fitDataFill", # Scale to show all columns
"responsiveLayout": False, # No wrapping/collapse
"rowHeight": 25, # Very tight rows (2x density - 2 rows per item)
"pagination": None,
"dataTree": False,
"dataTreeChildIndent": 0,
"columnDefaults": {
"headerSort": False,
"resizable": True,
"tooltip": True,
"vertAlign": "middle", # Center align for tighter look
"formatter": "plaintext",
"cssClass": "tabulator-cell-nowrap", # Prevent wrapping
},
"columns": [
{
"field": "Company",
"minWidth": 110, # Tighter column
"widthGrow": 1,
"frozen": False,
},
{
"field": "url",
"minWidth": 35, # Much tighter - icon only
"width": 35, # Fixed narrow width for icon
"widthGrow": 0,
"formatter": "html",
"headerSort": False,
"hozAlign": "center", # Center the icon
},
{
"field": "Climate Stressor",
"minWidth": 120,
"widthGrow": 1.5,
}, # Tighter
{
"field": "Economic Pain",
"minWidth": 140,
"widthGrow": 2,
}, # Tighter
{"field": "Payer", "minWidth": 120, "widthGrow": 1.5}, # Tighter
{
"field": "Business Model",
"minWidth": 150,
"widthGrow": 2,
}, # Tighter
{
"field": "Climate Acceleration",
"minWidth": 150,
"widthGrow": 2,
}, # Tighter
],
},
),
strategy,
)
# -------------------------------------------------------------------
# Tab 3: Returns & Modeling
# -------------------------------------------------------------------
# Static disclaimer
_returns_disclaimer = pmui.Alert(
"These figures are hypothetical projections based on the selected scenario and do not guarantee future performance.",
severity="info",
)
# Reactive hero card
@pn.depends(
scenario_radio.param.value,
temp_radio.param.value,
commit_radio.param.value,
)
async def _returns_hero(scenario: str, temp_str: str, commit_str: str) -> pmui.Card:
"""Async reactive function that takes widget values as explicit arguments."""
lp_invest = parse_lp_investment(commit_str)
(
_temp,
_uplift,
engine,
_main_df,
summary,
_breakdown,
) = await get_model_state(scenario, temp_str, lp_invest)
lp_result = await compute_lp_returns(lp_invest, engine, summary)
return HeroCard(
f"Modeled Returns: {summary['TVPI']:.2f}× TVPI | {summary['NetIRR']:.1f}% IRR"
if summary["NetIRR"] is not None
else f"Modeled Returns: {summary['TVPI']:.2f}× TVPI | IRR N/A",
f"Hypothetical outperformance vs S&P 500: {lp_result['outperformance_pct']:+.1f}%",
get_color("primary"),
get_color("accent"),
)
# Reactive LP chart
@pn.depends(
scenario_radio.param.value,
temp_radio.param.value,
commit_radio.param.value,
)
async def _returns_lp_chart(
scenario: str, temp_str: str, commit_str: str
) -> pn.pane.Plotly:
"""Async reactive function that takes widget values as explicit arguments."""
lp_invest = parse_lp_investment(commit_str)
(
_temp,
_uplift,
engine,
_main_df,
summary,
_breakdown,
) = await get_model_state(scenario, temp_str, lp_invest)
lp_result = await compute_lp_returns(lp_invest, engine, summary)
lp_df = pd.DataFrame(
{
"Year": lp_result["years"],
"Adaptus": lp_result["lp_cum_k"],
"SP500": lp_result["sp_cum_k"],
}
)
fig_lp = go.Figure()
fig_lp.add_trace(
go.Scatter(
x=lp_df["Year"],
y=lp_df["Adaptus"],
fill="tozeroy",
mode="lines",
name="Adaptus",
line={"color": get_color("accent"), "width": 3},
fillcolor=hex_to_rgba(get_color("accent"), 0.3),
)
)
fig_lp.add_trace(
go.Scatter(
x=lp_df["Year"],
y=lp_df["SP500"],
mode="lines",
name="SP500",
line={"color": "#94a3b8", "width": 2, "dash": "dash"},
)
)
fig_lp.update_layout(
yaxis_title="$K (net of calls)",
legend_title="",
hovermode="x unified",
showlegend=True,
)
return styled_plotly(fig_lp, height=350)
# Reactive KPI cards (top row indicators)
@pn.depends(
scenario_radio.param.value,
temp_radio.param.value,
commit_radio.param.value,
)
async def _returns_top_kpis(scenario: str, temp_str: str, commit_str: str) -> pmui.Row:
"""Async reactive function that returns KPIs as a horizontal row at the top."""
lp_invest = parse_lp_investment(commit_str)
(
_temp,
_uplift,
_engine,
_main_df,
summary,
_breakdown,
) = await get_model_state(scenario, temp_str, lp_invest)
return pmui.Row(
KPICard(
"TVPI",
f"{summary['TVPI']:.2f}×",
"Total value / paid-in",
tooltip="Total return multiple = Total value (distributions + remaining NAV) divided by net capital called.",
),
KPICard(
"Net IRR",
f"{summary['NetIRR']:.1f}%" if summary["NetIRR"] is not None else "N/A",
"Internal rate of return",
tooltip="Annualized return = Average annual percentage return, after fees and carry.",
),
KPICard(
"DPI",
f"{summary['DPI']:.2f}×",
"Distributed / paid-in",
tooltip="Cash paid back = Total cash distributions divided by net capital called.",
),
sx={"gap": 2, "mb": 3},
)
# Reactive combined fund charts (annual net CF, J-curve, and waterfall)
@pn.depends(
scenario_radio.param.value,
temp_radio.param.value,
commit_radio.param.value,
)
async def _returns_combined_charts(
scenario: str, temp_str: str, commit_str: str
) -> pmui.Column:
"""Async reactive function that creates combined annual net CF, J-curve, and waterfall charts."""
lp_invest = parse_lp_investment(commit_str)
(
_temp,
_uplift,
_engine,
main_df,
summary,
breakdown,
) = await get_model_state(scenario, temp_str, lp_invest)
combined_chart = create_combined_fund_charts(breakdown, summary, main_df)
return pmui.Column(
pmui.Alert(
"**European-style waterfall**: LPs receive preferred return before GP carry. Carry only paid on profits after hurdle. Standard venture fund economics.",
severity="info",
variant="outlined",
sx={"mb": 2},
),
combined_chart,
)
def returns_tab() -> pmui.Column:
"""Main tab structure - mostly static, with reactive components inserted."""
# Use ParamFunction wrappers for stable containers that update in place
# These maintain stable DOM positions and only update content
# loading_indicator=False for fast updates to prevent flicker
# Top row KPIs
top_kpis_pane = pn.pane.ParamFunction(
_returns_top_kpis,
loading_indicator=False,
defer_load=False,
lazy=False,
)
top_kpis_pane.sizing_mode = "stretch_width"
lp_chart_pane = pn.pane.ParamFunction(
_returns_lp_chart,
loading_indicator=False, # Charts update smoothly without spinner
defer_load=False,
lazy=False,
)
lp_chart_pane.sizing_mode = "stretch_width"
combined_charts_pane = pn.pane.ParamFunction(
_returns_combined_charts,
loading_indicator=False, # Charts update smoothly
defer_load=False,
lazy=False,
)
combined_charts_pane.sizing_mode = "stretch_width"
return pmui.Column(
top_kpis_pane,
_returns_disclaimer,
pmui.Card(
pmui.Column(
pmui.Typography(
"LP Cumulative Cash Position ($K)",
variant="h6",
sx={"fontWeight": 600, "mb": 1},
),
lp_chart_pane,
),
sx={
"p": 2,
"boxShadow": 2,
"borderRadius": 2,
"minHeight": 400,
"mt": 2,
},
),
pmui.Card(
combined_charts_pane,
sx={"mt": 3, "p": 3, "boxShadow": 2, "borderRadius": 2, "minHeight": 750},
),
)
# -------------------------------------------------------------------
# Tab 4: Impact
# -------------------------------------------------------------------
# Static sectors chart (doesn't depend on inputs)
_sectors_df = pd.DataFrame([s.__dict__ for s in SECTORS])
_fig_sectors = go.Figure()
_fig_sectors.add_trace(
go.Bar(
x=_sectors_df["current"],
y=_sectors_df["name"],
orientation="h",
name="Current Market",
marker_color=get_color("muted"),
)
)
_fig_sectors.add_trace(
go.Bar(
x=_sectors_df["future"],
y=_sectors_df["name"],
orientation="h",
name="2050 Projection",
marker_color=get_color("primary"),
)
)
_fig_sectors.update_layout(
barmode="group",
xaxis_title="Billions",
yaxis_title="",
margin={"l": 150},
)
_impact_sectors_chart = styled_plotly(_fig_sectors, height=400)
# Static comparison table with natural hedge explanation
_impact_comp_table = pmui.Card(
pmui.Column(
pmui.Typography(
"Adaptation vs Mitigation Positioning",
variant="h5",
sx={"mb": 2, "fontWeight": 700},
),
pmui.Typography(
"Adaptation creates a form of resilience hedge: as climate risks become more visible, these solutions move from discretionary to essential. Demand is anchored in real-world conditions and operational needs, not solely in policy cycles.",
variant="body2",
sx={"mb": 2, "fontStyle": "italic", "color": "text.secondary"},
),
pmui.Typography(
"Adaptation vs Mitigation",
variant="h6",
sx={"mb": 1, "fontWeight": 600},
),
pn.widgets.Tabulator(
value=pd.DataFrame(
[
{
"Metric": "Climate Sensitivity",
"Mitigation Fund (Standard)": "Negative Correlation (Risks asset integrity)",
"Adaptation Fund (Adapt[us])": "Positive Correlation (Demand scales with heat)",
},
{
"Metric": "Time Horizon",
"Mitigation Fund (Standard)": "20-50 years (Science risk)",
"Adaptation Fund (Adapt[us])": "Immediate (Deployment risk only)",
},
{
"Metric": "Economic Buyer",
"Mitigation Fund (Standard)": "Policy / Subsidy dependent",
"Adaptation Fund (Adapt[us])": "Insurer / Real Estate / Corporate (P&L protection)",
},
]
),
show_index=False,
height=200,
theme="materialize",
styles={
"fontSize": "9px", # Even tighter font
"borderRadius": "4px",
"overflow": "hidden",
"whiteSpace": "nowrap", # Prevent wrapping
"padding": "2px 4px", # Very tight padding (vertical 2px, horizontal 4px)
},
configuration={
"layout": "fitDataFill", # Scale to show all columns
"responsiveLayout": False, # No wrapping/collapse
"rowHeight": 25, # Very tight rows (2x density - 2 rows per item)
"columnDefaults": {
"headerSort": False,
"formatter": "plaintext",
"cssClass": "tabulator-cell-nowrap", # Prevent wrapping
},
},
),
),
variant="outlined",
sx={"boxShadow": 2, "borderRadius": 2, "p": 2},
)
# Reactive hero and KPIs
@pn.depends(
temp_radio.param.value,
scenario_radio.param.value,
)
async def _impact_hero_and_kpis(temp_str: str, scenario: str) -> pmui.Column:
"""Async reactive function that takes widget values as explicit arguments."""
temp, uplift, _engine, _main_df, summary, _ = await get_model_state(
scenario,
temp_str,
2_500_000.0, # Default LP commitment
)
total_expansion = sum(s.future - s.current for s in SECTORS)
hero = HeroCard(
f"Dual Returns: {summary['TVPI']:.2f}× Financial + Resilience",
"Investing in adaptation is backing the continuity of essential systems.",
get_color("primary"),
get_color("accent"),
)
kpis = pmui.Row(
KPICard("Global Warming", f"{temp:.1f}°C", "Scenario baseline"),
KPICard(
"Portfolio Uplift",
f"+{(uplift - 1) * 100:.0f}%",
"Demand vs current",
tooltip="Demand multiplier calculated using GIC 4-factor methodology: frequency × intensity × exposure × passthrough. Calibrated to match GIC finding of 61% revenue uplift at 2.7°C by 2050. Based on GIC ThinkSpace (2025) 'Sizing the Inevitable Investment Opportunity: Climate Adaptation.'",
),
pmui.Card(
pmui.Typography(
"Total Market Expansion", variant="subtitle1", sx={"fontWeight": 600}
),
pmui.Typography(
f"${total_expansion:.0f}B",
variant="h4",
sx={"color": "primary.main", "fontWeight": 700},
),
pmui.Typography(
"New value creation by 2050 across 5 core sectors.", variant="body2"
),
variant="outlined",
sx={"boxShadow": "none", "borderRadius": 8},
),
sx={"gap": 8, "flexWrap": "wrap", "mb": 2},
)
# Return a single Column instead of tuple to fix rendering issue
return pmui.Column(hero, kpis)
def impact_tab() -> pmui.Column:
"""Main tab structure - mostly static, with reactive components inserted."""
# Use ParamFunction wrapper for stable container
hero_kpis_pane = pn.pane.ParamFunction(
_impact_hero_and_kpis,
loading_indicator=False, # Fast updates don't need spinner
defer_load=False,
lazy=False,
)
hero_kpis_pane.sizing_mode = "stretch_width"
return pmui.Column(
hero_kpis_pane,
pmui.Typography(
"Sector Growth Trajectory (Billions)",
variant="h5",
sx={"mb": 2, "mt": 4, "fontWeight": 700},
),
pmui.Card(
_impact_sectors_chart,
variant="outlined",
sx={"p": 2, "mb": 4, "minHeight": 450},
),
_impact_comp_table,
)
# -------------------------------------------------------------------
# 4f. Final Assembly
# -------------------------------------------------------------------
# Single function that builds the complete app.
# This is the only place where template is created and configured.
def create_app():
"""
Create and configure the Panel application.
This function assembles:
- Sidebar controls
- Main tabs
- Template with styling
Returns:
Configured Panel template ready to serve
"""
tabs = pn.Tabs(
("1. Climate Thesis", climate_thesis_tab),
("2. Pipeline", pipeline_tab),
("3. Returns & Modeling", returns_tab),
active=0,
)
template = pn.template.MaterialTemplate(
title="Adapt[us] Simulator",
sidebar=[sidebar_controls],
sidebar_width=320,
theme="default",
header_background=get_color("primary"),
)
template.main.append(tabs)
return template
# Create and serve the app
template = create_app()
template.servable()