build(agent): semicolon#54de0b iteration
This commit is contained in:
parent
06c8ddd791
commit
1466780eca
|
|
@ -0,0 +1,21 @@
|
||||||
|
node_modules/
|
||||||
|
.npmrc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
__tests__/
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.cache/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
tmp/
|
||||||
|
.tmp/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
READY_TO_PUBLISH
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Repository Purpose
|
||||||
|
VizForge models startup economics in a YAML DSL, runs stochastic scenario simulation, and generates investor-facing artifacts.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- `src/idea39_vizforge_interactive_economic/models.py`: typed domain models and validation.
|
||||||
|
- `src/idea39_vizforge_interactive_economic/dsl.py`: YAML DSL parser, serializer, and model digesting.
|
||||||
|
- `src/idea39_vizforge_interactive_economic/simulation.py`: Monte Carlo engine, sensitivity analysis, dilution projection.
|
||||||
|
- `src/idea39_vizforge_interactive_economic/artifacts.py`: markdown report and SVG chart generation.
|
||||||
|
- `src/idea39_vizforge_interactive_economic/cli.py`: command-line entry point.
|
||||||
|
- `tests/`: integration-style tests for parsing, simulation, and artifacts.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- Python 3.11+
|
||||||
|
- `numpy` for stochastic simulation
|
||||||
|
- `pydantic` for validation
|
||||||
|
- `PyYAML` for DSL parsing
|
||||||
|
- `jinja2` for report templating
|
||||||
|
- `matplotlib` for chart rendering
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- Keep the DSL declarative and versioned.
|
||||||
|
- Prefer deterministic tests with explicit seeds.
|
||||||
|
- Do not weaken validation to make a test pass.
|
||||||
|
- Preserve the README package metadata fields if packaging changes.
|
||||||
|
- Keep outputs reproducible by recording seeds and source digests.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `bash test.sh`
|
||||||
|
- `python3 -m pytest`
|
||||||
|
- `python3 -m build`
|
||||||
58
README.md
58
README.md
|
|
@ -1,3 +1,59 @@
|
||||||
# idea39-vizforge-interactive-economic
|
# idea39-vizforge-interactive-economic
|
||||||
|
|
||||||
Source logic for Idea #39
|
VizForge is a Python scenario studio for startup economics. It lets founders describe a business in a declarative YAML DSL, run stochastic simulations across macro shocks, and generate investor-facing markdown and SVG artifacts with a reproducible seed and source digest.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
- Parses a versioned business model DSL with typed validation.
|
||||||
|
- Simulates revenue, cash runway, dilution, and LTV/CAC under macro uncertainty.
|
||||||
|
- Compares scenarios such as recession, supply shocks, or tighter funding.
|
||||||
|
- Generates an investor brief with KPI tables and slide-ready SVG charts.
|
||||||
|
- Exposes a CLI for exporting reports from a YAML model file.
|
||||||
|
|
||||||
|
## Package layout
|
||||||
|
- `src/idea39_vizforge_interactive_economic/models.py` - typed model schema.
|
||||||
|
- `src/idea39_vizforge_interactive_economic/dsl.py` - YAML parser and serializer.
|
||||||
|
- `src/idea39_vizforge_interactive_economic/simulation.py` - Monte Carlo engine and sensitivity analysis.
|
||||||
|
- `src/idea39_vizforge_interactive_economic/artifacts.py` - markdown report and SVG generation.
|
||||||
|
- `src/idea39_vizforge_interactive_economic/cli.py` - command-line entry point.
|
||||||
|
|
||||||
|
## DSL example
|
||||||
|
```yaml
|
||||||
|
name: Acme AI
|
||||||
|
horizon_months: 12
|
||||||
|
starting_cash: 250000
|
||||||
|
macro:
|
||||||
|
gdp_growth: 0.02
|
||||||
|
inflation: 0.03
|
||||||
|
consumer_confidence: 102
|
||||||
|
revenue_streams:
|
||||||
|
- name: core
|
||||||
|
starting_customers: 100
|
||||||
|
price_per_customer: 120
|
||||||
|
monthly_growth_rate: 0.06
|
||||||
|
monthly_churn: 0.04
|
||||||
|
gross_margin: 0.82
|
||||||
|
acquisition_rate: 15
|
||||||
|
costs:
|
||||||
|
- name: cloud
|
||||||
|
monthly_amount: 10000
|
||||||
|
financing:
|
||||||
|
- month: 0
|
||||||
|
instrument: equity
|
||||||
|
amount: 150000
|
||||||
|
valuation: 3000000
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
```bash
|
||||||
|
vizforge path/to/model.yaml --output-dir vizforge-output --sims 1000 --seed 7
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes `report.md` plus SVG charts into the output directory.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
- Install the package in editable mode: `python3 -m pip install -e .`
|
||||||
|
- Run tests: `python3 -m pytest`
|
||||||
|
- Build the distribution: `python3 -m build`
|
||||||
|
|
||||||
|
## Reproducibility
|
||||||
|
Every simulation bundle records a deterministic seed and SHA-256 digest of the parsed DSL text so the same assumptions can be replayed later.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "idea39-vizforge-interactive-economic"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "VizForge: interactive economic scenario simulation and investor artifact generation for startups"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=2.0",
|
||||||
|
"pydantic>=2.0",
|
||||||
|
"PyYAML>=6.0",
|
||||||
|
"jinja2>=3.1",
|
||||||
|
"matplotlib>=3.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = ["pytest>=8.0"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
vizforge = "idea39_vizforge_interactive_economic.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = {"" = "src"}
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
__all__ = [
|
||||||
|
"BusinessModel",
|
||||||
|
"MacroInputs",
|
||||||
|
"RevenueStream",
|
||||||
|
"CostBucket",
|
||||||
|
"HirePlan",
|
||||||
|
"CapexEvent",
|
||||||
|
"FinancingRound",
|
||||||
|
"ScenarioShock",
|
||||||
|
"load_business_model",
|
||||||
|
"dump_business_model",
|
||||||
|
"parse_business_model",
|
||||||
|
"simulate_business_model",
|
||||||
|
"compare_scenarios",
|
||||||
|
"sensitivity_analysis",
|
||||||
|
"generate_investor_artifact",
|
||||||
|
]
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
from .models import BusinessModel, CapexEvent, CostBucket, FinancingRound, HirePlan, MacroInputs, RevenueStream, ScenarioShock
|
||||||
|
from .dsl import dump_business_model, load_business_model, parse_business_model
|
||||||
|
from .simulation import compare_scenarios, sensitivity_analysis, simulate_business_model
|
||||||
|
from .artifacts import generate_investor_artifact
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
|
from .dsl import parse_business_model
|
||||||
|
from .simulation import SimulationResult, compare_scenarios, sensitivity_analysis, simulate_business_model
|
||||||
|
from .models import BusinessModel, ScenarioShock
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ArtifactBundle:
|
||||||
|
markdown: str
|
||||||
|
charts: dict[str, str]
|
||||||
|
metadata: dict[str, str | float]
|
||||||
|
|
||||||
|
|
||||||
|
_REPORT = Template(
|
||||||
|
"""# VizForge Investor Scenario Brief
|
||||||
|
|
||||||
|
## Model
|
||||||
|
- Name: {{ name }}
|
||||||
|
- Source digest: {{ digest }}
|
||||||
|
- Horizon: {{ horizon }} months
|
||||||
|
- Seed: {{ seed }}
|
||||||
|
|
||||||
|
## Base Case Summary
|
||||||
|
| Metric | Value |
|
||||||
|
| --- | ---: |
|
||||||
|
| Runway P50 | {{ runway_p50 | round(2) }} |
|
||||||
|
| Ending Cash P50 | {{ ending_cash_p50 | round(2) }} |
|
||||||
|
| LTV/CAC P50 | {{ ltv_cac_p50 | round(2) }} |
|
||||||
|
| Gross Margin P50 | {{ gross_margin_p50 | round(2) }} |
|
||||||
|
|
||||||
|
## Narrative
|
||||||
|
{% for line in narrative %}- {{ line }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
## Dilution
|
||||||
|
| Month | Event | Founder % | Investor % | Pool % |
|
||||||
|
| --- | --- | ---: | ---: | ---: |
|
||||||
|
{% for row in dilution %}| {{ row.month }} | {{ row.event }} | {{ (row.founder_pct * 100) | round(2) }} | {{ (row.investor_pct * 100) | round(2) }} | {{ (row.pool_pct * 100) | round(2) }} |
|
||||||
|
{% endfor %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _svg_from_figure(figure) -> str:
|
||||||
|
buf = BytesIO()
|
||||||
|
figure.savefig(buf, format="svg", bbox_inches="tight")
|
||||||
|
plt.close(figure)
|
||||||
|
return buf.getvalue().decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _plot_cash_band(result: SimulationResult) -> str:
|
||||||
|
fig, ax = plt.subplots(figsize=(9, 4))
|
||||||
|
x = np.arange(result.horizon_months + 1)
|
||||||
|
p10 = np.percentile(result.cash_paths, 10, axis=0)
|
||||||
|
p50 = np.percentile(result.cash_paths, 50, axis=0)
|
||||||
|
p90 = np.percentile(result.cash_paths, 90, axis=0)
|
||||||
|
ax.fill_between(x, p10, p90, alpha=0.18, label="P10-P90")
|
||||||
|
ax.plot(x, p50, linewidth=2, label="P50")
|
||||||
|
ax.axhline(0, color="black", linewidth=1)
|
||||||
|
ax.set_title("Cash Runway Distribution")
|
||||||
|
ax.set_xlabel("Month")
|
||||||
|
ax.set_ylabel("Cash")
|
||||||
|
ax.legend(loc="upper right")
|
||||||
|
return _svg_from_figure(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def _plot_revenue_band(result: SimulationResult) -> str:
|
||||||
|
fig, ax = plt.subplots(figsize=(9, 4))
|
||||||
|
x = np.arange(1, result.horizon_months + 1)
|
||||||
|
p10 = np.percentile(result.revenue_paths, 10, axis=0)
|
||||||
|
p50 = np.percentile(result.revenue_paths, 50, axis=0)
|
||||||
|
p90 = np.percentile(result.revenue_paths, 90, axis=0)
|
||||||
|
ax.fill_between(x, p10, p90, alpha=0.18, label="P10-P90")
|
||||||
|
ax.plot(x, p50, linewidth=2, label="P50")
|
||||||
|
ax.set_title("Revenue Trajectory Distribution")
|
||||||
|
ax.set_xlabel("Month")
|
||||||
|
ax.set_ylabel("Revenue")
|
||||||
|
ax.legend(loc="upper left")
|
||||||
|
return _svg_from_figure(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_investor_artifact(model_or_source: BusinessModel | str | Path, n_sims: int = 1000, seed: int = 7, scenario: ScenarioShock | None = None) -> ArtifactBundle:
|
||||||
|
model, digest = (model_or_source, "direct-model") if isinstance(model_or_source, BusinessModel) else parse_business_model(model_or_source)
|
||||||
|
result = simulate_business_model(model, n_sims=n_sims, seed=seed, scenario=scenario)
|
||||||
|
summary = result.summary()
|
||||||
|
narrative = [
|
||||||
|
f"{result.scenario_name} keeps the business in a {summary['runway_p50']:.1f}-month runway band at the median outcome.",
|
||||||
|
f"Median LTV/CAC is {summary['ltv_cac_p50']:.2f}, which is {('healthy' if summary['ltv_cac_p50'] >= 3 else 'compressed')} for investor diligence.",
|
||||||
|
f"Dilution is captured as a reproducible event trail tied to the source digest.",
|
||||||
|
]
|
||||||
|
markdown = _REPORT.render(
|
||||||
|
name=model.name,
|
||||||
|
digest=digest,
|
||||||
|
horizon=model.horizon_months,
|
||||||
|
seed=seed,
|
||||||
|
runway_p50=summary["runway_p50"],
|
||||||
|
ending_cash_p50=summary["ending_cash_p50"],
|
||||||
|
ltv_cac_p50=summary["ltv_cac_p50"],
|
||||||
|
gross_margin_p50=summary["gross_margin_p50"],
|
||||||
|
narrative=narrative,
|
||||||
|
dilution=result.dilution_table,
|
||||||
|
)
|
||||||
|
return ArtifactBundle(
|
||||||
|
markdown=markdown,
|
||||||
|
charts={"cash_runway.svg": _plot_cash_band(result), "revenue_trajectory.svg": _plot_revenue_band(result)},
|
||||||
|
metadata={"digest": digest, **summary, "scenario": result.scenario_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def export_artifact(bundle: ArtifactBundle, output_dir: str | Path) -> None:
|
||||||
|
path = Path(output_dir)
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(path / "report.md").write_text(bundle.markdown, encoding="utf-8")
|
||||||
|
for name, content in bundle.charts.items():
|
||||||
|
(path / name).write_text(content, encoding="utf-8")
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .artifacts import export_artifact, generate_investor_artifact
|
||||||
|
from .dsl import load_business_model
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="VizForge scenario studio")
|
||||||
|
parser.add_argument("input", type=Path, help="Path to a VizForge YAML model")
|
||||||
|
parser.add_argument("--output-dir", type=Path, default=Path("vizforge-output"))
|
||||||
|
parser.add_argument("--sims", type=int, default=1000)
|
||||||
|
parser.add_argument("--seed", type=int, default=7)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
model = load_business_model(args.input)
|
||||||
|
bundle = generate_investor_artifact(model, n_sims=args.sims, seed=args.seed)
|
||||||
|
export_artifact(bundle, args.output_dir)
|
||||||
|
print(f"Wrote {args.output_dir / 'report.md'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .models import BusinessModel
|
||||||
|
|
||||||
|
|
||||||
|
def _read_source(source: str | Path) -> str:
|
||||||
|
path = Path(source)
|
||||||
|
try:
|
||||||
|
if path.exists():
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return str(source)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_business_model(source: str | Path) -> tuple[BusinessModel, str]:
|
||||||
|
text = _read_source(source)
|
||||||
|
payload = yaml.safe_load(text) or {}
|
||||||
|
model = BusinessModel.model_validate(payload)
|
||||||
|
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
return model, digest
|
||||||
|
|
||||||
|
|
||||||
|
def load_business_model(source: str | Path) -> BusinessModel:
|
||||||
|
return parse_business_model(source)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def dump_business_model(model: BusinessModel) -> str:
|
||||||
|
payload = model.model_dump(mode="json", exclude_none=True)
|
||||||
|
return yaml.safe_dump(payload, sort_keys=False)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_mapping(value: Any) -> dict[str, Any]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
raise TypeError("expected mapping")
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class MacroInputs(BaseModel):
|
||||||
|
gdp_growth: float = 0.0
|
||||||
|
inflation: float = 0.03
|
||||||
|
interest_rate: float = 0.05
|
||||||
|
consumer_confidence: float = 100.0
|
||||||
|
funding_environment: float = 0.5
|
||||||
|
regime_switch_probability: float = 0.08
|
||||||
|
fx_rates: dict[str, float] = Field(default_factory=dict)
|
||||||
|
region: str = "global"
|
||||||
|
|
||||||
|
@field_validator("funding_environment", "regime_switch_probability")
|
||||||
|
@classmethod
|
||||||
|
def _bounded_probability(cls, value: float) -> float:
|
||||||
|
if not 0.0 <= value <= 1.0:
|
||||||
|
raise ValueError("value must be between 0 and 1")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("consumer_confidence")
|
||||||
|
@classmethod
|
||||||
|
def _confidence_range(cls, value: float) -> float:
|
||||||
|
if value <= 0:
|
||||||
|
raise ValueError("consumer_confidence must be positive")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class RevenueStream(BaseModel):
|
||||||
|
name: str
|
||||||
|
starting_customers: float = 0.0
|
||||||
|
price_per_customer: float
|
||||||
|
monthly_growth_rate: float = 0.0
|
||||||
|
monthly_churn: float = 0.04
|
||||||
|
gross_margin: float = 0.75
|
||||||
|
acquisition_rate: float = 0.0
|
||||||
|
macro_sensitivity: float = 0.35
|
||||||
|
currency: str = "USD"
|
||||||
|
|
||||||
|
@field_validator("monthly_churn")
|
||||||
|
@classmethod
|
||||||
|
def _churn_range(cls, value: float) -> float:
|
||||||
|
if not 0.0 <= value < 1.0:
|
||||||
|
raise ValueError("monthly_churn must be in [0, 1)")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("gross_margin")
|
||||||
|
@classmethod
|
||||||
|
def _margin_range(cls, value: float) -> float:
|
||||||
|
if not 0.0 <= value <= 1.0:
|
||||||
|
raise ValueError("gross_margin must be in [0, 1]")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CostBucket(BaseModel):
|
||||||
|
name: str
|
||||||
|
monthly_amount: float
|
||||||
|
inflation_sensitivity: float = 0.7
|
||||||
|
macro_sensitivity: float = 0.15
|
||||||
|
currency: str = "USD"
|
||||||
|
|
||||||
|
|
||||||
|
class HirePlan(BaseModel):
|
||||||
|
month: int
|
||||||
|
role: str
|
||||||
|
annual_salary: float
|
||||||
|
count: int = 1
|
||||||
|
ramp_months: int = 1
|
||||||
|
currency: str = "USD"
|
||||||
|
|
||||||
|
@field_validator("month")
|
||||||
|
@classmethod
|
||||||
|
def _month_non_negative(cls, value: int) -> int:
|
||||||
|
if value < 0:
|
||||||
|
raise ValueError("month must be non-negative")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CapexEvent(BaseModel):
|
||||||
|
month: int
|
||||||
|
amount: float
|
||||||
|
description: str = "capex"
|
||||||
|
depreciation_months: int = 24
|
||||||
|
currency: str = "USD"
|
||||||
|
|
||||||
|
|
||||||
|
class FinancingRound(BaseModel):
|
||||||
|
month: int
|
||||||
|
instrument: Literal["equity", "safe", "option_pool"] = "equity"
|
||||||
|
amount: float = 0.0
|
||||||
|
valuation: float | None = None
|
||||||
|
discount: float = 0.2
|
||||||
|
cap: float | None = None
|
||||||
|
option_pool_percent: float = 0.0
|
||||||
|
currency: str = "USD"
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_round(self) -> "FinancingRound":
|
||||||
|
if self.instrument == "equity" and not self.valuation:
|
||||||
|
raise ValueError("equity financing requires valuation")
|
||||||
|
if self.instrument == "safe" and not (self.cap or self.discount):
|
||||||
|
raise ValueError("safe financing requires a cap or discount")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioShock(BaseModel):
|
||||||
|
name: str
|
||||||
|
probability: float = 0.2
|
||||||
|
demand_multiplier: float = 0.9
|
||||||
|
cac_multiplier: float = 1.1
|
||||||
|
churn_delta: float = 0.01
|
||||||
|
cost_inflation_delta: float = 0.01
|
||||||
|
funding_environment_delta: float = -0.05
|
||||||
|
interest_rate_delta: float = 0.01
|
||||||
|
duration_months: int = 6
|
||||||
|
regime_bias: float = -0.15
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessModel(BaseModel):
|
||||||
|
name: str
|
||||||
|
horizon_months: int = 24
|
||||||
|
base_currency: str = "USD"
|
||||||
|
starting_cash: float = 0.0
|
||||||
|
macro: MacroInputs = Field(default_factory=MacroInputs)
|
||||||
|
revenue_streams: list[RevenueStream] = Field(default_factory=list)
|
||||||
|
costs: list[CostBucket] = Field(default_factory=list)
|
||||||
|
hires: list[HirePlan] = Field(default_factory=list)
|
||||||
|
capex: list[CapexEvent] = Field(default_factory=list)
|
||||||
|
financing: list[FinancingRound] = Field(default_factory=list)
|
||||||
|
scenarios: list[ScenarioShock] = Field(default_factory=list)
|
||||||
|
fx_rates: dict[str, float] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@field_validator("horizon_months")
|
||||||
|
@classmethod
|
||||||
|
def _horizon_positive(cls, value: int) -> int:
|
||||||
|
if value <= 0:
|
||||||
|
raise ValueError("horizon_months must be positive")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _default_fx_rate(self) -> "BusinessModel":
|
||||||
|
if self.base_currency not in self.fx_rates:
|
||||||
|
self.fx_rates[self.base_currency] = 1.0
|
||||||
|
return self
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .dsl import parse_business_model
|
||||||
|
from .models import BusinessModel, ScenarioShock
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SimulationResult:
|
||||||
|
scenario_name: str
|
||||||
|
seed: int
|
||||||
|
horizon_months: int
|
||||||
|
revenue_paths: np.ndarray
|
||||||
|
cash_paths: np.ndarray
|
||||||
|
runway_months: np.ndarray
|
||||||
|
ending_cash: np.ndarray
|
||||||
|
ltv_cac: np.ndarray
|
||||||
|
gross_margin: np.ndarray
|
||||||
|
monthly_summary: list[dict[str, float]]
|
||||||
|
dilution_table: list[dict[str, float | str]]
|
||||||
|
model_digest: str
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, float]:
|
||||||
|
return {
|
||||||
|
"runway_p10": float(np.percentile(self.runway_months, 10)),
|
||||||
|
"runway_p50": float(np.percentile(self.runway_months, 50)),
|
||||||
|
"runway_p90": float(np.percentile(self.runway_months, 90)),
|
||||||
|
"ending_cash_p50": float(np.percentile(self.ending_cash, 50)),
|
||||||
|
"ltv_cac_p50": float(np.percentile(self.ltv_cac, 50)),
|
||||||
|
"gross_margin_p50": float(np.percentile(self.gross_margin, 50)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fx(amount: float, currency: str, fx_rates: dict[str, float]) -> float:
|
||||||
|
if currency not in fx_rates:
|
||||||
|
raise KeyError(f"missing fx rate for {currency}")
|
||||||
|
return amount * fx_rates[currency]
|
||||||
|
|
||||||
|
|
||||||
|
def _monthly_rate_from_annual(salary: float) -> float:
|
||||||
|
return salary / 12.0
|
||||||
|
|
||||||
|
|
||||||
|
def _dilution_projection(model: BusinessModel) -> list[dict[str, float | str]]:
|
||||||
|
founder = 1.0
|
||||||
|
pool = 0.0
|
||||||
|
rows: list[dict[str, float | str]] = []
|
||||||
|
shares = 100.0
|
||||||
|
for round_ in sorted(model.financing, key=lambda item: item.month):
|
||||||
|
if round_.instrument == "option_pool":
|
||||||
|
pool += round_.option_pool_percent
|
||||||
|
founder *= max(0.0, 1.0 - round_.option_pool_percent)
|
||||||
|
shares *= max(0.0, 1.0 - round_.option_pool_percent)
|
||||||
|
rows.append({"month": round_.month, "event": "option_pool", "founder_pct": founder, "investor_pct": 0.0, "pool_pct": pool})
|
||||||
|
continue
|
||||||
|
if round_.instrument == "safe":
|
||||||
|
assumed_valuation = round_.cap or round_.valuation or 1.0
|
||||||
|
investor = min(0.5, round_.amount / assumed_valuation)
|
||||||
|
else:
|
||||||
|
investor = min(0.9, round_.amount / (round_.valuation or 1.0))
|
||||||
|
founder *= max(0.0, 1.0 - investor)
|
||||||
|
shares *= max(0.0, 1.0 - investor)
|
||||||
|
rows.append({"month": round_.month, "event": round_.instrument, "founder_pct": founder, "investor_pct": investor, "pool_pct": pool})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _simulate_single_run(model: BusinessModel, rng: np.random.Generator, scenario: ScenarioShock | None) -> tuple[np.ndarray, np.ndarray, float, float, float]:
|
||||||
|
horizon = model.horizon_months
|
||||||
|
revenue = np.zeros(horizon, dtype=float)
|
||||||
|
cash = np.zeros(horizon + 1, dtype=float)
|
||||||
|
cash[0] = model.starting_cash
|
||||||
|
total_gross_profit = 0.0
|
||||||
|
total_cac_spend = 0.0
|
||||||
|
|
||||||
|
scenario = scenario or ScenarioShock(name="baseline", probability=1.0, demand_multiplier=1.0, cac_multiplier=1.0, churn_delta=0.0, cost_inflation_delta=0.0, funding_environment_delta=0.0, interest_rate_delta=0.0, duration_months=horizon, regime_bias=0.0)
|
||||||
|
regime = 1.0
|
||||||
|
monthly_customers = [stream.starting_customers for stream in model.revenue_streams]
|
||||||
|
|
||||||
|
financing_by_month: dict[int, list] = {}
|
||||||
|
for round_ in model.financing:
|
||||||
|
financing_by_month.setdefault(round_.month, []).append(round_)
|
||||||
|
|
||||||
|
for month in range(horizon):
|
||||||
|
switch_p = model.macro.regime_switch_probability * (1.2 if model.macro.consumer_confidence < 95 else 0.8)
|
||||||
|
if rng.random() < switch_p:
|
||||||
|
regime *= -1.0
|
||||||
|
active_scenario = 1.0 if month >= scenario.duration_months else scenario.demand_multiplier
|
||||||
|
regime_effect = 1.0 + (scenario.regime_bias if regime < 0 else -scenario.regime_bias / 2.0)
|
||||||
|
demand_shock = 1.0 + (0.02 * model.macro.gdp_growth) + ((model.macro.consumer_confidence - 100.0) / 1000.0) + rng.normal(0.0, 0.02)
|
||||||
|
price_shock = 1.0 + (0.5 * model.macro.inflation) + rng.normal(0.0, 0.01)
|
||||||
|
churn_shift = max(0.0, model.macro.interest_rate * 0.25 + scenario.churn_delta + rng.normal(0.0, 0.005))
|
||||||
|
cost_inflation = max(0.0, model.macro.inflation + scenario.cost_inflation_delta + rng.normal(0.0, 0.01))
|
||||||
|
funding_factor = 1.0 + scenario.funding_environment_delta + (model.macro.funding_environment - 0.5) * 0.1
|
||||||
|
|
||||||
|
month_revenue = 0.0
|
||||||
|
month_gross_profit = 0.0
|
||||||
|
month_cac = 0.0
|
||||||
|
|
||||||
|
for i, stream in enumerate(model.revenue_streams):
|
||||||
|
churn = min(0.95, stream.monthly_churn + churn_shift + rng.normal(0.0, 0.003))
|
||||||
|
acquisition = max(0.0, stream.acquisition_rate * active_scenario * regime_effect * funding_factor * demand_shock)
|
||||||
|
customers = monthly_customers[i] * (1.0 - churn) + acquisition
|
||||||
|
customers = max(0.0, customers * (1.0 + stream.monthly_growth_rate * active_scenario * demand_shock * regime_effect))
|
||||||
|
monthly_customers[i] = customers
|
||||||
|
price = stream.price_per_customer * price_shock * (1.0 + stream.macro_sensitivity * (demand_shock - 1.0))
|
||||||
|
stream_revenue = _fx(customers * price, stream.currency, model.fx_rates)
|
||||||
|
stream_gross = stream_revenue * stream.gross_margin
|
||||||
|
month_revenue += stream_revenue
|
||||||
|
month_gross_profit += stream_gross
|
||||||
|
month_cac += max(0.0, acquisition * stream.price_per_customer * scenario.cac_multiplier * 0.15)
|
||||||
|
|
||||||
|
cost_total = 0.0
|
||||||
|
for cost in model.costs:
|
||||||
|
inflated = cost.monthly_amount * (1.0 + cost.inflation_sensitivity * cost_inflation) * (1.0 + cost.macro_sensitivity * (demand_shock - 1.0))
|
||||||
|
cost_total += _fx(inflated, cost.currency, model.fx_rates)
|
||||||
|
|
||||||
|
for hire in model.hires:
|
||||||
|
if month >= hire.month:
|
||||||
|
ramp = min(1.0, max(0.2, (month - hire.month + 1) / max(1, hire.ramp_months)))
|
||||||
|
cost_total += _fx(_monthly_rate_from_annual(hire.annual_salary) * hire.count * ramp, hire.currency, model.fx_rates)
|
||||||
|
|
||||||
|
for capex in model.capex:
|
||||||
|
if month == capex.month:
|
||||||
|
cost_total += _fx(capex.amount, capex.currency, model.fx_rates)
|
||||||
|
|
||||||
|
month_cash = cash[month] + month_gross_profit - cost_total
|
||||||
|
if month in financing_by_month:
|
||||||
|
for round_ in financing_by_month[month]:
|
||||||
|
if round_.instrument in {"equity", "safe"}:
|
||||||
|
month_cash += _fx(round_.amount, round_.currency, model.fx_rates)
|
||||||
|
cash[month + 1] = month_cash
|
||||||
|
revenue[month] = month_revenue
|
||||||
|
total_gross_profit += month_gross_profit
|
||||||
|
total_cac_spend += month_cac
|
||||||
|
|
||||||
|
runway = int(np.argmax(cash[1:] <= 0)) if np.any(cash[1:] <= 0) else horizon
|
||||||
|
lifetime_value = (np.mean(revenue) / max(1.0, np.mean([s.starting_customers + s.acquisition_rate for s in model.revenue_streams]) or 1.0)) * max(0.0, np.mean([s.gross_margin for s in model.revenue_streams])) / max(0.001, np.mean([s.monthly_churn for s in model.revenue_streams]))
|
||||||
|
cac = max(1.0, total_cac_spend / max(1.0, sum(s.acquisition_rate for s in model.revenue_streams) * horizon))
|
||||||
|
ltv_cac = lifetime_value / cac
|
||||||
|
gross_margin = total_gross_profit / max(1.0, np.sum(revenue))
|
||||||
|
return revenue, cash, float(runway), float(cash[-1]), float(ltv_cac), float(gross_margin)
|
||||||
|
|
||||||
|
|
||||||
|
def _monthly_summary(revenue_paths: np.ndarray, cash_paths: np.ndarray) -> list[dict[str, float]]:
|
||||||
|
rows: list[dict[str, float]] = []
|
||||||
|
for month in range(revenue_paths.shape[1]):
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"month": float(month + 1),
|
||||||
|
"revenue_p10": float(np.percentile(revenue_paths[:, month], 10)),
|
||||||
|
"revenue_p50": float(np.percentile(revenue_paths[:, month], 50)),
|
||||||
|
"revenue_p90": float(np.percentile(revenue_paths[:, month], 90)),
|
||||||
|
"cash_p10": float(np.percentile(cash_paths[:, month + 1], 10)),
|
||||||
|
"cash_p50": float(np.percentile(cash_paths[:, month + 1], 50)),
|
||||||
|
"cash_p90": float(np.percentile(cash_paths[:, month + 1], 90)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_business_model(model_or_source: BusinessModel | str, n_sims: int = 1000, seed: int = 7, scenario: ScenarioShock | None = None) -> SimulationResult:
|
||||||
|
if isinstance(model_or_source, BusinessModel):
|
||||||
|
model = model_or_source
|
||||||
|
digest = "direct-model"
|
||||||
|
else:
|
||||||
|
model, digest = parse_business_model(model_or_source)
|
||||||
|
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
revenue_paths = np.zeros((n_sims, model.horizon_months), dtype=float)
|
||||||
|
cash_paths = np.zeros((n_sims, model.horizon_months + 1), dtype=float)
|
||||||
|
runways = np.zeros(n_sims, dtype=float)
|
||||||
|
ending_cash = np.zeros(n_sims, dtype=float)
|
||||||
|
ltv_cac = np.zeros(n_sims, dtype=float)
|
||||||
|
gross_margin = np.zeros(n_sims, dtype=float)
|
||||||
|
|
||||||
|
for i in range(n_sims):
|
||||||
|
revenue, cash, runway, final_cash, ltv, margin = _simulate_single_run(model, rng, scenario)
|
||||||
|
revenue_paths[i] = revenue
|
||||||
|
cash_paths[i] = cash
|
||||||
|
runways[i] = runway
|
||||||
|
ending_cash[i] = final_cash
|
||||||
|
ltv_cac[i] = ltv
|
||||||
|
gross_margin[i] = margin
|
||||||
|
|
||||||
|
return SimulationResult(
|
||||||
|
scenario_name=(scenario.name if scenario else "baseline"),
|
||||||
|
seed=seed,
|
||||||
|
horizon_months=model.horizon_months,
|
||||||
|
revenue_paths=revenue_paths,
|
||||||
|
cash_paths=cash_paths,
|
||||||
|
runway_months=runways,
|
||||||
|
ending_cash=ending_cash,
|
||||||
|
ltv_cac=ltv_cac,
|
||||||
|
gross_margin=gross_margin,
|
||||||
|
monthly_summary=_monthly_summary(revenue_paths, cash_paths),
|
||||||
|
dilution_table=_dilution_projection(model),
|
||||||
|
model_digest=digest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compare_scenarios(model_or_source: BusinessModel | str, scenarios: Iterable[ScenarioShock], n_sims: int = 1000, seed: int = 7) -> list[SimulationResult]:
|
||||||
|
model = model_or_source if isinstance(model_or_source, BusinessModel) else parse_business_model(model_or_source)[0]
|
||||||
|
results: list[SimulationResult] = []
|
||||||
|
for index, scenario in enumerate(scenarios):
|
||||||
|
results.append(simulate_business_model(model, n_sims=n_sims, seed=seed + index, scenario=scenario))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def sensitivity_analysis(model_or_source: BusinessModel | str, parameter_path: str, values: Iterable[float], n_sims: int = 500, seed: int = 7) -> list[dict[str, float]]:
|
||||||
|
model = model_or_source if isinstance(model_or_source, BusinessModel) else parse_business_model(model_or_source)[0]
|
||||||
|
if parameter_path != "macro.inflation":
|
||||||
|
raise NotImplementedError("sensitivity analysis currently supports macro.inflation")
|
||||||
|
rows: list[dict[str, float]] = []
|
||||||
|
for idx, value in enumerate(values):
|
||||||
|
cloned = model.model_copy(deep=True)
|
||||||
|
cloned.macro.inflation = float(value)
|
||||||
|
result = simulate_business_model(cloned, n_sims=n_sims, seed=seed + idx)
|
||||||
|
summary = result.summary()
|
||||||
|
rows.append({"value": float(value), "runway_p50": summary["runway_p50"], "ending_cash_p50": summary["ending_cash_p50"], "ltv_cac_p50": summary["ltv_cac_p50"]})
|
||||||
|
return rows
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
python3 -m pip install --quiet -e .
|
||||||
|
python3 -m pip install --quiet build pytest
|
||||||
|
python3 -m pytest
|
||||||
|
python3 -m build
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from idea39_vizforge_interactive_economic.artifacts import export_artifact, generate_investor_artifact
|
||||||
|
from idea39_vizforge_interactive_economic.dsl import dump_business_model, load_business_model, parse_business_model
|
||||||
|
from idea39_vizforge_interactive_economic.models import BusinessModel
|
||||||
|
from idea39_vizforge_interactive_economic.simulation import compare_scenarios, sensitivity_analysis, simulate_business_model
|
||||||
|
|
||||||
|
|
||||||
|
def sample_yaml() -> str:
|
||||||
|
return """
|
||||||
|
name: Acme AI
|
||||||
|
horizon_months: 12
|
||||||
|
base_currency: USD
|
||||||
|
starting_cash: 250000
|
||||||
|
macro:
|
||||||
|
gdp_growth: 0.02
|
||||||
|
inflation: 0.03
|
||||||
|
interest_rate: 0.05
|
||||||
|
consumer_confidence: 102
|
||||||
|
funding_environment: 0.68
|
||||||
|
regime_switch_probability: 0.05
|
||||||
|
fx_rates:
|
||||||
|
USD: 1.0
|
||||||
|
revenue_streams:
|
||||||
|
- name: core
|
||||||
|
starting_customers: 100
|
||||||
|
price_per_customer: 120
|
||||||
|
monthly_growth_rate: 0.06
|
||||||
|
monthly_churn: 0.04
|
||||||
|
gross_margin: 0.82
|
||||||
|
acquisition_rate: 15
|
||||||
|
macro_sensitivity: 0.4
|
||||||
|
costs:
|
||||||
|
- name: cloud
|
||||||
|
monthly_amount: 10000
|
||||||
|
inflation_sensitivity: 0.5
|
||||||
|
macro_sensitivity: 0.1
|
||||||
|
hires:
|
||||||
|
- month: 2
|
||||||
|
role: engineer
|
||||||
|
annual_salary: 180000
|
||||||
|
count: 1
|
||||||
|
capex:
|
||||||
|
- month: 3
|
||||||
|
amount: 25000
|
||||||
|
description: hardware
|
||||||
|
financing:
|
||||||
|
- month: 0
|
||||||
|
instrument: equity
|
||||||
|
amount: 150000
|
||||||
|
valuation: 3000000
|
||||||
|
- month: 6
|
||||||
|
instrument: option_pool
|
||||||
|
option_pool_percent: 0.08
|
||||||
|
scenarios:
|
||||||
|
- name: recession
|
||||||
|
probability: 0.2
|
||||||
|
demand_multiplier: 0.82
|
||||||
|
cac_multiplier: 1.25
|
||||||
|
churn_delta: 0.03
|
||||||
|
cost_inflation_delta: 0.02
|
||||||
|
funding_environment_delta: -0.2
|
||||||
|
interest_rate_delta: 0.02
|
||||||
|
duration_months: 4
|
||||||
|
regime_bias: -0.2
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_roundtrip_and_digest() -> None:
|
||||||
|
model, digest = parse_business_model(sample_yaml())
|
||||||
|
assert model.name == "Acme AI"
|
||||||
|
assert len(digest) == 64
|
||||||
|
dumped = dump_business_model(model)
|
||||||
|
loaded = load_business_model(dumped)
|
||||||
|
assert loaded.name == model.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_simulation_and_scenario_comparison() -> None:
|
||||||
|
model = load_business_model(sample_yaml())
|
||||||
|
base = simulate_business_model(model, n_sims=64, seed=11)
|
||||||
|
assert base.revenue_paths.shape == (64, 12)
|
||||||
|
assert base.cash_paths.shape == (64, 13)
|
||||||
|
assert base.summary()["runway_p50"] >= 0
|
||||||
|
|
||||||
|
recession = model.scenarios[0]
|
||||||
|
results = compare_scenarios(model, [recession], n_sims=32, seed=12)
|
||||||
|
assert results[0].scenario_name == "recession"
|
||||||
|
assert results[0].runway_months.shape == (32,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensitivity_and_artifacts(tmp_path: Path) -> None:
|
||||||
|
model = load_business_model(sample_yaml())
|
||||||
|
rows = sensitivity_analysis(model, "macro.inflation", [0.02, 0.05], n_sims=24, seed=3)
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0]["value"] == 0.02
|
||||||
|
|
||||||
|
bundle = generate_investor_artifact(model, n_sims=32, seed=4)
|
||||||
|
assert "VizForge Investor Scenario Brief" in bundle.markdown
|
||||||
|
assert "cash_runway.svg" in bundle.charts
|
||||||
|
|
||||||
|
export_artifact(bundle, tmp_path)
|
||||||
|
assert (tmp_path / "report.md").exists()
|
||||||
|
assert (tmp_path / "cash_runway.svg").exists()
|
||||||
Loading…
Reference in New Issue