Spaces:
Running
on
Zero
Running
on
Zero
Commit
·
6d9f030
1
Parent(s):
bfaf40f
feat: add roast mode and expand example portfolio gallery
Browse filesAdd roast mode toggle that provides brutally honest portfolio analysis
with humour whilst maintaining professional recommendations. Expand
example portfolios from 3 to 5 diverse scenarios showcasing tech growth,
conservative income, balanced allocation, global diversification, and
single stock concentration risk.
Changes:
- Add ROAST_MODE_SYSTEM_PROMPT with entertaining yet educational critique style
- Modify PortfolioAnalystAgent to support roast_mode parameter
- Update workflow to dynamically create agents with correct mode
- Add roast mode checkbox toggle in Gradio UI
- Expand example gallery to 5 portfolios (Tech Growth, Conservative Income,
Balanced 60/40, Global Diversified, Single Stock Risk)
- Wire roast_mode through full analysis pipeline
- app.py +29 -10
- backend/agents/portfolio_analyst.py +48 -3
- backend/agents/workflow.py +4 -2
app.py
CHANGED
|
@@ -124,11 +124,12 @@ def parse_portfolio_input(portfolio_text: str) -> List[Dict[str, Any]]:
|
|
| 124 |
return holdings
|
| 125 |
|
| 126 |
|
| 127 |
-
async def run_analysis(portfolio_text: str) -> str:
|
| 128 |
"""Run portfolio analysis workflow.
|
| 129 |
|
| 130 |
Args:
|
| 131 |
portfolio_text: Raw portfolio input
|
|
|
|
| 132 |
|
| 133 |
Returns:
|
| 134 |
Formatted analysis result
|
|
@@ -143,7 +144,7 @@ async def run_analysis(portfolio_text: str) -> str:
|
|
| 143 |
if not holdings:
|
| 144 |
return "❌ Could not parse portfolio. Please use format: TICKER QUANTITY"
|
| 145 |
|
| 146 |
-
logger.info(f"Starting analysis for {len(holdings)} holdings")
|
| 147 |
|
| 148 |
# Construct initial state
|
| 149 |
portfolio_id = f"demo_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
@@ -167,8 +168,11 @@ async def run_analysis(portfolio_text: str) -> str:
|
|
| 167 |
'mcp_calls': []
|
| 168 |
}
|
| 169 |
|
|
|
|
|
|
|
|
|
|
| 170 |
# Run workflow
|
| 171 |
-
final_state = await
|
| 172 |
LAST_ANALYSIS_STATE = final_state
|
| 173 |
|
| 174 |
if final_state.get("errors"):
|
|
@@ -314,6 +318,7 @@ def create_visualisations() -> Tuple:
|
|
| 314 |
|
| 315 |
async def run_analysis_with_ui_update(
|
| 316 |
portfolio_text: str,
|
|
|
|
| 317 |
progress=gr.Progress()
|
| 318 |
) -> Tuple[str, str, Any, Any, Any, Any, Any]:
|
| 319 |
"""Run analysis and return results with loading progress overlay.
|
|
@@ -322,6 +327,7 @@ async def run_analysis_with_ui_update(
|
|
| 322 |
|
| 323 |
Args:
|
| 324 |
portfolio_text: Portfolio input
|
|
|
|
| 325 |
progress: Gradio progress tracker
|
| 326 |
|
| 327 |
Returns:
|
|
@@ -374,7 +380,10 @@ async def run_analysis_with_ui_update(
|
|
| 374 |
|
| 375 |
progress(0.2, desc=random.choice(LOADING_MESSAGES))
|
| 376 |
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
| 378 |
LAST_ANALYSIS_STATE = final_state
|
| 379 |
|
| 380 |
progress(0.7, desc=random.choice(LOADING_MESSAGES))
|
|
@@ -942,18 +951,28 @@ def create_interface() -> gr.Blocks:
|
|
| 942 |
lines=8,
|
| 943 |
info="Enter your holdings below (see examples for format)"
|
| 944 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 945 |
analyse_btn = gr.Button("Analyse Portfolio", variant="primary", size="lg", scale=0)
|
| 946 |
|
| 947 |
# Examples integrated (no separate card)
|
| 948 |
gr.Markdown("### Example Portfolios", elem_classes="examples-header")
|
| 949 |
gr.Examples(
|
| 950 |
examples=[
|
| 951 |
-
["AAPL 50\nTSLA 25 shares\nNVDA
|
| 952 |
-
["VOO 100 shares\nVTI 75 shares\nSCHD 50 shares"],
|
| 953 |
["VTI $25000\nVXUS $15000\nBND $15000\nGLD $5000"],
|
|
|
|
|
|
|
| 954 |
],
|
| 955 |
inputs=portfolio_input,
|
| 956 |
-
label="Tech Growth
|
| 957 |
)
|
| 958 |
|
| 959 |
# Right column: Live preview (wider at scale=3)
|
|
@@ -1075,7 +1094,7 @@ def create_interface() -> gr.Blocks:
|
|
| 1075 |
results_page: gr.update(visible=False)
|
| 1076 |
}
|
| 1077 |
|
| 1078 |
-
async def handle_analysis(portfolio_text, progress=gr.Progress()):
|
| 1079 |
# Show loading page immediately
|
| 1080 |
yield {
|
| 1081 |
input_page: gr.update(visible=False),
|
|
@@ -1092,7 +1111,7 @@ def create_interface() -> gr.Blocks:
|
|
| 1092 |
|
| 1093 |
# Run analysis with progress updates
|
| 1094 |
page, analysis, alloc, risk, perf, corr, opt = await run_analysis_with_ui_update(
|
| 1095 |
-
portfolio_text, progress
|
| 1096 |
)
|
| 1097 |
|
| 1098 |
# Show results or return to input
|
|
@@ -1139,7 +1158,7 @@ def create_interface() -> gr.Blocks:
|
|
| 1139 |
|
| 1140 |
analyse_btn.click(
|
| 1141 |
handle_analysis,
|
| 1142 |
-
inputs=[portfolio_input],
|
| 1143 |
outputs=[
|
| 1144 |
input_page,
|
| 1145 |
loading_page,
|
|
|
|
| 124 |
return holdings
|
| 125 |
|
| 126 |
|
| 127 |
+
async def run_analysis(portfolio_text: str, roast_mode: bool = False) -> str:
|
| 128 |
"""Run portfolio analysis workflow.
|
| 129 |
|
| 130 |
Args:
|
| 131 |
portfolio_text: Raw portfolio input
|
| 132 |
+
roast_mode: If True, use brutal honesty mode
|
| 133 |
|
| 134 |
Returns:
|
| 135 |
Formatted analysis result
|
|
|
|
| 144 |
if not holdings:
|
| 145 |
return "❌ Could not parse portfolio. Please use format: TICKER QUANTITY"
|
| 146 |
|
| 147 |
+
logger.info(f"Starting analysis for {len(holdings)} holdings (Roast Mode: {roast_mode})")
|
| 148 |
|
| 149 |
# Construct initial state
|
| 150 |
portfolio_id = f"demo_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
|
|
| 168 |
'mcp_calls': []
|
| 169 |
}
|
| 170 |
|
| 171 |
+
# Create workflow with roast mode if enabled
|
| 172 |
+
analysis_workflow = PortfolioAnalysisWorkflow(mcp_router, roast_mode=roast_mode)
|
| 173 |
+
|
| 174 |
# Run workflow
|
| 175 |
+
final_state = await analysis_workflow.run(initial_state)
|
| 176 |
LAST_ANALYSIS_STATE = final_state
|
| 177 |
|
| 178 |
if final_state.get("errors"):
|
|
|
|
| 318 |
|
| 319 |
async def run_analysis_with_ui_update(
|
| 320 |
portfolio_text: str,
|
| 321 |
+
roast_mode: bool = False,
|
| 322 |
progress=gr.Progress()
|
| 323 |
) -> Tuple[str, str, Any, Any, Any, Any, Any]:
|
| 324 |
"""Run analysis and return results with loading progress overlay.
|
|
|
|
| 327 |
|
| 328 |
Args:
|
| 329 |
portfolio_text: Portfolio input
|
| 330 |
+
roast_mode: If True, use brutal honesty mode
|
| 331 |
progress: Gradio progress tracker
|
| 332 |
|
| 333 |
Returns:
|
|
|
|
| 380 |
|
| 381 |
progress(0.2, desc=random.choice(LOADING_MESSAGES))
|
| 382 |
|
| 383 |
+
# Create workflow with roast mode if enabled
|
| 384 |
+
analysis_workflow = PortfolioAnalysisWorkflow(mcp_router, roast_mode=roast_mode)
|
| 385 |
+
|
| 386 |
+
final_state = await analysis_workflow.run(initial_state)
|
| 387 |
LAST_ANALYSIS_STATE = final_state
|
| 388 |
|
| 389 |
progress(0.7, desc=random.choice(LOADING_MESSAGES))
|
|
|
|
| 951 |
lines=8,
|
| 952 |
info="Enter your holdings below (see examples for format)"
|
| 953 |
)
|
| 954 |
+
|
| 955 |
+
# Roast Mode Toggle
|
| 956 |
+
roast_mode_toggle = gr.Checkbox(
|
| 957 |
+
label="Roast Mode",
|
| 958 |
+
value=False,
|
| 959 |
+
info="Enable brutal honesty mode for portfolio critique (still gets serious recommendations!)"
|
| 960 |
+
)
|
| 961 |
+
|
| 962 |
analyse_btn = gr.Button("Analyse Portfolio", variant="primary", size="lg", scale=0)
|
| 963 |
|
| 964 |
# Examples integrated (no separate card)
|
| 965 |
gr.Markdown("### Example Portfolios", elem_classes="examples-header")
|
| 966 |
gr.Examples(
|
| 967 |
examples=[
|
| 968 |
+
["AAPL 50 shares\nTSLA 25 shares\nNVDA 30 shares\nMETA 20 shares"],
|
| 969 |
+
["VOO 100 shares\nVTI 75 shares\nSCHD 50 shares\nTLT 40 shares\nVXUS 60 shares"],
|
| 970 |
["VTI $25000\nVXUS $15000\nBND $15000\nGLD $5000"],
|
| 971 |
+
["VTI $15000\nVXUS $10000\nVWO $5000\nBND $10000\nGLD $3000\nVNQ $2000"],
|
| 972 |
+
["TSLA 100 shares"],
|
| 973 |
],
|
| 974 |
inputs=portfolio_input,
|
| 975 |
+
label="Try: Tech Growth | Conservative Income | Balanced 60/40 | Global Diversified | Single Stock Risk"
|
| 976 |
)
|
| 977 |
|
| 978 |
# Right column: Live preview (wider at scale=3)
|
|
|
|
| 1094 |
results_page: gr.update(visible=False)
|
| 1095 |
}
|
| 1096 |
|
| 1097 |
+
async def handle_analysis(portfolio_text, roast_mode, progress=gr.Progress()):
|
| 1098 |
# Show loading page immediately
|
| 1099 |
yield {
|
| 1100 |
input_page: gr.update(visible=False),
|
|
|
|
| 1111 |
|
| 1112 |
# Run analysis with progress updates
|
| 1113 |
page, analysis, alloc, risk, perf, corr, opt = await run_analysis_with_ui_update(
|
| 1114 |
+
portfolio_text, roast_mode, progress
|
| 1115 |
)
|
| 1116 |
|
| 1117 |
# Show results or return to input
|
|
|
|
| 1158 |
|
| 1159 |
analyse_btn.click(
|
| 1160 |
handle_analysis,
|
| 1161 |
+
inputs=[portfolio_input, roast_mode_toggle],
|
| 1162 |
outputs=[
|
| 1163 |
input_page,
|
| 1164 |
loading_page,
|
backend/agents/portfolio_analyst.py
CHANGED
|
@@ -212,15 +212,60 @@ Output a structured analysis with:
|
|
| 212 |
- Confidence level in your analysis (0.0-1.0)
|
| 213 |
"""
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
class PortfolioAnalystAgent(BasePortfolioAgent[PortfolioAnalysisOutput]):
|
| 217 |
"""Main portfolio analysis agent."""
|
| 218 |
|
| 219 |
-
def __init__(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
super().__init__(
|
| 221 |
output_type=PortfolioAnalysisOutput,
|
| 222 |
-
system_prompt=
|
| 223 |
-
agent_name="PortfolioAnalyst",
|
| 224 |
)
|
| 225 |
|
| 226 |
async def analyze_portfolio(
|
|
|
|
| 212 |
- Confidence level in your analysis (0.0-1.0)
|
| 213 |
"""
|
| 214 |
|
| 215 |
+
ROAST_MODE_SYSTEM_PROMPT = """You are a brutally honest portfolio analyst with a sharp wit and no patience for poor investment decisions.
|
| 216 |
+
|
| 217 |
+
Your role is to roast questionable portfolios whilst still providing genuinely useful, actionable advice. Be savage about mistakes but educational in your corrections.
|
| 218 |
+
|
| 219 |
+
Roasting style guidelines:
|
| 220 |
+
1. Start with brutal honesty about portfolio mistakes (but keep it entertaining, not cruel)
|
| 221 |
+
2. Use comparisons and metaphors to highlight poor decisions
|
| 222 |
+
3. Call out specific red flags with humour
|
| 223 |
+
4. Balance roasting with actual expertise - show you know better
|
| 224 |
+
5. ALWAYS end with constructive, actionable recommendations
|
| 225 |
+
|
| 226 |
+
Examples of good roasts:
|
| 227 |
+
- "Your portfolio screams 'I read Reddit in 2021.' 54% in meme stocks isn't diversification, it's gambling with extra steps."
|
| 228 |
+
- "Congratulations on creating the world's most expensive way to lose money. This allocation has the risk of a startup but returns of a savings account."
|
| 229 |
+
- "This portfolio is like bringing a knife to a gunfight, except the knife is made of paper and it's raining."
|
| 230 |
+
|
| 231 |
+
CRITICAL: After the roasting, you MUST provide serious, professional advice. The roast is the spoonful of sugar that helps the medicine go down.
|
| 232 |
+
|
| 233 |
+
CRITICAL RESPONSE FORMAT REQUIREMENTS:
|
| 234 |
+
- Return ONLY valid JSON matching the exact schema specified
|
| 235 |
+
- All required fields must be present with correct types
|
| 236 |
+
- summary: must be 50-500 characters long (THIS IS WHERE YOU ROAST)
|
| 237 |
+
- health_score: must be an integer between 0-100
|
| 238 |
+
- risk_level: must be exactly one of "conservative", "moderate", or "aggressive"
|
| 239 |
+
- top_strength: must be at least 10 characters
|
| 240 |
+
- top_concern: must be at least 10 characters (can be roast-style)
|
| 241 |
+
- diversification_score: must be an integer between 0-100
|
| 242 |
+
- recommendations: must be an array of 2-5 strings (serious, actionable advice)
|
| 243 |
+
- reasoning: array of explanation strings (can include roast commentary)
|
| 244 |
+
- confidence: must be a float between 0.0-1.0
|
| 245 |
+
|
| 246 |
+
Output a structured analysis with:
|
| 247 |
+
- A roast-style summary of portfolio health (50-500 chars)
|
| 248 |
+
- The single biggest strength (acknowledge good decisions!) and concern (roast the bad)
|
| 249 |
+
- 2-5 prioritised, SERIOUS, actionable recommendations (no jokes here)
|
| 250 |
+
- Your reasoning process (can include roast commentary)
|
| 251 |
+
- Confidence level in your analysis (0.0-1.0)
|
| 252 |
+
"""
|
| 253 |
+
|
| 254 |
|
| 255 |
class PortfolioAnalystAgent(BasePortfolioAgent[PortfolioAnalysisOutput]):
|
| 256 |
"""Main portfolio analysis agent."""
|
| 257 |
|
| 258 |
+
def __init__(self, roast_mode: bool = False):
|
| 259 |
+
"""Initialise portfolio analyst agent.
|
| 260 |
+
|
| 261 |
+
Args:
|
| 262 |
+
roast_mode: If True, use brutal honesty mode for analysis
|
| 263 |
+
"""
|
| 264 |
+
system_prompt = ROAST_MODE_SYSTEM_PROMPT if roast_mode else SYSTEM_PROMPT
|
| 265 |
super().__init__(
|
| 266 |
output_type=PortfolioAnalysisOutput,
|
| 267 |
+
system_prompt=system_prompt,
|
| 268 |
+
agent_name="PortfolioAnalyst" + (" (Roast Mode)" if roast_mode else ""),
|
| 269 |
)
|
| 270 |
|
| 271 |
async def analyze_portfolio(
|
backend/agents/workflow.py
CHANGED
|
@@ -58,14 +58,16 @@ def summarize_fred_data(series_data: Dict[str, Any], indicator_name: str) -> Dic
|
|
| 58 |
class PortfolioAnalysisWorkflow:
|
| 59 |
"""LangGraph workflow for portfolio analysis."""
|
| 60 |
|
| 61 |
-
def __init__(self, mcp_router):
|
| 62 |
"""Initialize the workflow with MCP router.
|
| 63 |
|
| 64 |
Args:
|
| 65 |
mcp_router: MCP router instance for calling MCP servers
|
|
|
|
| 66 |
"""
|
| 67 |
self.mcp_router = mcp_router
|
| 68 |
-
self.analyst_agent = PortfolioAnalystAgent()
|
|
|
|
| 69 |
|
| 70 |
# Build the workflow graph
|
| 71 |
self.workflow = self._build_workflow()
|
|
|
|
| 58 |
class PortfolioAnalysisWorkflow:
|
| 59 |
"""LangGraph workflow for portfolio analysis."""
|
| 60 |
|
| 61 |
+
def __init__(self, mcp_router, roast_mode: bool = False):
|
| 62 |
"""Initialize the workflow with MCP router.
|
| 63 |
|
| 64 |
Args:
|
| 65 |
mcp_router: MCP router instance for calling MCP servers
|
| 66 |
+
roast_mode: If True, use brutal honesty mode for analysis
|
| 67 |
"""
|
| 68 |
self.mcp_router = mcp_router
|
| 69 |
+
self.analyst_agent = PortfolioAnalystAgent(roast_mode=roast_mode)
|
| 70 |
+
self.roast_mode = roast_mode
|
| 71 |
|
| 72 |
# Build the workflow graph
|
| 73 |
self.workflow = self._build_workflow()
|