BrianIsaac commited on
Commit
b5f969d
·
1 Parent(s): 1586274

feat: add portfolio rehearsal engine for Test Changes workflow

Browse files

- Add PortfolioRehearsalEngine with simulation, stress tests, and recommendations
- Add route_test() method to WorkflowRouter and enable TEST task
- Add test_page UI with side-by-side comparison and impact analysis
- Add comprehensive test coverage (16 tests)

app.py CHANGED
@@ -1954,13 +1954,12 @@ def create_interface() -> gr.Blocks:
1954
  size="lg"
1955
  )
1956
 
1957
- # Test Changes - coming soon
1958
  with gr.Column(scale=1, min_width=200):
1959
  task_test_btn = gr.Button(
1960
- value="🔬\n\nTest Changes\n\nPreview impact before making portfolio changes\n\n🔒 Coming Soon",
1961
  elem_classes=["task-card"],
1962
  variant="secondary",
1963
- interactive=False,
1964
  size="lg"
1965
  )
1966
 
@@ -2088,6 +2087,90 @@ def create_interface() -> gr.Blocks:
2088
  with gr.Accordion("View Full Debate", open=False):
2089
  compare_debate_transcript = gr.JSON(label="Debate Rounds")
2090
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2091
  # Input Page with side-by-side layout (hidden until authenticated)
2092
  with gr.Group(visible=False) as input_page:
2093
  # Side-by-side input and preview (2:3 ratio for wider preview)
@@ -2526,7 +2609,8 @@ def create_interface() -> gr.Blocks:
2526
  results_page: gr.update(visible=False),
2527
  history_page: gr.update(visible=False),
2528
  build_page: gr.update(visible=False),
2529
- compare_page: gr.update(visible=False)
 
2530
  }
2531
 
2532
  def show_task_page():
@@ -2536,7 +2620,8 @@ def create_interface() -> gr.Blocks:
2536
  results_page: gr.update(visible=False),
2537
  history_page: gr.update(visible=False),
2538
  build_page: gr.update(visible=False),
2539
- compare_page: gr.update(visible=False)
 
2540
  }
2541
 
2542
  def show_build_page():
@@ -2546,7 +2631,8 @@ def create_interface() -> gr.Blocks:
2546
  results_page: gr.update(visible=False),
2547
  history_page: gr.update(visible=False),
2548
  build_page: gr.update(visible=True),
2549
- compare_page: gr.update(visible=False)
 
2550
  }
2551
 
2552
  def show_compare_page():
@@ -2556,7 +2642,19 @@ def create_interface() -> gr.Blocks:
2556
  results_page: gr.update(visible=False),
2557
  history_page: gr.update(visible=False),
2558
  build_page: gr.update(visible=False),
2559
- compare_page: gr.update(visible=True)
 
 
 
 
 
 
 
 
 
 
 
 
2560
  }
2561
 
2562
  async def handle_build_portfolio(goals, risk_tolerance, constraints, show_reasoning, session_state):
@@ -2658,7 +2756,8 @@ def create_interface() -> gr.Blocks:
2658
  gr.update(visible=False), # results_page
2659
  gr.update(visible=False), # history_page
2660
  gr.update(visible=False), # build_page
2661
- gr.update(visible=False) # compare_page
 
2662
  )
2663
 
2664
  # Convert portfolio table to input format
@@ -2678,7 +2777,8 @@ def create_interface() -> gr.Blocks:
2678
  gr.update(visible=False), # results_page
2679
  gr.update(visible=False), # history_page
2680
  gr.update(visible=False), # build_page
2681
- gr.update(visible=False) # compare_page
 
2682
  )
2683
 
2684
  async def handle_compare_portfolio(portfolio_text, session_state):
@@ -2754,6 +2854,187 @@ def create_interface() -> gr.Blocks:
2754
  """Synchronous wrapper for handle_compare_portfolio."""
2755
  return asyncio.run(handle_compare_portfolio(portfolio_text, session_state))
2756
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2757
  async def load_history(session_state):
2758
  """Load analysis history from database.
2759
 
@@ -3824,29 +4105,34 @@ Please try again with different parameters.
3824
  # Navigation event handlers
3825
  nav_new_analysis.click(
3826
  show_task_page,
3827
- outputs=[task_page, input_page, results_page, history_page, build_page, compare_page]
3828
  )
3829
 
3830
  # Task card event handlers
3831
  task_analyse_btn.click(
3832
  show_input_page,
3833
- outputs=[task_page, input_page, results_page, history_page, build_page, compare_page]
3834
  )
3835
 
3836
  task_build_btn.click(
3837
  show_build_page,
3838
- outputs=[task_page, input_page, results_page, history_page, build_page, compare_page]
3839
  )
3840
 
3841
  task_compare_btn.click(
3842
  show_compare_page,
3843
- outputs=[task_page, input_page, results_page, history_page, build_page, compare_page]
 
 
 
 
 
3844
  )
3845
 
3846
  # Build page event handlers
3847
  build_back_btn.click(
3848
  show_task_page,
3849
- outputs=[task_page, input_page, results_page, history_page, build_page, compare_page]
3850
  )
3851
 
3852
  build_submit_btn.click(
@@ -3871,7 +4157,7 @@ Please try again with different parameters.
3871
  build_accept_btn.click(
3872
  handle_build_accept,
3873
  inputs=[build_portfolio_table],
3874
- outputs=[portfolio_input, task_page, input_page, results_page, history_page, build_page, compare_page]
3875
  )
3876
 
3877
  build_regenerate_btn.click(
@@ -3890,7 +4176,7 @@ Please try again with different parameters.
3890
  # Compare page event handlers
3891
  compare_back_btn.click(
3892
  show_task_page,
3893
- outputs=[task_page, input_page, results_page, history_page, build_page, compare_page]
3894
  )
3895
 
3896
  compare_submit_btn.click(
@@ -3909,6 +4195,27 @@ Please try again with different parameters.
3909
  ]
3910
  )
3911
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3912
  nav_view_history.click(
3913
  show_history_page,
3914
  outputs=[task_page, input_page, results_page, history_page]
 
1954
  size="lg"
1955
  )
1956
 
1957
+ # Test Changes
1958
  with gr.Column(scale=1, min_width=200):
1959
  task_test_btn = gr.Button(
1960
+ value="🔬\n\nTest Changes\n\nPreview impact before making portfolio changes",
1961
  elem_classes=["task-card"],
1962
  variant="secondary",
 
1963
  size="lg"
1964
  )
1965
 
 
2087
  with gr.Accordion("View Full Debate", open=False):
2088
  compare_debate_transcript = gr.JSON(label="Debate Rounds")
2089
 
2090
+ # Test Changes Page (shown when Test Changes task selected)
2091
+ with gr.Group(visible=False, elem_classes="test-changes-container") as test_page:
2092
+ gr.Markdown("## Test Portfolio Changes", elem_classes="section-title")
2093
+ gr.Markdown("Preview the impact of changes before making them.")
2094
+
2095
+ with gr.Row(equal_height=True):
2096
+ # Left column: Portfolio and changes input
2097
+ with gr.Column(scale=2):
2098
+ with gr.Group(elem_classes="input-card"):
2099
+ test_portfolio_input = gr.Textbox(
2100
+ placeholder="AAPL 30%\nTSLA 25%\nNVDA 25%\nBND 20%",
2101
+ label="Current Portfolio (ticker weight%)",
2102
+ lines=6,
2103
+ info="Enter your current portfolio allocations"
2104
+ )
2105
+
2106
+ test_changes_input = gr.Textbox(
2107
+ placeholder="sell TSLA 10\nbuy VTI 10",
2108
+ label="Proposed Changes",
2109
+ lines=4,
2110
+ info="Format: action ticker amount (e.g., 'sell TSLA 10' or 'buy VTI 15')"
2111
+ )
2112
+
2113
+ test_portfolio_value = gr.Number(
2114
+ label="Portfolio Value ($)",
2115
+ value=100000,
2116
+ info="Total portfolio value for calculations"
2117
+ )
2118
+
2119
+ with gr.Row():
2120
+ test_submit_btn = gr.Button(
2121
+ "Run Simulation",
2122
+ variant="primary",
2123
+ size="lg"
2124
+ )
2125
+ test_back_btn = gr.Button(
2126
+ "Back",
2127
+ variant="secondary",
2128
+ size="lg"
2129
+ )
2130
+
2131
+ # Right column: Results
2132
+ with gr.Column(scale=3):
2133
+ with gr.Group(elem_classes="preview-card", visible=False) as test_results_container:
2134
+ test_status = gr.Markdown("", elem_classes="test-status")
2135
+
2136
+ # Side-by-side metrics comparison
2137
+ with gr.Row():
2138
+ with gr.Column():
2139
+ gr.Markdown("### Current Portfolio")
2140
+ test_current_metrics = gr.Dataframe(
2141
+ headers=["Metric", "Value"],
2142
+ label="Current Metrics",
2143
+ interactive=False
2144
+ )
2145
+
2146
+ with gr.Column():
2147
+ gr.Markdown("### After Changes")
2148
+ test_simulated_metrics = gr.Dataframe(
2149
+ headers=["Metric", "Value"],
2150
+ label="Simulated Metrics",
2151
+ interactive=False
2152
+ )
2153
+
2154
+ # Impact summary
2155
+ gr.Markdown("### Impact Analysis")
2156
+ test_impact_summary = gr.Dataframe(
2157
+ headers=["Metric", "Current", "Simulated", "Change", "% Change"],
2158
+ label="Impact Summary",
2159
+ interactive=False
2160
+ )
2161
+
2162
+ # Stress test comparison
2163
+ with gr.Accordion("Stress Test Comparison", open=False):
2164
+ test_stress_comparison = gr.Dataframe(
2165
+ headers=["Scenario", "Shock %", "Current Loss", "Simulated Loss", "Improvement"],
2166
+ label="Stress Tests",
2167
+ interactive=False
2168
+ )
2169
+
2170
+ # Recommendations and assessment
2171
+ test_assessment = gr.Markdown("", elem_classes="test-assessment")
2172
+ test_recommendations = gr.Markdown("", label="Recommendations")
2173
+
2174
  # Input Page with side-by-side layout (hidden until authenticated)
2175
  with gr.Group(visible=False) as input_page:
2176
  # Side-by-side input and preview (2:3 ratio for wider preview)
 
2609
  results_page: gr.update(visible=False),
2610
  history_page: gr.update(visible=False),
2611
  build_page: gr.update(visible=False),
2612
+ compare_page: gr.update(visible=False),
2613
+ test_page: gr.update(visible=False)
2614
  }
2615
 
2616
  def show_task_page():
 
2620
  results_page: gr.update(visible=False),
2621
  history_page: gr.update(visible=False),
2622
  build_page: gr.update(visible=False),
2623
+ compare_page: gr.update(visible=False),
2624
+ test_page: gr.update(visible=False)
2625
  }
2626
 
2627
  def show_build_page():
 
2631
  results_page: gr.update(visible=False),
2632
  history_page: gr.update(visible=False),
2633
  build_page: gr.update(visible=True),
2634
+ compare_page: gr.update(visible=False),
2635
+ test_page: gr.update(visible=False)
2636
  }
2637
 
2638
  def show_compare_page():
 
2642
  results_page: gr.update(visible=False),
2643
  history_page: gr.update(visible=False),
2644
  build_page: gr.update(visible=False),
2645
+ compare_page: gr.update(visible=True),
2646
+ test_page: gr.update(visible=False)
2647
+ }
2648
+
2649
+ def show_test_page():
2650
+ return {
2651
+ task_page: gr.update(visible=False),
2652
+ input_page: gr.update(visible=False),
2653
+ results_page: gr.update(visible=False),
2654
+ history_page: gr.update(visible=False),
2655
+ build_page: gr.update(visible=False),
2656
+ compare_page: gr.update(visible=False),
2657
+ test_page: gr.update(visible=True)
2658
  }
2659
 
2660
  async def handle_build_portfolio(goals, risk_tolerance, constraints, show_reasoning, session_state):
 
2756
  gr.update(visible=False), # results_page
2757
  gr.update(visible=False), # history_page
2758
  gr.update(visible=False), # build_page
2759
+ gr.update(visible=False), # compare_page
2760
+ gr.update(visible=False) # test_page
2761
  )
2762
 
2763
  # Convert portfolio table to input format
 
2777
  gr.update(visible=False), # results_page
2778
  gr.update(visible=False), # history_page
2779
  gr.update(visible=False), # build_page
2780
+ gr.update(visible=False), # compare_page
2781
+ gr.update(visible=False) # test_page
2782
  )
2783
 
2784
  async def handle_compare_portfolio(portfolio_text, session_state):
 
2854
  """Synchronous wrapper for handle_compare_portfolio."""
2855
  return asyncio.run(handle_compare_portfolio(portfolio_text, session_state))
2856
 
2857
+ async def handle_test_changes(portfolio_text, changes_text, portfolio_value, session_state):
2858
+ """Handle the Test Changes workflow.
2859
+
2860
+ Args:
2861
+ portfolio_text: Current portfolio as text (ticker weight%)
2862
+ changes_text: Proposed changes as text
2863
+ portfolio_value: Total portfolio value
2864
+ session_state: User session
2865
+
2866
+ Returns:
2867
+ Tuple of UI updates for test results
2868
+ """
2869
+ try:
2870
+ if not portfolio_text.strip():
2871
+ return (
2872
+ gr.update(visible=True),
2873
+ "Please enter your current portfolio.",
2874
+ [],
2875
+ [],
2876
+ [],
2877
+ [],
2878
+ "",
2879
+ ""
2880
+ )
2881
+
2882
+ if not changes_text.strip():
2883
+ return (
2884
+ gr.update(visible=True),
2885
+ "Please enter proposed changes.",
2886
+ [],
2887
+ [],
2888
+ [],
2889
+ [],
2890
+ "",
2891
+ ""
2892
+ )
2893
+
2894
+ # Parse current portfolio
2895
+ current_portfolio = {}
2896
+ for line in portfolio_text.strip().split("\n"):
2897
+ line = line.strip()
2898
+ if not line:
2899
+ continue
2900
+ parts = line.replace("%", "").split()
2901
+ if len(parts) >= 2:
2902
+ ticker = parts[0].upper()
2903
+ try:
2904
+ weight = float(parts[1])
2905
+ current_portfolio[ticker] = {"weight": weight}
2906
+ except ValueError:
2907
+ continue
2908
+
2909
+ # Parse proposed changes
2910
+ proposed_changes = []
2911
+ for line in changes_text.strip().split("\n"):
2912
+ line = line.strip().lower()
2913
+ if not line:
2914
+ continue
2915
+ parts = line.split()
2916
+ if len(parts) >= 3:
2917
+ action = parts[0]
2918
+ ticker = parts[1].upper()
2919
+ try:
2920
+ amount = float(parts[2])
2921
+ if action in ["buy", "sell"]:
2922
+ proposed_changes.append({
2923
+ "action": action,
2924
+ "ticker": ticker,
2925
+ "amount": amount
2926
+ })
2927
+ except ValueError:
2928
+ continue
2929
+
2930
+ if not current_portfolio:
2931
+ return (
2932
+ gr.update(visible=True),
2933
+ "Could not parse portfolio. Use format: TICKER WEIGHT%",
2934
+ [],
2935
+ [],
2936
+ [],
2937
+ [],
2938
+ "",
2939
+ ""
2940
+ )
2941
+
2942
+ if not proposed_changes:
2943
+ return (
2944
+ gr.update(visible=True),
2945
+ "Could not parse changes. Use format: buy/sell TICKER AMOUNT",
2946
+ [],
2947
+ [],
2948
+ [],
2949
+ [],
2950
+ "",
2951
+ ""
2952
+ )
2953
+
2954
+ # Initialise MCP router and workflow router
2955
+ from backend.mcp_router import MCPRouter
2956
+ from backend.agents.workflow_router import WorkflowRouter
2957
+
2958
+ mcp_router = MCPRouter()
2959
+ workflow_router = WorkflowRouter(mcp_router)
2960
+
2961
+ # Run the test workflow
2962
+ result = await workflow_router.route_test(
2963
+ current_portfolio=current_portfolio,
2964
+ proposed_changes=proposed_changes,
2965
+ portfolio_value=float(portfolio_value)
2966
+ )
2967
+
2968
+ # Format current metrics for display
2969
+ current_metrics_data = []
2970
+ for metric, value in result.get("current", {}).get("metrics", {}).items():
2971
+ if isinstance(value, (int, float)):
2972
+ current_metrics_data.append([metric.replace("_", " ").title(), f"{value:.4f}"])
2973
+
2974
+ # Format simulated metrics for display
2975
+ simulated_metrics_data = []
2976
+ for metric, value in result.get("simulated", {}).get("metrics", {}).items():
2977
+ if isinstance(value, (int, float)):
2978
+ simulated_metrics_data.append([metric.replace("_", " ").title(), f"{value:.4f}"])
2979
+
2980
+ # Format impact summary
2981
+ impact_data = []
2982
+ for metric, impact in result.get("impact", {}).items():
2983
+ impact_data.append([
2984
+ metric.replace("_", " ").title(),
2985
+ f"{impact.get('current', 0):.4f}",
2986
+ f"{impact.get('simulated', 0):.4f}",
2987
+ f"{impact.get('delta', 0):+.4f}",
2988
+ f"{impact.get('pct_change', 0):+.2f}%"
2989
+ ])
2990
+
2991
+ # Format stress test comparison
2992
+ stress_data = []
2993
+ for scenario, data in result.get("stress_comparison", {}).items():
2994
+ stress_data.append([
2995
+ scenario.replace("_", " ").title(),
2996
+ f"{data.get('shock', 0):.0f}%",
2997
+ f"${data.get('current_loss', 0):,.0f}",
2998
+ f"${data.get('simulated_loss', 0):,.0f}",
2999
+ f"${data.get('improvement', 0):+,.0f}"
3000
+ ])
3001
+
3002
+ # Format recommendations
3003
+ recommendations_text = "### Recommendations\n\n"
3004
+ for rec in result.get("recommendations", []):
3005
+ recommendations_text += f"- {rec}\n"
3006
+
3007
+ # Format assessment
3008
+ assessment_text = f"### Overall Assessment\n\n**{result.get('overall_assessment', 'No assessment')}**"
3009
+
3010
+ return (
3011
+ gr.update(visible=True),
3012
+ "Simulation complete!",
3013
+ current_metrics_data,
3014
+ simulated_metrics_data,
3015
+ impact_data,
3016
+ stress_data,
3017
+ assessment_text,
3018
+ recommendations_text
3019
+ )
3020
+
3021
+ except Exception as e:
3022
+ logger.error(f"Test changes error: {e}")
3023
+ return (
3024
+ gr.update(visible=True),
3025
+ f"Error running simulation: {str(e)}",
3026
+ [],
3027
+ [],
3028
+ [],
3029
+ [],
3030
+ "",
3031
+ ""
3032
+ )
3033
+
3034
+ def sync_handle_test_changes(portfolio_text, changes_text, portfolio_value, session_state):
3035
+ """Synchronous wrapper for handle_test_changes."""
3036
+ return asyncio.run(handle_test_changes(portfolio_text, changes_text, portfolio_value, session_state))
3037
+
3038
  async def load_history(session_state):
3039
  """Load analysis history from database.
3040
 
 
4105
  # Navigation event handlers
4106
  nav_new_analysis.click(
4107
  show_task_page,
4108
+ outputs=[task_page, input_page, results_page, history_page, build_page, compare_page, test_page]
4109
  )
4110
 
4111
  # Task card event handlers
4112
  task_analyse_btn.click(
4113
  show_input_page,
4114
+ outputs=[task_page, input_page, results_page, history_page, build_page, compare_page, test_page]
4115
  )
4116
 
4117
  task_build_btn.click(
4118
  show_build_page,
4119
+ outputs=[task_page, input_page, results_page, history_page, build_page, compare_page, test_page]
4120
  )
4121
 
4122
  task_compare_btn.click(
4123
  show_compare_page,
4124
+ outputs=[task_page, input_page, results_page, history_page, build_page, compare_page, test_page]
4125
+ )
4126
+
4127
+ task_test_btn.click(
4128
+ show_test_page,
4129
+ outputs=[task_page, input_page, results_page, history_page, build_page, compare_page, test_page]
4130
  )
4131
 
4132
  # Build page event handlers
4133
  build_back_btn.click(
4134
  show_task_page,
4135
+ outputs=[task_page, input_page, results_page, history_page, build_page, compare_page, test_page]
4136
  )
4137
 
4138
  build_submit_btn.click(
 
4157
  build_accept_btn.click(
4158
  handle_build_accept,
4159
  inputs=[build_portfolio_table],
4160
+ outputs=[portfolio_input, task_page, input_page, results_page, history_page, build_page, compare_page, test_page]
4161
  )
4162
 
4163
  build_regenerate_btn.click(
 
4176
  # Compare page event handlers
4177
  compare_back_btn.click(
4178
  show_task_page,
4179
+ outputs=[task_page, input_page, results_page, history_page, build_page, compare_page, test_page]
4180
  )
4181
 
4182
  compare_submit_btn.click(
 
4195
  ]
4196
  )
4197
 
4198
+ # Test page event handlers
4199
+ test_back_btn.click(
4200
+ show_task_page,
4201
+ outputs=[task_page, input_page, results_page, history_page, build_page, compare_page, test_page]
4202
+ )
4203
+
4204
+ test_submit_btn.click(
4205
+ sync_handle_test_changes,
4206
+ inputs=[test_portfolio_input, test_changes_input, test_portfolio_value, session_state],
4207
+ outputs=[
4208
+ test_results_container,
4209
+ test_status,
4210
+ test_current_metrics,
4211
+ test_simulated_metrics,
4212
+ test_impact_summary,
4213
+ test_stress_comparison,
4214
+ test_assessment,
4215
+ test_recommendations
4216
+ ]
4217
+ )
4218
+
4219
  nav_view_history.click(
4220
  show_history_page,
4221
  outputs=[task_page, input_page, results_page, history_page]
backend/agents/rehearsal.py ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Portfolio rehearsal engine for testing changes before execution.
2
+
3
+ Simulates portfolio changes and provides side-by-side impact comparison.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ from decimal import Decimal
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class PortfolioRehearsalEngine:
15
+ """Simulates portfolio changes before execution.
16
+
17
+ Provides side-by-side comparison of:
18
+ - Current vs proposed metrics
19
+ - Risk impact
20
+ - Expected return changes
21
+ - Diversification effects
22
+ """
23
+
24
+ def __init__(self, mcp_router):
25
+ """Initialise the rehearsal engine.
26
+
27
+ Args:
28
+ mcp_router: MCP router instance for calling MCP servers
29
+ """
30
+ self.mcp_router = mcp_router
31
+
32
+ async def rehearse_changes(
33
+ self,
34
+ current_portfolio: Dict[str, Dict[str, Any]],
35
+ proposed_changes: List[Dict[str, Any]],
36
+ portfolio_value: float = 100000.0
37
+ ) -> Dict[str, Any]:
38
+ """Simulate impact of proposed portfolio changes.
39
+
40
+ Args:
41
+ current_portfolio: {ticker: {weight: float, shares: int, value: float}}
42
+ proposed_changes: [{action: buy/sell, ticker: str, amount: float}]
43
+ portfolio_value: Total portfolio value for calculations
44
+
45
+ Returns:
46
+ Comparison metrics and recommendations
47
+ """
48
+ logger.info(f"Rehearsing changes for portfolio with {len(current_portfolio)} holdings")
49
+
50
+ simulated_portfolio = self._apply_changes(current_portfolio, proposed_changes)
51
+
52
+ all_tickers = set(current_portfolio.keys()) | set(simulated_portfolio.keys())
53
+ historical_data = await self._fetch_historical_data(list(all_tickers))
54
+
55
+ current_analysis, simulated_analysis = await asyncio.gather(
56
+ self._analyse_portfolio(current_portfolio, historical_data, portfolio_value),
57
+ self._analyse_portfolio(simulated_portfolio, historical_data, portfolio_value)
58
+ )
59
+
60
+ impact = self._calculate_impact(current_analysis, simulated_analysis)
61
+
62
+ stress_comparison = await self._compare_stress_tests(
63
+ current_portfolio,
64
+ simulated_portfolio,
65
+ historical_data,
66
+ portfolio_value
67
+ )
68
+
69
+ recommendations = self._generate_recommendations(impact, stress_comparison)
70
+
71
+ overall_assessment = self._assess_changes(impact)
72
+
73
+ return {
74
+ "current": {
75
+ "portfolio": current_portfolio,
76
+ "metrics": current_analysis
77
+ },
78
+ "simulated": {
79
+ "portfolio": simulated_portfolio,
80
+ "metrics": simulated_analysis
81
+ },
82
+ "impact": impact,
83
+ "stress_comparison": stress_comparison,
84
+ "recommendations": recommendations,
85
+ "overall_assessment": overall_assessment
86
+ }
87
+
88
+ def _apply_changes(
89
+ self,
90
+ portfolio: Dict[str, Dict[str, Any]],
91
+ changes: List[Dict[str, Any]]
92
+ ) -> Dict[str, Dict[str, Any]]:
93
+ """Apply proposed changes to create simulated portfolio.
94
+
95
+ Args:
96
+ portfolio: Current portfolio holdings
97
+ changes: List of proposed changes
98
+
99
+ Returns:
100
+ New portfolio with changes applied
101
+ """
102
+ new_portfolio = {
103
+ ticker: data.copy()
104
+ for ticker, data in portfolio.items()
105
+ }
106
+
107
+ for change in changes:
108
+ ticker = change["ticker"]
109
+ action = change["action"]
110
+ amount = change["amount"]
111
+
112
+ if action == "sell":
113
+ if ticker in new_portfolio:
114
+ new_portfolio[ticker]["weight"] -= amount
115
+ if new_portfolio[ticker]["weight"] <= 0:
116
+ del new_portfolio[ticker]
117
+
118
+ elif action == "buy":
119
+ if ticker in new_portfolio:
120
+ new_portfolio[ticker]["weight"] += amount
121
+ else:
122
+ new_portfolio[ticker] = {"weight": amount}
123
+
124
+ total_weight = sum(h["weight"] for h in new_portfolio.values())
125
+ if total_weight > 0:
126
+ for ticker in new_portfolio:
127
+ new_portfolio[ticker]["weight"] = (
128
+ new_portfolio[ticker]["weight"] / total_weight * 100
129
+ )
130
+
131
+ return new_portfolio
132
+
133
+ async def _fetch_historical_data(
134
+ self,
135
+ tickers: List[str]
136
+ ) -> Dict[str, Dict[str, Any]]:
137
+ """Fetch historical price data for all tickers.
138
+
139
+ Args:
140
+ tickers: List of ticker symbols
141
+
142
+ Returns:
143
+ Historical data keyed by ticker
144
+ """
145
+ logger.info(f"Fetching historical data for {len(tickers)} tickers")
146
+
147
+ async def fetch_ticker(ticker: str) -> tuple[str, Dict[str, Any]]:
148
+ try:
149
+ result = await self.mcp_router.call_yahoo_finance_mcp(
150
+ "get_historical_data",
151
+ {"ticker": ticker, "period": "1y", "interval": "1d"}
152
+ )
153
+ return ticker, result
154
+ except Exception as e:
155
+ logger.warning(f"Failed to fetch historical data for {ticker}: {e}")
156
+ return ticker, {"close_prices": [], "error": str(e)}
157
+
158
+ results = await asyncio.gather(*[fetch_ticker(t) for t in tickers])
159
+ return dict(results)
160
+
161
+ async def _analyse_portfolio(
162
+ self,
163
+ portfolio: Dict[str, Dict[str, Any]],
164
+ historical_data: Dict[str, Dict[str, Any]],
165
+ portfolio_value: float
166
+ ) -> Dict[str, Any]:
167
+ """Run full analysis on a portfolio.
168
+
169
+ Args:
170
+ portfolio: Portfolio holdings
171
+ historical_data: Historical price data
172
+ portfolio_value: Total portfolio value
173
+
174
+ Returns:
175
+ Analysis metrics
176
+ """
177
+ if not portfolio:
178
+ return {
179
+ "expected_return": 0.0,
180
+ "var_95": 0.0,
181
+ "cvar_95": 0.0,
182
+ "volatility": 0.0,
183
+ "sharpe_ratio": 0.0,
184
+ "diversification": 0.0,
185
+ "max_drawdown": 0.0,
186
+ "num_holdings": 0
187
+ }
188
+
189
+ portfolio_inputs = []
190
+ for ticker, data in portfolio.items():
191
+ if ticker in historical_data and "close_prices" in historical_data[ticker]:
192
+ prices = historical_data[ticker].get("close_prices", [])
193
+ if prices:
194
+ portfolio_inputs.append({
195
+ "ticker": ticker,
196
+ "weight": Decimal(str(data["weight"] / 100)),
197
+ "prices": [Decimal(str(p)) for p in prices]
198
+ })
199
+
200
+ if not portfolio_inputs:
201
+ return {
202
+ "expected_return": 0.0,
203
+ "var_95": 0.0,
204
+ "cvar_95": 0.0,
205
+ "volatility": 0.0,
206
+ "sharpe_ratio": 0.0,
207
+ "diversification": 0.0,
208
+ "max_drawdown": 0.0,
209
+ "num_holdings": len(portfolio)
210
+ }
211
+
212
+ try:
213
+ risk_result = await self.mcp_router.call_risk_analyzer_mcp(
214
+ "analyze_risk",
215
+ {
216
+ "portfolio": portfolio_inputs,
217
+ "portfolio_value": Decimal(str(portfolio_value)),
218
+ "confidence_level": Decimal("0.95"),
219
+ "time_horizon": 1,
220
+ "method": "historical"
221
+ }
222
+ )
223
+ except Exception as e:
224
+ logger.warning(f"Risk analysis failed: {e}")
225
+ risk_result = {}
226
+
227
+ expected_return = self._calculate_expected_return(portfolio, historical_data)
228
+
229
+ weights = [data["weight"] / 100 for data in portfolio.values()]
230
+ herfindahl = sum(w ** 2 for w in weights)
231
+ diversification = 1 - herfindahl
232
+
233
+ return {
234
+ "expected_return": expected_return,
235
+ "var_95": float(risk_result.get("var_percentage", 0)),
236
+ "cvar_95": float(risk_result.get("cvar_percentage", 0)),
237
+ "volatility": float(risk_result.get("portfolio_volatility", 0)),
238
+ "sharpe_ratio": float(risk_result.get("sharpe_ratio", 0)),
239
+ "diversification": diversification,
240
+ "max_drawdown": float(risk_result.get("max_drawdown", 0)),
241
+ "num_holdings": len(portfolio)
242
+ }
243
+
244
+ def _calculate_expected_return(
245
+ self,
246
+ portfolio: Dict[str, Dict[str, Any]],
247
+ historical_data: Dict[str, Dict[str, Any]]
248
+ ) -> float:
249
+ """Calculate expected portfolio return based on historical data.
250
+
251
+ Args:
252
+ portfolio: Portfolio holdings
253
+ historical_data: Historical price data
254
+
255
+ Returns:
256
+ Expected annual return as percentage
257
+ """
258
+ total_return = 0.0
259
+ total_weight = 0.0
260
+
261
+ for ticker, data in portfolio.items():
262
+ if ticker in historical_data:
263
+ prices = historical_data[ticker].get("close_prices", [])
264
+ if len(prices) >= 2:
265
+ annual_return = ((prices[-1] - prices[0]) / prices[0]) * 100
266
+ weight = data["weight"] / 100
267
+ total_return += annual_return * weight
268
+ total_weight += weight
269
+
270
+ return total_return if total_weight > 0 else 0.0
271
+
272
+ def _calculate_impact(
273
+ self,
274
+ current: Dict[str, Any],
275
+ simulated: Dict[str, Any]
276
+ ) -> Dict[str, Dict[str, Any]]:
277
+ """Calculate impact deltas between portfolios.
278
+
279
+ Args:
280
+ current: Current portfolio metrics
281
+ simulated: Simulated portfolio metrics
282
+
283
+ Returns:
284
+ Impact metrics with current, simulated, delta, and pct_change
285
+ """
286
+ impact = {}
287
+ for metric in current:
288
+ if isinstance(current[metric], (int, float)):
289
+ current_val = current[metric]
290
+ simulated_val = simulated[metric]
291
+ delta = simulated_val - current_val
292
+ pct_change = (delta / current_val * 100) if current_val != 0 else 0
293
+
294
+ impact[metric] = {
295
+ "current": current_val,
296
+ "simulated": simulated_val,
297
+ "delta": delta,
298
+ "pct_change": pct_change
299
+ }
300
+
301
+ return impact
302
+
303
+ async def _compare_stress_tests(
304
+ self,
305
+ current_portfolio: Dict[str, Dict[str, Any]],
306
+ simulated_portfolio: Dict[str, Dict[str, Any]],
307
+ historical_data: Dict[str, Dict[str, Any]],
308
+ portfolio_value: float
309
+ ) -> Dict[str, Any]:
310
+ """Compare stress test results between portfolios.
311
+
312
+ Args:
313
+ current_portfolio: Current portfolio
314
+ simulated_portfolio: Simulated portfolio
315
+ historical_data: Historical price data
316
+ portfolio_value: Total portfolio value
317
+
318
+ Returns:
319
+ Stress test comparison results
320
+ """
321
+ scenarios = {
322
+ "market_crash": -0.20,
323
+ "correction": -0.10,
324
+ "mild_decline": -0.05
325
+ }
326
+
327
+ results = {}
328
+ for scenario_name, shock in scenarios.items():
329
+ current_impact = self._apply_market_shock(
330
+ current_portfolio, historical_data, shock, portfolio_value
331
+ )
332
+ simulated_impact = self._apply_market_shock(
333
+ simulated_portfolio, historical_data, shock, portfolio_value
334
+ )
335
+
336
+ results[scenario_name] = {
337
+ "shock": shock * 100,
338
+ "current_loss": current_impact,
339
+ "simulated_loss": simulated_impact,
340
+ "improvement": current_impact - simulated_impact
341
+ }
342
+
343
+ return results
344
+
345
+ def _apply_market_shock(
346
+ self,
347
+ portfolio: Dict[str, Dict[str, Any]],
348
+ historical_data: Dict[str, Dict[str, Any]],
349
+ shock: float,
350
+ portfolio_value: float
351
+ ) -> float:
352
+ """Calculate portfolio loss under market shock scenario.
353
+
354
+ Args:
355
+ portfolio: Portfolio holdings
356
+ historical_data: Historical price data
357
+ shock: Market shock as decimal (-0.20 = 20% crash)
358
+ portfolio_value: Total portfolio value
359
+
360
+ Returns:
361
+ Loss amount in currency
362
+ """
363
+ total_loss = 0.0
364
+ for ticker, data in portfolio.items():
365
+ weight = data["weight"] / 100
366
+ beta = self._estimate_beta(ticker, historical_data)
367
+ ticker_loss = portfolio_value * weight * shock * beta
368
+ total_loss += ticker_loss
369
+
370
+ return abs(total_loss)
371
+
372
+ def _estimate_beta(
373
+ self,
374
+ ticker: str,
375
+ historical_data: Dict[str, Dict[str, Any]]
376
+ ) -> float:
377
+ """Estimate beta for a ticker (simplified).
378
+
379
+ Args:
380
+ ticker: Ticker symbol
381
+ historical_data: Historical price data
382
+
383
+ Returns:
384
+ Estimated beta (defaults to 1.0)
385
+ """
386
+ high_beta_tickers = {"TSLA", "NVDA", "AMD", "MSTR", "COIN"}
387
+ low_beta_tickers = {"BND", "TLT", "VGSH", "GLD", "VNQ"}
388
+
389
+ if ticker in high_beta_tickers:
390
+ return 1.5
391
+ elif ticker in low_beta_tickers:
392
+ return 0.5
393
+ else:
394
+ return 1.0
395
+
396
+ def _generate_recommendations(
397
+ self,
398
+ impact: Dict[str, Dict[str, Any]],
399
+ stress: Dict[str, Any]
400
+ ) -> List[str]:
401
+ """Generate actionable recommendations based on impact.
402
+
403
+ Args:
404
+ impact: Impact metrics
405
+ stress: Stress test results
406
+
407
+ Returns:
408
+ List of recommendation strings
409
+ """
410
+ recommendations = []
411
+
412
+ var_change = impact.get("var_95", {}).get("pct_change", 0)
413
+ if var_change < -10:
414
+ recommendations.append("Risk reduction: VaR decreased significantly")
415
+ elif var_change > 10:
416
+ recommendations.append("Risk increase: VaR increased significantly - consider reviewing")
417
+
418
+ return_change = impact.get("expected_return", {}).get("pct_change", 0)
419
+ if return_change > 5:
420
+ recommendations.append("Return improvement: Expected returns increased")
421
+ elif return_change < -5:
422
+ recommendations.append("Return reduction: Expected returns decreased")
423
+
424
+ sharpe_change = impact.get("sharpe_ratio", {}).get("delta", 0)
425
+ if sharpe_change > 0.1:
426
+ recommendations.append("Efficiency improved: Better risk-adjusted returns")
427
+ elif sharpe_change < -0.1:
428
+ recommendations.append("Efficiency decreased: Worse risk-adjusted returns")
429
+
430
+ div_change = impact.get("diversification", {}).get("delta", 0)
431
+ if div_change > 0.05:
432
+ recommendations.append("Diversification improved")
433
+ elif div_change < -0.05:
434
+ recommendations.append("Concentration increased - consider diversifying")
435
+
436
+ crash_improvement = stress.get("market_crash", {}).get("improvement", 0)
437
+ if crash_improvement > 0:
438
+ recommendations.append(
439
+ f"Crash resilience improved: {crash_improvement:.0f} less loss in market crash"
440
+ )
441
+
442
+ if not recommendations:
443
+ recommendations.append("Changes have minimal impact on portfolio characteristics")
444
+
445
+ return recommendations
446
+
447
+ def _assess_changes(self, impact: Dict[str, Dict[str, Any]]) -> str:
448
+ """Overall assessment of the proposed changes.
449
+
450
+ Args:
451
+ impact: Impact metrics
452
+
453
+ Returns:
454
+ Assessment string
455
+ """
456
+ positive_signals = 0
457
+ negative_signals = 0
458
+
459
+ if impact.get("var_95", {}).get("delta", 0) < 0:
460
+ positive_signals += 1
461
+ else:
462
+ negative_signals += 1
463
+
464
+ if impact.get("expected_return", {}).get("delta", 0) > 0:
465
+ positive_signals += 1
466
+ else:
467
+ negative_signals += 1
468
+
469
+ if impact.get("sharpe_ratio", {}).get("delta", 0) > 0:
470
+ positive_signals += 1
471
+ else:
472
+ negative_signals += 1
473
+
474
+ if impact.get("diversification", {}).get("delta", 0) > 0:
475
+ positive_signals += 1
476
+ else:
477
+ negative_signals += 1
478
+
479
+ if positive_signals >= 3:
480
+ return "RECOMMENDED: Changes improve portfolio characteristics"
481
+ elif negative_signals >= 3:
482
+ return "CAUTION: Changes may degrade portfolio performance"
483
+ else:
484
+ return "NEUTRAL: Mixed impact - review individual metrics"
backend/agents/workflow_router.py CHANGED
@@ -10,6 +10,7 @@ from typing import Optional, Dict, Any, List
10
  from backend.agents.workflow import PortfolioAnalysisWorkflow
11
  from backend.agents.react_agent import PortfolioBuilderAgent
12
  from backend.agents.council import AdvisoryCouncil
 
13
  from backend.agents.personas import PersonaType
14
  from backend.models.agent_state import AgentState
15
 
@@ -112,7 +113,10 @@ class WorkflowRouter:
112
  )
113
 
114
  elif task == TaskType.TEST:
115
- raise ValueError("Test Changes workflow not yet implemented")
 
 
 
116
 
117
  else:
118
  raise ValueError(f"Unknown task type: {task}")
@@ -199,6 +203,29 @@ class WorkflowRouter:
199
  "council_result": council_result
200
  }
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  def get_available_tasks(self) -> list[Dict[str, Any]]:
203
  """Get list of available tasks with their status.
204
 
@@ -235,7 +262,7 @@ class WorkflowRouter:
235
  "title": "Test Changes",
236
  "description": "Preview impact before making portfolio changes",
237
  "icon": "flask",
238
- "enabled": False,
239
  "task_type": TaskType.TEST,
240
  },
241
  ]
 
10
  from backend.agents.workflow import PortfolioAnalysisWorkflow
11
  from backend.agents.react_agent import PortfolioBuilderAgent
12
  from backend.agents.council import AdvisoryCouncil
13
+ from backend.agents.rehearsal import PortfolioRehearsalEngine
14
  from backend.agents.personas import PersonaType
15
  from backend.models.agent_state import AgentState
16
 
 
113
  )
114
 
115
  elif task == TaskType.TEST:
116
+ raise ValueError(
117
+ "Test Changes requires different parameters. "
118
+ "Use route_test() method instead."
119
+ )
120
 
121
  else:
122
  raise ValueError(f"Unknown task type: {task}")
 
203
  "council_result": council_result
204
  }
205
 
206
+ async def route_test(
207
+ self,
208
+ current_portfolio: Dict[str, Dict[str, Any]],
209
+ proposed_changes: List[Dict[str, Any]],
210
+ portfolio_value: float = 100000.0
211
+ ) -> Dict[str, Any]:
212
+ """Route to the Test Changes workflow with rehearsal engine.
213
+
214
+ Args:
215
+ current_portfolio: {ticker: {weight: float, shares: int, value: float}}
216
+ proposed_changes: [{action: buy/sell, ticker: str, amount: float}]
217
+ portfolio_value: Total portfolio value for calculations
218
+
219
+ Returns:
220
+ Rehearsal result with comparison metrics and recommendations
221
+ """
222
+ engine = PortfolioRehearsalEngine(self.mcp_router)
223
+ return await engine.rehearse_changes(
224
+ current_portfolio,
225
+ proposed_changes,
226
+ portfolio_value
227
+ )
228
+
229
  def get_available_tasks(self) -> list[Dict[str, Any]]:
230
  """Get list of available tasks with their status.
231
 
 
262
  "title": "Test Changes",
263
  "description": "Preview impact before making portfolio changes",
264
  "icon": "flask",
265
+ "enabled": True,
266
  "task_type": TaskType.TEST,
267
  },
268
  ]
tests/test_rehearsal.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the PortfolioRehearsalEngine."""
2
+
3
+ import pytest
4
+ from unittest.mock import AsyncMock, MagicMock
5
+ from decimal import Decimal
6
+
7
+ from backend.agents.rehearsal import PortfolioRehearsalEngine
8
+
9
+
10
+ class TestPortfolioRehearsalEngine:
11
+ """Tests for the PortfolioRehearsalEngine class."""
12
+
13
+ @pytest.fixture
14
+ def mock_mcp_router(self):
15
+ """Create a mock MCP router."""
16
+ router = MagicMock()
17
+ router.call_yahoo_finance_mcp = AsyncMock(return_value={
18
+ "close_prices": [100.0, 102.0, 101.0, 103.0, 105.0]
19
+ })
20
+ router.call_risk_analyzer_mcp = AsyncMock(return_value={
21
+ "var_percentage": 0.05,
22
+ "cvar_percentage": 0.07,
23
+ "portfolio_volatility": 0.15,
24
+ "sharpe_ratio": 1.2,
25
+ "max_drawdown": 0.10
26
+ })
27
+ return router
28
+
29
+ @pytest.fixture
30
+ def engine(self, mock_mcp_router):
31
+ """Create a PortfolioRehearsalEngine instance."""
32
+ return PortfolioRehearsalEngine(mock_mcp_router)
33
+
34
+ def test_engine_initialization(self, engine, mock_mcp_router):
35
+ """Test engine initialisation."""
36
+ assert engine.mcp_router == mock_mcp_router
37
+
38
+ def test_apply_changes_sell(self, engine):
39
+ """Test applying sell changes to portfolio."""
40
+ portfolio = {
41
+ "AAPL": {"weight": 50},
42
+ "TSLA": {"weight": 30},
43
+ "BND": {"weight": 20}
44
+ }
45
+ changes = [{"action": "sell", "ticker": "TSLA", "amount": 15}]
46
+
47
+ result = engine._apply_changes(portfolio, changes)
48
+
49
+ assert "AAPL" in result
50
+ assert "TSLA" in result
51
+ assert "BND" in result
52
+ total = sum(h["weight"] for h in result.values())
53
+ assert abs(total - 100) < 0.01
54
+
55
+ def test_apply_changes_buy(self, engine):
56
+ """Test applying buy changes to portfolio."""
57
+ portfolio = {
58
+ "AAPL": {"weight": 50},
59
+ "BND": {"weight": 50}
60
+ }
61
+ changes = [{"action": "buy", "ticker": "TSLA", "amount": 20}]
62
+
63
+ result = engine._apply_changes(portfolio, changes)
64
+
65
+ assert "AAPL" in result
66
+ assert "BND" in result
67
+ assert "TSLA" in result
68
+ total = sum(h["weight"] for h in result.values())
69
+ assert abs(total - 100) < 0.01
70
+
71
+ def test_apply_changes_sell_all(self, engine):
72
+ """Test selling entire position removes ticker from portfolio."""
73
+ portfolio = {
74
+ "AAPL": {"weight": 50},
75
+ "TSLA": {"weight": 50}
76
+ }
77
+ changes = [{"action": "sell", "ticker": "TSLA", "amount": 50}]
78
+
79
+ result = engine._apply_changes(portfolio, changes)
80
+
81
+ assert "AAPL" in result
82
+ assert "TSLA" not in result
83
+ assert result["AAPL"]["weight"] == 100
84
+
85
+ def test_calculate_impact(self, engine):
86
+ """Test impact calculation between portfolios."""
87
+ current = {
88
+ "expected_return": 10.0,
89
+ "var_95": 5.0,
90
+ "sharpe_ratio": 1.0,
91
+ "num_holdings": 3
92
+ }
93
+ simulated = {
94
+ "expected_return": 12.0,
95
+ "var_95": 4.0,
96
+ "sharpe_ratio": 1.2,
97
+ "num_holdings": 4
98
+ }
99
+
100
+ impact = engine._calculate_impact(current, simulated)
101
+
102
+ assert "expected_return" in impact
103
+ assert impact["expected_return"]["current"] == 10.0
104
+ assert impact["expected_return"]["simulated"] == 12.0
105
+ assert impact["expected_return"]["delta"] == 2.0
106
+ assert impact["expected_return"]["pct_change"] == 20.0
107
+
108
+ assert impact["var_95"]["delta"] == -1.0
109
+
110
+ def test_generate_recommendations_risk_reduction(self, engine):
111
+ """Test recommendations for risk reduction."""
112
+ impact = {
113
+ "var_95": {"pct_change": -15},
114
+ "expected_return": {"pct_change": 3},
115
+ "sharpe_ratio": {"delta": 0.05},
116
+ "diversification": {"delta": 0.02}
117
+ }
118
+ stress = {}
119
+
120
+ recs = engine._generate_recommendations(impact, stress)
121
+
122
+ assert any("Risk reduction" in rec for rec in recs)
123
+
124
+ def test_generate_recommendations_risk_increase(self, engine):
125
+ """Test recommendations for risk increase."""
126
+ impact = {
127
+ "var_95": {"pct_change": 15},
128
+ "expected_return": {"pct_change": 3},
129
+ "sharpe_ratio": {"delta": 0.05},
130
+ "diversification": {"delta": 0.02}
131
+ }
132
+ stress = {}
133
+
134
+ recs = engine._generate_recommendations(impact, stress)
135
+
136
+ assert any("Risk increase" in rec for rec in recs)
137
+
138
+ def test_assess_changes_recommended(self, engine):
139
+ """Test assessment returns RECOMMENDED for positive changes."""
140
+ impact = {
141
+ "var_95": {"delta": -0.01},
142
+ "expected_return": {"delta": 0.02},
143
+ "sharpe_ratio": {"delta": 0.1},
144
+ "diversification": {"delta": 0.05}
145
+ }
146
+
147
+ assessment = engine._assess_changes(impact)
148
+
149
+ assert "RECOMMENDED" in assessment
150
+
151
+ def test_assess_changes_caution(self, engine):
152
+ """Test assessment returns CAUTION for negative changes."""
153
+ impact = {
154
+ "var_95": {"delta": 0.02},
155
+ "expected_return": {"delta": -0.01},
156
+ "sharpe_ratio": {"delta": -0.1},
157
+ "diversification": {"delta": -0.05}
158
+ }
159
+
160
+ assessment = engine._assess_changes(impact)
161
+
162
+ assert "CAUTION" in assessment
163
+
164
+ def test_assess_changes_neutral(self, engine):
165
+ """Test assessment returns NEUTRAL for mixed changes."""
166
+ impact = {
167
+ "var_95": {"delta": -0.01},
168
+ "expected_return": {"delta": -0.01},
169
+ "sharpe_ratio": {"delta": 0.1},
170
+ "diversification": {"delta": -0.05}
171
+ }
172
+
173
+ assessment = engine._assess_changes(impact)
174
+
175
+ assert "NEUTRAL" in assessment
176
+
177
+ def test_estimate_beta(self, engine):
178
+ """Test beta estimation for different ticker types."""
179
+ assert engine._estimate_beta("TSLA", {}) == 1.5
180
+ assert engine._estimate_beta("BND", {}) == 0.5
181
+ assert engine._estimate_beta("AAPL", {}) == 1.0
182
+
183
+ def test_calculate_expected_return(self, engine):
184
+ """Test expected return calculation."""
185
+ portfolio = {
186
+ "AAPL": {"weight": 60},
187
+ "BND": {"weight": 40}
188
+ }
189
+ historical_data = {
190
+ "AAPL": {"close_prices": [100.0, 110.0]},
191
+ "BND": {"close_prices": [50.0, 51.0]}
192
+ }
193
+
194
+ result = engine._calculate_expected_return(portfolio, historical_data)
195
+
196
+ aapl_return = 10.0 * 0.6
197
+ bnd_return = 2.0 * 0.4
198
+ expected = aapl_return + bnd_return
199
+ assert abs(result - expected) < 0.01
200
+
201
+ @pytest.mark.asyncio
202
+ async def test_rehearse_changes_structure(self, engine, mock_mcp_router):
203
+ """Test that rehearse_changes returns expected structure."""
204
+ current_portfolio = {
205
+ "AAPL": {"weight": 50},
206
+ "TSLA": {"weight": 30},
207
+ "BND": {"weight": 20}
208
+ }
209
+ proposed_changes = [
210
+ {"action": "sell", "ticker": "TSLA", "amount": 10},
211
+ {"action": "buy", "ticker": "VTI", "amount": 10}
212
+ ]
213
+
214
+ result = await engine.rehearse_changes(
215
+ current_portfolio,
216
+ proposed_changes,
217
+ portfolio_value=100000.0
218
+ )
219
+
220
+ assert "current" in result
221
+ assert "simulated" in result
222
+ assert "impact" in result
223
+ assert "stress_comparison" in result
224
+ assert "recommendations" in result
225
+ assert "overall_assessment" in result
226
+
227
+ assert "portfolio" in result["current"]
228
+ assert "metrics" in result["current"]
229
+ assert "portfolio" in result["simulated"]
230
+ assert "metrics" in result["simulated"]
231
+
232
+ @pytest.mark.asyncio
233
+ async def test_rehearse_changes_empty_portfolio(self, engine, mock_mcp_router):
234
+ """Test rehearsing changes with empty portfolio."""
235
+ current_portfolio = {}
236
+ proposed_changes = [
237
+ {"action": "buy", "ticker": "VTI", "amount": 100}
238
+ ]
239
+
240
+ result = await engine.rehearse_changes(
241
+ current_portfolio,
242
+ proposed_changes,
243
+ portfolio_value=100000.0
244
+ )
245
+
246
+ assert "current" in result
247
+ assert "simulated" in result
248
+ assert result["simulated"]["portfolio"]["VTI"]["weight"] == 100
249
+
250
+ @pytest.mark.asyncio
251
+ async def test_analyse_portfolio_empty(self, engine):
252
+ """Test analysing empty portfolio returns zero metrics."""
253
+ result = await engine._analyse_portfolio({}, {}, 100000.0)
254
+
255
+ assert result["expected_return"] == 0.0
256
+ assert result["var_95"] == 0.0
257
+ assert result["num_holdings"] == 0
258
+
259
+ @pytest.mark.asyncio
260
+ async def test_compare_stress_tests(self, engine, mock_mcp_router):
261
+ """Test stress test comparison."""
262
+ current_portfolio = {"AAPL": {"weight": 100}}
263
+ simulated_portfolio = {"BND": {"weight": 100}}
264
+ historical_data = {}
265
+
266
+ result = await engine._compare_stress_tests(
267
+ current_portfolio,
268
+ simulated_portfolio,
269
+ historical_data,
270
+ 100000.0
271
+ )
272
+
273
+ assert "market_crash" in result
274
+ assert "correction" in result
275
+ assert "mild_decline" in result
276
+
277
+ for scenario in result.values():
278
+ assert "shock" in scenario
279
+ assert "current_loss" in scenario
280
+ assert "simulated_loss" in scenario
281
+ assert "improvement" in scenario