simbiot-harness/scripts/market_sim.py

235 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Рыночная симуляция Simbiot: конкуренты + клиенты + сценарии входа (t-261009123019-a32bb3).
Модель поверх расширенного шаблона гипотез (docs/product-hypothesis-template.md):
- конкуренты: цены, качество, доли (logit-выбор клиента);
- клиенты: сегменты с WTP, ценностью продукта и оттоком подписки;
- Simbiot: вход по сценариям (тёплый контур / presale-воронка / микс + сессии $150);
- метрика: E[ΔAUM] = revenue − CAC − burn за 12 мес, Монте-Карло + чувствительность.
Калибровка (v0.2, после sanity-чека):
- подписка $100/mo — РЕКУРРЕНТНО (биллинг на активных, не разово);
- logit_scale=100 у.е.: холодная сквозная конверсия ≈0.5–1.2% — верх плана SALES-PLAN-1000-US;
- тёплая конверсия ≈20–40% (буст доверия на value) — не 77%;
- допущения помечены в PARAMS, чувствительность — отдельным прогоном (--sens).
Запуск: ./venv/bin/python scripts/market_sim.py [--trials N] [--seed S] [--md] [--sens]
"""
from __future__ import annotations
import argparse
import copy
import math
import random
import statistics as st
from dataclasses import dataclass
BASE = {
# Экономика харнеса (замер cli.py aum 09.10: burn 7д=28.55 у.е.)
"aum0": 26.7, # у.е., docs/aum/balances.json
"burn_month_base": 28.55 / 7 * 30.4, # ≈124 у.е./мес — замер стора (BudgetSpent-оценка)
"burn_per_session": 5.0, # допущение: токены 1 сессии агента (60 мин)
"burn_per_sub_extra": 1.0, # допущение: доп. тики агента на активного подписчика/мес
# Presale (директива m-516173408): $100/mo, без скидок, места total=100
"presale_price": 100.0,
"seats_total": 100,
"monthly_churn": 0.08, # допущение: отток AI-подписок 8%/мес (чувствительность: 5–12%)
# Сессии (ранбук session_productize): $150 one_time
"session_price": 150.0,
# Конкуренты (блок 1 шаблона: cheapest/closest/premium)
"competitors": [
# name, цена $/мес, value (полезность у.е./мес для среднего клиента), share0 — априорная доля
{"name": "DIY (LLM-подписка + ручные промпты)", "price": 25.0, "value": 30.0, "share0": 0.45},
{"name": "Upwork/AI-фрилансер (часами)", "price": 100.0, "value": 110.0, "share0": 0.30},
{"name": "AI-консультант соло (сессии)", "price": 150.0, "value": 140.0, "share0": 0.15},
{"name": "AI-студия (проектно)", "price": 300.0, "value": 260.0, "share0": 0.10},
],
# Simbiot: базовая value для сегмента s2; сегменты масштабируют (value_mult)
"simbiot_value_base": 130.0,
"logit_scale": 100.0, # делитель (value−price): калибрует конверсию (см. шапку)
# Сегменты (блок 2-3 шаблона): weight, WTP, множитель ценности Simbiot
"segments": [
{"name": "s1 соло-основатель (рутина, платит $50–200)", "w": 0.35, "wtp": 130.0, "vm": 1.0},
{"name": "s2 индиз/автоматизатор (платит $30–80)", "w": 0.40, "wtp": 60.0, "vm": 0.75},
{"name": "s3 операционка малого бизнеса (платит $150–500)", "w": 0.25, "wtp": 300.0, "vm": 1.3},
],
# Сценарии входа: n_warm/мес; visitors/мес (presale-лендинг); paid_share и cac — платный трафик
"scenarios": {
"A_warm_only": {"n_warm": 15, "visitors": [0] * 12, "paid_share": 0.0, "cac": 0.0,
"session_rate": 0.12, "warm_mult": 1.3},
"D_no_entry": {"n_warm": 0, "visitors": [0] * 12, "paid_share": 0.0, "cac": 0.0,
"session_rate": 0.0, "warm_mult": 1.0},
"B_presale_funnel": {"n_warm": 12,
"visitors": [5000, 8000, 12000, 18000, 25000, 35000, 45000, 55000, 65000, 75000, 85000, 95000],
"paid_share": 0.6, "cac": 22.0, "session_rate": 0.05, "warm_mult": 1.2},
"C_mixed": {"n_warm": 15,
"visitors": [3000, 6000, 10000, 15000, 22000, 30000, 40000, 50000, 60000, 70000, 80000, 90000],
"paid_share": 0.5, "cac": 22.0, "session_rate": 0.12, "warm_mult": 1.3},
},
"reach_in_market": 0.04, # допущение: 4% посетителей реально «в рынке» (чувствительность ×0.5/×2)
"horizon_months": 12,
}
MC_TRIALS = 400
@dataclass
class TrialResult:
revenue_sub: float
revenue_sessions: float
cac_total: float
burn_total: float
d_aum: float
subs_final: int
sessions_total: int
payback_month: int # 0 = не окупился за горизонт
def logit_share(options, scale):
us = {n: (v - p) / scale for n, v, p in options}
mx = max(us.values())
exps = {n: math.exp(u - mx) for n, u in us.items()}
z = sum(exps.values())
return {n: e / z for n, e in exps.items()}
def share_simbiot(seg, p, warm_mult):
opts = [(c["name"], c["value"], c["price"]) for c in p["competitors"]]
val = p["simbiot_value_base"] * seg["vm"] * warm_mult
opts.append(("simbiot", val, p["presale_price"]))
return logit_share(opts, p["logit_scale"])["simbiot"]
def run_trial(rng, p, sc):
H = p["horizon_months"]
subs = 0
rev_sub = rev_sess = cac_t = burn_t = 0.0
sessions = 0
payback = 0
cum = 0.0
segs, ws = p["segments"], [s["w"] for s in p["segments"]]
for m in range(H):
# отток
subs = int(round(subs * (1 - p["monthly_churn"])))
# рекуррентный биллинг активных
rev_sub += subs * p["presale_price"]
# тёплые лиды
for _ in range(sc["n_warm"]):
seg = rng.choices(segs, weights=ws)[0]
if rng.random() < share_simbiot(seg, p, sc["warm_mult"]) and subs < p["seats_total"]:
subs += 1
# сессии $150 (разовый вход, часть тёплых)
exp_booked = int(round(sc["n_warm"] * sc["session_rate"]))
booked = rng.randint(0, exp_booked) if exp_booked > 0 else 0
sessions += booked
rev_sess += booked * p["session_price"]
# presale-воронка
vis = sc["visitors"][m]
if vis:
for _ in range(int(vis * p["reach_in_market"])):
seg = rng.choices(segs, weights=ws)[0]
if rng.random() < share_simbiot(seg, p, 1.0) and subs < p["seats_total"]:
subs += 1
if rng.random() < sc["paid_share"]:
cac_t += sc["cac"]
burn_t += p["burn_month_base"] + subs * p["burn_per_sub_extra"] + booked * p["burn_per_session"]
cum = rev_sub + rev_sess - cac_t - burn_t
if payback == 0 and cum > 0:
payback = m + 1
return TrialResult(rev_sub, rev_sess, cac_t, burn_t, cum, subs, sessions, payback)
def run_scenario(name, sc, p, trials, seed):
rng = random.Random(seed)
rs = [run_trial(rng, p, sc) for _ in range(trials)]
d = [r.d_aum for r in rs]
aum0 = p["aum0"]
base_burn = p["burn_month_base"] * p["horizon_months"] # «не входить»: burn всё равно идёт
return {
"name": name,
"E[rev sub]": st.mean(r.revenue_sub for r in rs),
"E[rev sess]": st.mean(r.revenue_sessions for r in rs),
"E[CAC]": st.mean(r.cac_total for r in rs),
"E[burn]": st.mean(r.burn_total for r in rs),
"E[dAUM]": st.mean(d),
"P(dAUM>0)": sum(1 for x in d if x > 0) / len(d),
"P(инкремент>0 vs без входа)": sum(1 for x in d if x > -base_burn) / len(d),
"P(фильтр +1% AUM)": sum(1 for x in d if x >= 0.01 * aum0) / len(d),
"subs_final med": st.median(r.subs_final for r in rs),
"sessions med": st.median(r.sessions_total for r in rs),
"payback med": st.median([r.payback_month for r in rs if r.payback_month > 0] or [0]),
"payback>0": sum(1 for r in rs if r.payback_month > 0) / len(rs),
}
def sens_table(trials, seed):
"""Чувствительность E[dAUM] сценария C_mixed к ключевым допущениям."""
rows = []
variants = [
("база", {}),
("churn 5%", {"monthly_churn": 0.05}),
("churn 12%", {"monthly_churn": 0.12}),
("logit_scale 80 (быстрее верят)", {"logit_scale": 80.0}),
("logit_scale 130 (скептичнее)", {"logit_scale": 130.0}),
("value −15%", {"simbiot_value_base": 130.0 * 0.85}),
("value +15%", {"simbiot_value_base": 130.0 * 1.15}),
("reach ×0.5 (трафик холоднее)", {"reach_in_market": 0.02}),
("reach ×2 (трафик горячее)", {"reach_in_market": 0.08}),
]
for label, patch in variants:
p = copy.deepcopy(BASE)
p.update(patch)
r = run_scenario("C_mixed", p["scenarios"]["C_mixed"], p, trials, seed)
rows.append((label, r["E[dAUM]"], r["P(dAUM>0)"], r["subs_final med"]))
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--trials", type=int, default=MC_TRIALS)
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--md", action="store_true")
ap.add_argument("--sens", action="store_true")
a = ap.parse_args()
p = BASE
print(f"# Рыночная симуляция Simbiot: {a.trials} trial'ов, seed={a.seed}, горизонт {p['horizon_months']} мес")
print(f"# база: AUM={p['aum0']} у.е., burn={p['burn_month_base']:.1f}/мес (~{p['burn_month_base']*12:.0f}/год);")
print(f"# presale $100/mo рекуррентно (мест {p['seats_total']}), сессия $150; отток {p['monthly_churn']:.0%}/мес")
keys = ["E[rev sub]", "E[rev sess]", "E[CAC]", "E[burn]", "E[dAUM]", "P(dAUM>0)",
"P(инкремент>0 vs без входа)", "P(фильтр +1% AUM)", "subs_final med",
"sessions med", "payback med", "payback>0"]
rows = [run_scenario(n, sc, p, a.trials, a.seed) for n, sc in p["scenarios"].items()]
if a.md:
print("| сценарий | " + " | ".join(keys) + " |")
print("|---|" + "---|" * len(keys))
for r in rows:
print(f"| {r['name']} | " + " | ".join(
(f"{r[k]:.0f}" if abs(r[k]) >= 10 else f"{r[k]:.3f}") if isinstance(r[k], float) else str(r[k])
for k in keys) + " |")
else:
for r in rows:
print("\n===", r["name"])
for k in keys:
v = r[k]
print(f" {k:30s} {v:,.1f}" if isinstance(v, float) else f" {k:30s} {v}")
if a.sens:
print("\n# Чувствительность (C_mixed): E[dAUM], P(>0), подписчики")
print("| вариант | E[dAUM] у.е. | P(dAUM>0) | subs med |")
print("|---|---|---|---|")
for label, e, pp, subs in sens_table(a.trials, a.seed):
print(f"| {label} | {e:,.0f} | {pp:.2f} | {subs:.0f} |")
# снимок долей (сегмент s1, тёплый буст 1.3)
print("\n# Доли выбора, сегмент s1, тёплый контур (буст 1.3):")
s1 = p["segments"][0]
opts = [(c["name"], c["value"], c["price"]) for c in p["competitors"]]
opts.append(("Simbiot $100/mo", p["simbiot_value_base"] * s1["vm"] * 1.3, p["presale_price"]))
for n, sh in sorted(logit_share(opts, p["logit_scale"]).items(), key=lambda x: -x[1]):
print(f" {sh*100:5.1f}% {n}")
print("# то же, холодный (буст 1.0):")
opts[-1] = ("Simbiot $100/mo", p["simbiot_value_base"] * s1["vm"], p["presale_price"])
for n, sh in sorted(logit_share(opts, p["logit_scale"]).items(), key=lambda x: -x[1]):
print(f" {sh*100:5.1f}% {n}")
if __name__ == "__main__":
main()