| import os |
| import json |
| import random |
| import tempfile |
| import csv |
| import shutil |
| from uuid import uuid4 |
| from typing import Dict, Any, List |
|
|
| from openenv.core.env_server.interfaces import Environment |
| from openenv.core.env_server.types import State |
|
|
| from models import VcGeminiAction, VcGeminiObservation |
|
|
| class VcGeminiEnvironment(Environment): |
| SUPPORTS_CONCURRENT_SESSIONS: bool = True |
|
|
| def __init__(self): |
| self._state = State(episode_id=str(uuid4()), step_count=0) |
| self.workspace_dir = tempfile.mkdtemp(prefix="vc_env_") |
| |
| self.fund_budget = 100000000.0 |
| self.portfolio = [] |
| self.quarter = 1 |
| self.MAX_QUARTERS = 11 |
| self.MAX_TURNS_PER_QUARTER = 10 |
| self.turns_remaining = self.MAX_TURNS_PER_QUARTER |
| |
| |
| scenario_path = os.path.join(os.path.dirname(__file__), "fund_scenarios.json") |
| with open(scenario_path, "r") as f: |
| self.scenarios = json.load(f) |
| |
| comp_path = os.path.join(os.path.dirname(__file__), "competitors.json") |
| with open(comp_path, "r") as f: |
| self.competitors = json.load(f) |
| |
| self.available_scenarios = [] |
| self.active_competitors = [] |
| self.inbox_pitches = [] |
|
|
| def reset(self) -> VcGeminiObservation: |
| self._state = State(episode_id=str(uuid4()), step_count=0) |
| self.fund_budget = 100000000.0 |
| self.portfolio = [] |
| self.quarter = 1 |
| |
| |
| self.active_competitors = random.sample(self.competitors, 3) |
| |
| self.available_scenarios = random.sample(self.scenarios, 30) |
| |
| return self._setup_quarter() |
|
|
| def _mark_to_market(self): |
| """Updates the paper valuation of the portfolio and returns the interim reward (RVPI).""" |
| interim_reward = 0.0 |
| |
| for inv in self.portfolio: |
| if not inv["active"]: |
| continue |
| |
| |
| if random.random() < 0.3: |
| |
| if inv["true_potential_multiplier"] > 1.0: |
| rvpi_bump = random.uniform(0.1, 0.5) |
| inv["paper_multiplier"] += rvpi_bump |
| interim_reward += (rvpi_bump * 0.1) |
| elif inv["true_potential_multiplier"] == 0.0: |
| rvpi_drop = random.uniform(-0.1, -0.9) |
| inv["paper_multiplier"] = max(0.1, inv["paper_multiplier"] + rvpi_drop) |
| interim_reward += (rvpi_drop * 0.1) |
| |
| return interim_reward |
| |
| def _setup_quarter(self) -> VcGeminiObservation: |
| if self.quarter >= self.MAX_QUARTERS: |
| return self._calculate_final_tvpi() |
| |
| |
| if os.path.exists(self.workspace_dir): |
| shutil.rmtree(self.workspace_dir, ignore_errors=True) |
| |
| self.workspace_dir = tempfile.mkdtemp(prefix=f"vc_q{self.quarter}_") |
| self.turns_remaining = self.MAX_TURNS_PER_QUARTER |
| |
| |
| start_idx = (self.quarter - 1) * 3 |
| self.inbox_pitches = self.available_scenarios[start_idx:start_idx+3] |
| |
| pitch_names = [] |
| |
| for scenario in self.inbox_pitches: |
| startup_dir = os.path.join(self.workspace_dir, scenario["startup_name"].replace(" ", "_")) |
| os.makedirs(startup_dir) |
| |
| cap_table_path = os.path.join(startup_dir, "cap_table.csv") |
| with open(cap_table_path, "w", newline='') as f: |
| writer = csv.writer(f) |
| writer.writerow(["Shareholder", "Shares", "Type"]) |
| for row in scenario["cap_table"]: |
| writer.writerow([row["Shareholder"], row["Shares"], row["Type"]]) |
| |
| deck_path = os.path.join(startup_dir, "pitch_deck.txt") |
| with open(deck_path, "w") as f: |
| f.write(f"{scenario['startup_name']} Pitch Deck\n") |
| f.write(f"Sector: {scenario['sector']}\n") |
| f.write(f"We are raising {scenario['raise_amount_str']}.\n") |
| f.write("Note: Email the founder if you have diligence questions.\n") |
| |
| pitch_names.append(f"- {scenario['startup_name']} ({scenario['sector']})") |
| |
| |
| port_status = "Empty" |
| if self.portfolio: |
| port_items = [] |
| for p in self.portfolio: |
| status = "ACTIVE" if p["active"] else "SOLD" |
| val = p['invested_amount'] * p['paper_multiplier'] |
| port_items.append(f"{p['startup_name']} [{status}]: Paper Value ${val:,.2f} ({p['paper_multiplier']:.2f}x)") |
| port_status = "\n".join(port_items) |
| |
| comps_str = ", ".join([c["name"] for c in self.active_competitors]) |
| obs_text = ( |
| f"--- QUARTER {self.quarter} (Turns Remaining: {self.turns_remaining}) ---\n" |
| f"Fund Budget Remaining: ${self.fund_budget:,.2f}\n" |
| f"Active Portfolio:\n{port_status}\n\n" |
| f"Market Rumor: Active Rival Funds this decade are {comps_str}.\n\n" |
| f"New Pitches in Inbox:\n" + "\n".join(pitch_names) + "\n\n" |
| f"Their Data Rooms are mounted inside: {self.workspace_dir}. " |
| f"You can 'read_file', 'email_founder', 'submit_term_sheet', 'propose_syndicate', 'pass_on_deal', 'email_competitor', or 'sell_secondary_shares'." |
| ) |
| |
| |
| interim_reward = self._mark_to_market() if self.quarter > 1 else 0.0 |
| |
| return VcGeminiObservation( |
| observation_text=obs_text, |
| inbox=[], |
| data={"workspace_dir": self.workspace_dir, "quarter": self.quarter, "budget": self.fund_budget, "turns_left": self.turns_remaining}, |
| done=False, |
| reward=interim_reward |
| ) |
| |
| def _calculate_final_tvpi(self) -> VcGeminiObservation: |
| total_returned_capital = self._run_ipo_phase() |
| |
| |
| |
| |
| |
| total_fund_value = total_returned_capital + self.fund_budget |
| tvpi = total_fund_value / 100000000.0 |
| |
| |
| |
| if tvpi < 1.20: |
| final_reward = -10.0 |
| else: |
| final_reward = tvpi |
|
|
| summary = "\n=== FUND LIFE COMPLETE (10 QUARTERS) ===\n" |
| summary += f"Total Liquid Capital Returned: ${total_fund_value:,.2f}\n" |
| summary += f"Gross Fund TVPI: {tvpi:.2f}x\n" |
| if final_reward == -10.0: |
| summary += "\nLPs are furious. You failed to beat the hurdle rate. You are fired from the partnership." |
| else: |
| summary += "\nLPs are ecstatic. You successfully managed the fund!" |
| |
| return VcGeminiObservation( |
| observation_text=summary, |
| inbox=[], |
| data={"tvpi": tvpi, "final_reward": final_reward, "portfolio": self.portfolio}, |
| done=True, |
| reward=final_reward |
| ) |
| |
| def _run_ipo_phase(self) -> float: |
| total = 0.0 |
| for p in self.portfolio: |
| if p["active"]: |
| |
| exit_value = p["invested_amount"] * p["true_potential_multiplier"] |
| total += exit_value |
| p["active"] = False |
| p["paper_multiplier"] = p["true_potential_multiplier"] |
| else: |
| |
| pass |
| return total |
|
|
| def _get_target_scenario(self, target_name: str): |
| target = target_name.lower().replace(" ", "") |
| for s in self.inbox_pitches: |
| if s["startup_name"].lower().replace(" ", "") in target: |
| return s |
| return None |
|
|
| def step(self, action: VcGeminiAction) -> VcGeminiObservation: |
| self._state.step_count += 1 |
| |
| a_type = action.action_type |
| params = action.parameters |
| obs_text = "" |
| inbox_msgs = [] |
| data_res = {} |
| |
| |
| self.turns_remaining -= 1 |
| |
| if self.turns_remaining <= 0: |
| |
| obs_text = "You ran out of Time (Turns) for this Quarter. The remaining startups in your inbox raised capital from Rivals. " |
| self.quarter += 1 |
| obs = self._setup_quarter() |
| obs.observation_text = obs_text + "\n\n" + obs.observation_text |
| return obs |
|
|
| |
| startup_name = params.get("startup_name", "") |
| scen = self._get_target_scenario(startup_name) |
| |
| if a_type in ["email_founder", "submit_term_sheet", "propose_syndicate", "pass_on_deal"]: |
| if not scen: |
| return VcGeminiObservation( |
| observation_text=f"Error: Could not find startup matching '{startup_name}' in your current Quarter inbox.", |
| inbox=[], data={}, done=False, reward=0.0 |
| ) |
| |
| if a_type == "pass_on_deal": |
| self.inbox_pitches = [p for p in self.inbox_pitches if p["startup_id"] != scen["startup_id"]] |
| obs_text = f"You passed on {scen['startup_name']}. Saved capital." |
| |
| if not self.inbox_pitches: |
| |
| self.quarter += 1 |
| obs = self._setup_quarter() |
| obs.observation_text = obs_text + "\n\n" + obs.observation_text |
| return obs |
|
|
| elif a_type == "read_file": |
| path = params.get("path", "") |
| if not os.path.isabs(path): |
| path = os.path.join(self.workspace_dir, path) |
| |
| if os.path.exists(path) and os.path.isfile(path): |
| with open(path, "r") as f: |
| content = f.read() |
| obs_text = f"Read {path} successfully." |
| data_res["file_content"] = content |
| else: |
| obs_text = f"File not found: {path}" |
|
|
| elif a_type == "email_founder": |
| body = params.get("body", "").lower() |
| if "valuation" in body or "price" in body or "expectations" in body: |
| inbox_msgs.append({ |
| "from": f"{scen['startup_name']} Founder", |
| "subject": "Re: Diligence", |
| "body": scen["founder_hints"]["valuation_hint"] |
| }) |
| obs_text = "You emailed the founder. They replied instantly." |
| else: |
| inbox_msgs.append({ |
| "from": f"{scen['startup_name']} Founder", |
| "subject": "Re: Diligence", |
| "body": "Happy to answer any specific questions! Are you wondering about our Valuation expectations or Board dynamics?" |
| }) |
| obs_text = "You emailed the founder. They replied." |
|
|
| elif a_type == "email_competitor": |
| comp_id = params.get("competitor_id", "") |
| body = params.get("body", "").lower() |
| |
| |
| if "syndicate" in body or "split" in body: |
| inbox_msgs.append({ |
| "from": comp_id, |
| "subject": "Re: Syndicate", |
| "body": "We are open to a 50/50 split on the round if the valuation is reasonable. Send over the Term Sheet draft." |
| }) |
| else: |
| inbox_msgs.append({ |
| "from": comp_id, |
| "subject": "Re: Inquiry", |
| "body": "Not sure what you mean. We aren't sharing diligence notes." |
| }) |
| obs_text = f"You emailed competitor {comp_id}. Wait for their reply in your inbox." |
|
|
| elif a_type == "sell_secondary_shares": |
| |
| port_target = None |
| for p in self.portfolio: |
| if p["active"] and p["startup_name"].lower().replace(" ", "") in target: |
| port_target = p |
| break |
| |
| if not port_target: |
| obs_text = f"Error: You do not own active shares in '{startup_name}'." |
| else: |
| |
| paper_val = port_target["invested_amount"] * port_target["paper_multiplier"] |
| sale_price = paper_val * 0.60 |
| |
| self.fund_budget += sale_price |
| port_target["active"] = False |
| |
| |
| rvpi_realized = (sale_price / port_target["invested_amount"]) - 1.0 |
| obs_text = f"Successful Secondary Sale! You dumped '{startup_target['startup_name']}' shares to a PE firm for ${sale_price:,.2f} (a 40% illiquidity discount applied). This capital is added back to your budget." |
| return VcGeminiObservation( |
| observation_text=obs_text, inbox=[], data={}, done=False, reward=rvpi_realized * 0.5 |
| ) |
|
|
| elif a_type == "submit_term_sheet": |
| pre_money = float(params.get("valuation", 0.0)) |
| amount = float(params.get("amount", 0.0)) |
| board_seats = int(params.get("board_seats", 1)) |
| win_conds = scen["win_conditions"] |
| |
| if amount > self.fund_budget: |
| obs_text = f"You don't have ${amount} left in your fund! You only have ${self.fund_budget}. The deal fell through." |
| elif amount < (self.fund_budget * 0.10): |
| obs_text = f"Founder says: 'We need a Lead VC to buy at least a 10% chunk. Your check size is too small.' Deal lost." |
| self.inbox_pitches = [p for p in self.inbox_pitches if p["startup_id"] != scen["startup_id"]] |
| elif board_seats > win_conds["max_board_seats"]: |
| obs_text = f"Founder says: 'We can't sign this. We only allow {win_conds['max_board_seats']} board seats.' Deal lost." |
| self.inbox_pitches = [p for p in self.inbox_pitches if p["startup_id"] != scen["startup_id"]] |
| elif pre_money < win_conds["min_valuation"]: |
| obs_text = f"Founder says: 'This valuation is insulting. We are passing.' Deal lost." |
| self.inbox_pitches = [p for p in self.inbox_pitches if p["startup_id"] != scen["startup_id"]] |
| else: |
| |
| equity_percent = amount / (pre_money + amount) |
| self.portfolio.append({ |
| "startup_name": scen["startup_name"], |
| "invested_amount": amount, |
| "equity_percent": equity_percent, |
| "paper_multiplier": 1.0, |
| "true_potential_multiplier": scen["true_potential_multiplier"], |
| "active": True |
| }) |
| self.fund_budget -= amount |
| self.inbox_pitches = [p for p in self.inbox_pitches if p["startup_id"] != scen["startup_id"]] |
| obs_text = f"Founder says: 'We accept your term sheet!' You invested ${amount:,.2f} for {equity_percent*100:.1f}% equity in {scen['startup_name']}." |
| |
| if not self.inbox_pitches: |
| self.quarter += 1 |
| obs = self._setup_quarter() |
| obs.observation_text = obs_text + "\n\n" + obs.observation_text |
| return obs |
|
|
| elif a_type == "wait": |
| obs_text = "You waited a turn." |
| else: |
| obs_text = f"Invalid action_type: {a_type}" |
|
|
| return VcGeminiObservation( |
| observation_text=obs_text, |
| inbox=inbox_msgs, |
| data=data_res, |
| done=False, |
| reward=0.0, |
| metadata={"step": self._state.step_count, "quarter": self.quarter, "turns_left": self.turns_remaining} |
| ) |
|
|
| @property |
| def state(self) -> State: |
| return self._state |
|
|