* feat: C-Suite expansion — 8 new executive advisory roles Add COO, CPO, CMO, CFO, CRO, CISO, CHRO advisors and Executive Mentor. Expands C-level advisory from 2 to 10 roles with 74 total files. Each role includes: - SKILL.md (lean, <5KB, ~1200 tokens for context efficiency) - Reference docs (loaded on demand, not at startup) - Python analysis scripts (stdlib only, runnable CLI) Executive Mentor features /em: slash commands (challenge, board-prep, hard-call, stress-test, postmortem) with devil's advocate agent. 21 Python tools, 24 reference frameworks, 28,379 total lines. All SKILL.md files combined: ~17K tokens (8.5% of 200K context window). Badge: 88 → 116 skills * feat: C-Suite orchestration layer + 18 complementary skills ORCHESTRATION (new): - cs-onboard: Founder interview → company-context.md - chief-of-staff: Routing, synthesis, inter-agent orchestration - board-meeting: 6-phase multi-agent deliberation protocol - decision-logger: Two-layer memory (raw transcripts + approved decisions) - agent-protocol: Inter-agent invocation with loop prevention - context-engine: Company context loading + anonymization CROSS-CUTTING CAPABILITIES (new): - board-deck-builder: Board/investor update assembly - scenario-war-room: Cascading multi-variable what-if modeling - competitive-intel: Systematic competitor tracking + battlecards - org-health-diagnostic: Cross-functional health scoring (8 dimensions) - ma-playbook: M&A strategy (acquiring + being acquired) - intl-expansion: International market entry frameworks CULTURE & COLLABORATION (new): - culture-architect: Values → behaviors, culture code, health assessment - company-os: EOS/Scaling Up operating system selection + implementation - founder-coach: Founder development, delegation, blind spots - strategic-alignment: Strategy cascade, silo detection, alignment scoring - change-management: ADKAR-based change rollout framework - internal-narrative: One story across employees/investors/customers UPGRADES TO EXISTING ROLES: - All 10 roles get reasoning technique directives - All 10 roles get company-context.md integration - All 10 roles get board meeting isolation rules - CEO gets stage-adaptive temporal horizons (seed→C) Key design decisions: - Two-layer memory prevents hallucinated consensus from rejected ideas - Phase 2 isolation: agents think independently before cross-examination - Executive Mentor (The Critic) sees all perspectives, others don't - 25 Python tools total (stdlib only, no dependencies) 52 new files, 10 modified, 10,862 new lines. Total C-suite ecosystem: 134 files, 39,131 lines. * fix: connect all dots — Chief of Staff routes to all 28 skills - Added complementary skills registry to routing-matrix.md - Chief of Staff SKILL.md now lists all 28 skills in ecosystem - Added integration tables to scenario-war-room and competitive-intel - Badge: 116 → 134 skills - README: C-Level Advisory count 10 → 28 Quality audit passed: ✅ All 10 roles: company-context, reasoning, isolation, invocation ✅ All 6 phases in board meeting ✅ Two-layer memory with DO_NOT_RESURFACE ✅ Loop prevention (no self-invoke, max depth 2, no circular) ✅ All /em: commands present ✅ All complementary skills cross-reference roles ✅ Chief of Staff routes to every skill in ecosystem * refactor: CEO + CTO advisors upgraded to C-suite parity Both roles now match the structural standard of all new roles: - CEO: 11.7KB → 6.8KB SKILL.md (heavy content stays in references) - CTO: 10KB → 7.2KB SKILL.md (heavy content stays in references) Added to both: - Integration table (who they work with and when) - Key diagnostic questions - Structured metrics dashboard table - Consistent section ordering (Keywords → Quick Start → Responsibilities → Questions → Metrics → Red Flags → Integration → Reasoning → Context) CEO additions: - Stage-adaptive temporal horizons (seed=3m/6m/12m → B+=1y/3y/5y) - Cross-references to culture-architect and board-deck-builder CTO additions: - Key Questions section (7 diagnostic questions) - Structured metrics table (DORA + debt + team + architecture + cost) - Cross-references to all peer roles All 10 roles now pass structural parity: ✅ Keywords ✅ QuickStart ✅ Questions ✅ Metrics ✅ RedFlags ✅ Integration * feat: add proactive triggers + output artifacts to all 10 roles Every C-suite role now specifies: - Proactive Triggers: 'surface these without being asked' — context-driven early warnings that make advisors proactive, not reactive - Output Artifacts: concrete deliverables per request type (what you ask → what you get) CEO: runway alerts, board prep triggers, strategy review nudges CTO: deploy frequency monitoring, tech debt thresholds, bus factor flags COO: blocker detection, scaling threshold warnings, cadence gaps CPO: retention curve monitoring, portfolio dog detection, research gaps CMO: CAC trend monitoring, positioning gaps, budget staleness CFO: runway forecasting, burn multiple alerts, scenario planning gaps CRO: NRR monitoring, pipeline coverage, pricing review triggers CISO: audit overdue alerts, compliance gaps, vendor risk CHRO: retention risk, comp band gaps, org scaling thresholds Executive Mentor: board prep triggers, groupthink detection, hard call surfacing This transforms the C-suite from reactive advisors into proactive partners. * feat: User Communication Standard — structured output for all roles Defines 3 output formats in agent-protocol/SKILL.md: 1. Standard Output: Bottom Line → What → Why → How to Act → Risks → Your Decision 2. Proactive Alert: What I Noticed → Why It Matters → Action → Urgency (🔴🟡⚪) 3. Board Meeting: Decision Required → Perspectives → Agree/Disagree → Critic → Action Items 10 non-negotiable rules: - Bottom line first, always - Results and decisions only (no process narration) - What + Why + How for every finding - Actions have owners and deadlines ('we should consider' is banned) - Decisions framed as options with trade-offs - Founder is the highest authority — roles recommend, founder decides - Risks are concrete (if X → Y, costs $Z) - Max 5 bullets per section - No jargon without explanation - Silence over fabricated updates All 10 roles reference this standard. Chief of Staff enforces it as a quality gate. Board meeting Phase 4 uses the Board Meeting Output format. * feat: Internal Quality Loop — verification before delivery No role presents to the founder without passing verification: Step 1: Self-Verification (every role, every time) - Source attribution: where did each data point come from? - Assumption audit: [VERIFIED] vs [ASSUMED] tags on every finding - Confidence scoring: 🟢 high / 🟡 medium / 🔴 low per finding - Contradiction check against company-context + decision log - 'So what?' test: every finding needs a business consequence Step 2: Peer Verification (cross-functional) - Financial claims → CFO validates math - Revenue projections → CRO validates pipeline backing - Technical feasibility → CTO validates - People/hiring impact → CHRO validates - Skip for single-domain, low-stakes questions Step 3: Critic Pre-Screen (high-stakes only) - Irreversible decisions, >20% runway impact, strategy changes - Executive Mentor finds weakest point before founder sees it - Suspicious consensus triggers mandatory pre-screen Step 4: Course Correction (after founder feedback) - Approve → log + assign actions - Modify → re-verify changed parts - Reject → DO_NOT_RESURFACE + learn why - 30/60/90 day post-decision review Board meeting contributions now require self-verified format with confidence tags and source attribution on every finding. * fix: resolve PR review issues 1, 4, and minor observation Issue 1: c-level-advisor/CLAUDE.md — completely rewritten - Was: 2 skills (CEO, CTO only), dated Nov 2025 - Now: full 28-skill ecosystem map with architecture diagram, all roles/orchestration/cross-cutting/culture skills listed, design decisions, integration with other domains Issue 4: Root CLAUDE.md — updated all stale counts - 87 → 134 skills across all 3 references - C-Level: 2 → 33 (10 roles + 5 mentor commands + 18 complementary) - Tool count: 160+ → 185+ - Reference count: 200+ → 250+ Minor observation: Documented plugin.json convention - Explained in c-level-advisor/CLAUDE.md that only executive-mentor has plugin.json because only it has slash commands (/em: namespace) - Other skills are invoked by name through Chief of Staff or directly Also fixed: README.md 88+ → 134 in two places (first line + skills section) * fix: update all plugin/index registrations for 28-skill C-suite 1. c-level-advisor/.claude-plugin/plugin.json — v2.0.0 - Was: 2 skills, generic description - Now: all 28 skills listed with descriptions, all 25 scripts, namespace 'cs', full ecosystem description 2. .codex/skills-index.json — added 18 complementary skills - Was: 10 roles only - Now: 28 total c-level entries (10 roles + 6 orchestration + 6 cross-cutting + 6 culture) - Each with full description for skill discovery 3. .claude-plugin/marketplace.json — updated c-level-skills entry - Was: generic 2-skill description - Now: v2.0.0, full 28-skill ecosystem description, skills_count: 28, scripts_count: 25 * feat: add root SKILL.md for c-level-advisor ClawHub package --------- Co-authored-by: Leo <leo@openclaw.ai>
548 lines
21 KiB
Python
548 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Portfolio Analyzer — Product portfolio BCG matrix classification and investment analysis.
|
|
|
|
For each product, classifies into BCG quadrant (Star, Cash Cow, Question Mark, Dog)
|
|
and generates investment recommendations (Invest / Maintain / Kill).
|
|
|
|
Usage:
|
|
python portfolio_analyzer.py # Run with built-in sample data
|
|
python portfolio_analyzer.py --input data.json # Run with your data
|
|
python portfolio_analyzer.py --json # Output raw JSON
|
|
|
|
JSON input format: see sample_data() function below.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import argparse
|
|
from typing import Optional
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sample data
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def sample_data() -> dict:
|
|
"""
|
|
Sample portfolio. Replace with real product data.
|
|
|
|
Fields:
|
|
name Product name
|
|
revenue_quarterly Current quarter revenue (any consistent currency)
|
|
revenue_prev_q Revenue last quarter (for QoQ calculation)
|
|
market_growth_pct Annual market growth rate (percent, e.g. 12.5 for 12.5%)
|
|
your_market_share Your estimated market share (percent, e.g. 8.0 for 8%)
|
|
largest_competitor_share Largest competitor's share (percent)
|
|
eng_capacity_pct % of total engineering capacity allocated (0-100)
|
|
d30_retention Optional D30 retention rate (decimal, e.g. 0.45)
|
|
nps Optional NPS score (-100 to 100)
|
|
notes Optional free text notes for the report
|
|
"""
|
|
return {
|
|
"company": "Acme Corp",
|
|
"total_engineering_headcount": 45,
|
|
"products": [
|
|
{
|
|
"name": "CorePlatform",
|
|
"revenue_quarterly": 480000,
|
|
"revenue_prev_q": 430000,
|
|
"market_growth_pct": 22.0,
|
|
"your_market_share": 18.0,
|
|
"largest_competitor_share": 12.0,
|
|
"eng_capacity_pct": 35,
|
|
"d30_retention": 0.61,
|
|
"nps": 52,
|
|
"notes": "Our flagship. Leading market share in fast-growing segment.",
|
|
},
|
|
{
|
|
"name": "ReportingModule",
|
|
"revenue_quarterly": 290000,
|
|
"revenue_prev_q": 285000,
|
|
"market_growth_pct": 5.0,
|
|
"your_market_share": 22.0,
|
|
"largest_competitor_share": 18.0,
|
|
"eng_capacity_pct": 25,
|
|
"d30_retention": 0.58,
|
|
"nps": 38,
|
|
"notes": "Mature product, strong margins, slow market.",
|
|
},
|
|
{
|
|
"name": "MobileApp",
|
|
"revenue_quarterly": 95000,
|
|
"revenue_prev_q": 78000,
|
|
"market_growth_pct": 35.0,
|
|
"your_market_share": 3.5,
|
|
"largest_competitor_share": 24.0,
|
|
"eng_capacity_pct": 28,
|
|
"d30_retention": 0.31,
|
|
"nps": 22,
|
|
"notes": "High growth market. We're far behind on share. Bet or exit.",
|
|
},
|
|
{
|
|
"name": "LegacyConnector",
|
|
"revenue_quarterly": 62000,
|
|
"revenue_prev_q": 68000,
|
|
"market_growth_pct": -3.0,
|
|
"your_market_share": 8.0,
|
|
"largest_competitor_share": 35.0,
|
|
"eng_capacity_pct": 12,
|
|
"d30_retention": 0.42,
|
|
"nps": 14,
|
|
"notes": "Declining market. Customers are on long-term contracts.",
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BCG Classification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Growth rate threshold: markets growing faster than this are "high growth"
|
|
GROWTH_THRESHOLD_PCT = 10.0
|
|
|
|
# Market share ratio threshold: ratio > 1.0 means you lead the market
|
|
SHARE_RATIO_THRESHOLD = 1.0
|
|
|
|
|
|
def bcg_quadrant(market_growth_pct: float, share_ratio: float) -> str:
|
|
high_growth = market_growth_pct >= GROWTH_THRESHOLD_PCT
|
|
leading_share = share_ratio >= SHARE_RATIO_THRESHOLD
|
|
|
|
if high_growth and leading_share:
|
|
return "Star"
|
|
elif not high_growth and leading_share:
|
|
return "Cash Cow"
|
|
elif high_growth and not leading_share:
|
|
return "Question Mark"
|
|
else:
|
|
return "Dog"
|
|
|
|
|
|
def quadrant_emoji(quadrant: str) -> str:
|
|
return {
|
|
"Star": "⭐",
|
|
"Cash Cow": "🐄",
|
|
"Question Mark": "❓",
|
|
"Dog": "🐕",
|
|
}.get(quadrant, "?")
|
|
|
|
|
|
def investment_posture(quadrant: str, qoq_growth: float, retention: Optional[float]) -> str:
|
|
"""
|
|
Invest / Maintain / Kill recommendation with nuance.
|
|
"""
|
|
if quadrant == "Star":
|
|
return "Invest"
|
|
elif quadrant == "Cash Cow":
|
|
# If cash cow is declining fast or retention is poor, consider killing
|
|
if qoq_growth < -0.10 or (retention is not None and retention < 0.30):
|
|
return "Kill"
|
|
return "Maintain"
|
|
elif quadrant == "Question Mark":
|
|
# Fast QoQ growth signals the bet might pay off → Invest
|
|
# Flat or slow QoQ with weak retention → Kill
|
|
if qoq_growth >= 0.15 and (retention is None or retention >= 0.25):
|
|
return "Invest"
|
|
elif qoq_growth < 0.05 or (retention is not None and retention < 0.20):
|
|
return "Kill"
|
|
return "Evaluate" # Needs explicit strategic decision
|
|
else: # Dog
|
|
if qoq_growth > 0.10 and (retention is None or retention >= 0.35):
|
|
return "Evaluate" # Surprising momentum — verify before killing
|
|
return "Kill"
|
|
|
|
|
|
def posture_color(posture: str) -> str:
|
|
return {
|
|
"Invest": "✓",
|
|
"Maintain": "◑",
|
|
"Kill": "✗",
|
|
"Evaluate": "⚠",
|
|
}.get(posture, "?")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Product analysis
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def analyze_product(p: dict) -> dict:
|
|
revenue_q = p.get("revenue_quarterly", 0)
|
|
revenue_prev = p.get("revenue_prev_q", revenue_q)
|
|
qoq_growth = (revenue_q - revenue_prev) / revenue_prev if revenue_prev else 0.0
|
|
|
|
your_share = p.get("your_market_share", 0)
|
|
competitor_share = p.get("largest_competitor_share", 1)
|
|
share_ratio = your_share / competitor_share if competitor_share else 0.0
|
|
|
|
market_growth = p.get("market_growth_pct", 0)
|
|
retention = p.get("d30_retention")
|
|
nps = p.get("nps")
|
|
eng_pct = p.get("eng_capacity_pct", 0)
|
|
|
|
quadrant = bcg_quadrant(market_growth, share_ratio)
|
|
posture = investment_posture(quadrant, qoq_growth, retention)
|
|
|
|
# Alignment score: how well does engineering investment match the recommended posture?
|
|
# Invest products should have high eng allocation; Kill products should have low.
|
|
alignment_score = _compute_alignment(posture, eng_pct)
|
|
|
|
return {
|
|
"name": p.get("name", "Unknown"),
|
|
"revenue_quarterly": revenue_q,
|
|
"revenue_prev_q": revenue_prev,
|
|
"qoq_growth": qoq_growth,
|
|
"market_growth_pct": market_growth,
|
|
"your_market_share": your_share,
|
|
"largest_competitor_share": competitor_share,
|
|
"share_ratio": share_ratio,
|
|
"eng_capacity_pct": eng_pct,
|
|
"d30_retention": retention,
|
|
"nps": nps,
|
|
"quadrant": quadrant,
|
|
"posture": posture,
|
|
"alignment_score": alignment_score,
|
|
"notes": p.get("notes", ""),
|
|
"findings": _product_findings(quadrant, posture, qoq_growth, share_ratio,
|
|
market_growth, retention, nps, eng_pct),
|
|
}
|
|
|
|
|
|
def _compute_alignment(posture: str, eng_pct: float) -> float:
|
|
"""
|
|
Returns 0.0-1.0 score. High = engineering allocation matches strategic posture.
|
|
"""
|
|
targets = {"Invest": 0.35, "Maintain": 0.15, "Kill": 0.05, "Evaluate": 0.20}
|
|
target = targets.get(posture, 0.20)
|
|
deviation = abs(eng_pct / 100 - target)
|
|
return max(0.0, 1.0 - (deviation / 0.35))
|
|
|
|
|
|
def _product_findings(
|
|
quadrant: str, posture: str,
|
|
qoq_growth: float, share_ratio: float, market_growth: float,
|
|
retention: Optional[float], nps: Optional[int], eng_pct: float
|
|
) -> list:
|
|
findings = []
|
|
|
|
if quadrant == "Star":
|
|
if eng_pct < 30:
|
|
findings.append(f"⚠ Star product getting only {eng_pct}% of eng capacity — likely underinvested. Stars need fuel.")
|
|
else:
|
|
findings.append(f"✓ Star product with {eng_pct}% eng allocation — appropriate investment.")
|
|
if share_ratio < 1.5:
|
|
findings.append(f"◑ Share ratio {share_ratio:.1f}x — leading but not dominant. Accelerate to widen the gap.")
|
|
else:
|
|
findings.append(f"✓ Share ratio {share_ratio:.1f}x — strong lead. Defend aggressively.")
|
|
|
|
elif quadrant == "Cash Cow":
|
|
if eng_pct > 25:
|
|
findings.append(f"⚠ Cash Cow getting {eng_pct}% of eng — overinvested. Reduce to 10-15% max. Redeploy to Stars.")
|
|
else:
|
|
findings.append(f"✓ Cash Cow with {eng_pct}% eng — appropriate. Don't innovate, just maintain.")
|
|
if qoq_growth < -0.05:
|
|
findings.append(f"⚠ Revenue declining {abs(qoq_growth):.0%} QoQ — monitor for transition to Dog.")
|
|
else:
|
|
findings.append(f"✓ Revenue stable (QoQ: {qoq_growth:+.0%}) — milk this.")
|
|
|
|
elif quadrant == "Question Mark":
|
|
findings.append(f"⚠ Fast market ({market_growth:.0f}% growth) but only {share_ratio:.1f}x relative share.")
|
|
findings.append(f" Decision required: Invest to capture share or exit. 'Maintain' loses share every quarter.")
|
|
if qoq_growth >= 0.15:
|
|
findings.append(f"✓ QoQ growth {qoq_growth:+.0%} — momentum building. Investment may be justified.")
|
|
elif qoq_growth < 0.05:
|
|
findings.append(f"✗ QoQ growth {qoq_growth:+.0%} — stalled despite hot market. Strong exit signal.")
|
|
|
|
elif quadrant == "Dog":
|
|
findings.append(f"✗ Low share ({share_ratio:.1f}x) in slow/declining market ({market_growth:.0f}% growth).")
|
|
if eng_pct > 10:
|
|
findings.append(f"✗ Dog consuming {eng_pct}% of eng capacity. Set a sunset date. Migrate customers.")
|
|
if qoq_growth > 0:
|
|
findings.append(f"◑ Slight QoQ growth ({qoq_growth:+.0%}) — verify whether this is genuine or contract timing.")
|
|
|
|
if retention is not None:
|
|
if retention < 0.30:
|
|
findings.append(f"✗ D30 retention {retention:.0%} — users not finding value. Weak unit economics for any posture.")
|
|
elif retention >= 0.50:
|
|
findings.append(f"✓ D30 retention {retention:.0%} — users find value. Supports investment or stable maintenance.")
|
|
|
|
if nps is not None:
|
|
if nps < 0:
|
|
findings.append(f"✗ NPS {nps} — net detractors. Word of mouth is negative. Fix before scaling.")
|
|
elif nps >= 40:
|
|
findings.append(f"✓ NPS {nps} — strong promoter base. Harness for referrals.")
|
|
|
|
return findings
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Portfolio-level analysis
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def analyze_portfolio(data: dict) -> dict:
|
|
products = [analyze_product(p) for p in data.get("products", [])]
|
|
|
|
total_revenue = sum(p["revenue_quarterly"] for p in products)
|
|
total_eng = sum(p["eng_capacity_pct"] for p in products)
|
|
|
|
# Revenue by quadrant
|
|
quadrant_revenue = {}
|
|
quadrant_eng = {}
|
|
for p in products:
|
|
q = p["quadrant"]
|
|
quadrant_revenue[q] = quadrant_revenue.get(q, 0) + p["revenue_quarterly"]
|
|
quadrant_eng[q] = quadrant_eng.get(q, 0) + p["eng_capacity_pct"]
|
|
|
|
# Portfolio health score
|
|
health = _portfolio_health(products, total_revenue, total_eng)
|
|
|
|
# Portfolio-level findings
|
|
portfolio_findings = _portfolio_findings(products, total_revenue, quadrant_revenue, quadrant_eng)
|
|
|
|
return {
|
|
"company": data.get("company", "Unknown"),
|
|
"total_engineering_headcount": data.get("total_engineering_headcount"),
|
|
"products": products,
|
|
"total_revenue_quarterly": total_revenue,
|
|
"quadrant_summary": {
|
|
q: {
|
|
"count": sum(1 for p in products if p["quadrant"] == q),
|
|
"revenue": quadrant_revenue.get(q, 0),
|
|
"revenue_pct": quadrant_revenue.get(q, 0) / total_revenue if total_revenue else 0,
|
|
"eng_pct": quadrant_eng.get(q, 0),
|
|
}
|
|
for q in ["Star", "Cash Cow", "Question Mark", "Dog"]
|
|
},
|
|
"portfolio_health_score": health,
|
|
"portfolio_findings": portfolio_findings,
|
|
}
|
|
|
|
|
|
def _portfolio_health(products: list, total_revenue: float, total_eng: float) -> float:
|
|
"""
|
|
Portfolio health 0-1. Penalizes:
|
|
- No Stars (no growth engine)
|
|
- Dogs consuming > 20% of eng
|
|
- Poor alignment scores
|
|
- Revenue concentrated in Dogs/Question Marks
|
|
"""
|
|
score = 1.0
|
|
|
|
quadrants = [p["quadrant"] for p in products]
|
|
has_star = "Star" in quadrants
|
|
has_cash_cow = "Cash Cow" in quadrants
|
|
|
|
if not has_star:
|
|
score -= 0.25 # No growth engine is a serious problem
|
|
if not has_cash_cow:
|
|
score -= 0.10 # No cash generator means funding stars from burn
|
|
|
|
# Dog eng allocation penalty
|
|
dog_eng = sum(p["eng_capacity_pct"] for p in products if p["quadrant"] == "Dog")
|
|
if dog_eng > 20:
|
|
score -= 0.20
|
|
elif dog_eng > 10:
|
|
score -= 0.10
|
|
|
|
# Revenue in dogs penalty
|
|
if total_revenue > 0:
|
|
dog_rev_pct = sum(p["revenue_quarterly"] for p in products if p["quadrant"] == "Dog") / total_revenue
|
|
if dog_rev_pct > 0.30:
|
|
score -= 0.15
|
|
|
|
# Average alignment score
|
|
avg_alignment = sum(p["alignment_score"] for p in products) / len(products) if products else 0
|
|
score -= (1 - avg_alignment) * 0.20
|
|
|
|
return max(0.0, min(1.0, score))
|
|
|
|
|
|
def _portfolio_findings(
|
|
products: list, total_revenue: float,
|
|
quadrant_revenue: dict, quadrant_eng: dict
|
|
) -> list:
|
|
findings = []
|
|
|
|
stars = [p for p in products if p["quadrant"] == "Star"]
|
|
cows = [p for p in products if p["quadrant"] == "Cash Cow"]
|
|
questions = [p for p in products if p["quadrant"] == "Question Mark"]
|
|
dogs = [p for p in products if p["quadrant"] == "Dog"]
|
|
|
|
if not stars:
|
|
findings.append("✗ CRITICAL: No Star products. You have no growth engine. Identify a Question Mark to invest in or revisit your market positioning.")
|
|
elif len(stars) == 1:
|
|
findings.append(f"◑ Single Star ({stars[0]['name']}). Portfolio is fragile — one product drives all growth. Diversify.")
|
|
else:
|
|
findings.append(f"✓ {len(stars)} Star products — healthy growth engine.")
|
|
|
|
if not cows:
|
|
findings.append("⚠ No Cash Cow products. Stars are consuming capital without a self-funding mechanism. Watch burn rate.")
|
|
else:
|
|
cow_rev = quadrant_revenue.get("Cash Cow", 0)
|
|
cow_pct = cow_rev / total_revenue if total_revenue else 0
|
|
findings.append(f"✓ Cash Cow revenue: {cow_pct:.0%} of total — funds Star investment.")
|
|
|
|
if questions:
|
|
findings.append(f"⚠ {len(questions)} Question Mark(s): {', '.join(p['name'] for p in questions)}.")
|
|
findings.append(" Each needs a binary decision: invest to win share, or exit. Set a 2-quarter deadline.")
|
|
|
|
if dogs:
|
|
dog_eng_total = sum(p["eng_capacity_pct"] for p in dogs)
|
|
findings.append(f"✗ {len(dogs)} Dog product(s): {', '.join(p['name'] for p in dogs)} consuming {dog_eng_total}% of eng capacity.")
|
|
findings.append(f" That's {dog_eng_total}% of your engineers on declining products. Set sunset dates.")
|
|
|
|
# Alignment check
|
|
misaligned = [p for p in products if p["alignment_score"] < 0.50]
|
|
if misaligned:
|
|
findings.append(f"⚠ Engineering allocation misaligned on: {', '.join(p['name'] for p in misaligned)}.")
|
|
findings.append(" Rebalance: move capacity from Dogs/Cows to Stars.")
|
|
|
|
return findings
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Report rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def fmt_currency(n: float) -> str:
|
|
if n >= 1_000_000:
|
|
return f"${n/1_000_000:.1f}M"
|
|
elif n >= 1_000:
|
|
return f"${n/1_000:.0f}K"
|
|
return f"${n:.0f}"
|
|
|
|
|
|
def render_report(result: dict) -> str:
|
|
lines = []
|
|
lines.append("=" * 65)
|
|
lines.append(f" PORTFOLIO ANALYZER — {result['company']}")
|
|
lines.append(f" Total Quarterly Revenue: {fmt_currency(result['total_revenue_quarterly'])}")
|
|
if result.get("total_engineering_headcount"):
|
|
lines.append(f" Engineering Headcount: {result['total_engineering_headcount']}")
|
|
lines.append("=" * 65)
|
|
lines.append("")
|
|
|
|
# Portfolio health
|
|
health = result["portfolio_health_score"]
|
|
bar_len = 40
|
|
filled = round(health * bar_len)
|
|
bar = "█" * filled + "░" * (bar_len - filled)
|
|
lines.append(f" Portfolio Health: {health:.0%}")
|
|
lines.append(f" [{bar}]")
|
|
lines.append("")
|
|
|
|
# Quadrant summary
|
|
lines.append(" QUADRANT SUMMARY")
|
|
lines.append(" " + "-" * 55)
|
|
header = f" {'Quadrant':<15} {'Count':>5} {'Revenue':>10} {'Rev%':>6} {'Eng%':>6}"
|
|
lines.append(header)
|
|
lines.append(" " + "-" * 55)
|
|
total_rev = result["total_revenue_quarterly"]
|
|
for q in ["Star", "Cash Cow", "Question Mark", "Dog"]:
|
|
qs = result["quadrant_summary"][q]
|
|
emoji = quadrant_emoji(q)
|
|
label = f"{emoji} {q}"
|
|
rev_pct = f"{qs['revenue_pct']:.0%}" if qs["count"] else "-"
|
|
eng = f"{qs['eng_pct']}%" if qs["count"] else "-"
|
|
rev = fmt_currency(qs["revenue"]) if qs["count"] else "-"
|
|
lines.append(f" {label:<15} {qs['count']:>5} {rev:>10} {rev_pct:>6} {eng:>6}")
|
|
lines.append("")
|
|
|
|
# Per-product breakdown
|
|
lines.append(" PRODUCT BREAKDOWN")
|
|
lines.append(" " + "-" * 65)
|
|
for p in result["products"]:
|
|
emoji = quadrant_emoji(p["quadrant"])
|
|
pc = posture_color(p["posture"])
|
|
lines.append(
|
|
f" {emoji} {p['name']} — {p['quadrant']} → {pc} {p['posture']}"
|
|
)
|
|
lines.append(
|
|
f" Revenue: {fmt_currency(p['revenue_quarterly'])}/qtr "
|
|
f"QoQ: {p['qoq_growth']:+.0%} "
|
|
f"Mkt growth: {p['market_growth_pct']:+.0f}%"
|
|
)
|
|
lines.append(
|
|
f" Share ratio: {p['share_ratio']:.1f}x "
|
|
f"Eng: {p['eng_capacity_pct']}% "
|
|
f"Alignment: {p['alignment_score']:.0%}"
|
|
)
|
|
if p.get("d30_retention") is not None:
|
|
lines.append(
|
|
f" D30 retention: {p['d30_retention']:.0%} "
|
|
f"NPS: {p['nps'] if p['nps'] is not None else 'N/A'}"
|
|
)
|
|
if p.get("notes"):
|
|
lines.append(f" Note: {p['notes']}")
|
|
for f in p.get("findings", []):
|
|
lines.append(f" {f}")
|
|
lines.append("")
|
|
|
|
# Portfolio-level findings
|
|
lines.append(" PORTFOLIO FINDINGS")
|
|
lines.append(" " + "-" * 65)
|
|
for f in result.get("portfolio_findings", []):
|
|
lines.append(f" {f}")
|
|
lines.append("")
|
|
lines.append("=" * 65)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Portfolio Analyzer — BCG matrix classification and investment recommendations",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__,
|
|
)
|
|
parser.add_argument(
|
|
"--input", "-i",
|
|
metavar="FILE",
|
|
help="JSON file with portfolio data (default: built-in sample data)",
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
action="store_true",
|
|
help="Output raw JSON result",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.input:
|
|
try:
|
|
with open(args.input) as f:
|
|
data = json.load(f)
|
|
except FileNotFoundError:
|
|
print(f"Error: file not found: {args.input}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except json.JSONDecodeError as e:
|
|
print(f"Error: invalid JSON: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
else:
|
|
print("No input file provided — running with sample data.\n")
|
|
data = sample_data()
|
|
|
|
result = analyze_portfolio(data)
|
|
|
|
if args.json:
|
|
# Make result JSON-serializable
|
|
def clean(obj):
|
|
if isinstance(obj, dict):
|
|
return {k: clean(v) for k, v in obj.items()}
|
|
elif isinstance(obj, list):
|
|
return [clean(v) for v in obj]
|
|
elif isinstance(obj, float):
|
|
return round(obj, 4)
|
|
return obj
|
|
print(json.dumps(clean(result), indent=2))
|
|
else:
|
|
print(render_report(result))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|