235 lines
12 KiB
Python
235 lines
12 KiB
Python
#!/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()
|