| """FinAgent Gradio frontend — application entry point. |
| |
| This module constructs the Gradio Blocks application used as the FinAgent |
| Hugging Face Space. The outer shell (title, theme, custom CSS) is defined |
| in :func:`create_app`; UI widgets, session state, and event wiring are |
| added by later tasks (6.2, 6.3, 6.4). |
| |
| The vLLM inference endpoint URL is read from the ``VLLM_ENDPOINT_URL`` |
| environment variable at module import time so it can be validated early |
| by the Analyze event handler. |
| """ |
|
|
| import os |
| import time |
| from datetime import datetime |
|
|
| import gradio as gr |
|
|
| from rendering import ( |
| build_css, |
| render_activity_entry, |
| render_activity_feed, |
| render_error_card, |
| render_signal_card, |
| render_summary, |
| ) |
| from validation import validate_portfolio_value, validate_tickers |
|
|
| |
| |
| |
| VLLM_ENDPOINT_URL = os.environ.get("VLLM_ENDPOINT_URL") |
|
|
| |
| |
| |
| TIMEOUT_SECONDS = 180 |
|
|
|
|
| def _format_progress_message( |
| ticker: str, |
| i: int, |
| total: int, |
| elapsed_seconds: int, |
| ) -> str: |
| """Format the in-flight progress message shown above the activity feed. |
| |
| Pulled out of :func:`run_analysis` as a pure, side-effect-free helper |
| so the progress string's contract (Requirements 4.2, 4.3) can be |
| exercised directly by property tests without constructing a full |
| Gradio event loop. |
| |
| Args: |
| ticker: The ticker symbol currently being analyzed. |
| i: 1-indexed position of ``ticker`` in the watchlist. |
| total: Total number of tickers in the watchlist (``i <= total``). |
| elapsed_seconds: Whole seconds elapsed since the analysis began; |
| rendered verbatim in the ``(elapsed: Ns)`` suffix. |
| |
| Returns: |
| A Markdown-formatted string of the form:: |
| |
| **Analyzing ticker {i} of {total}** — {ticker} (elapsed: {N}s) |
| |
| which contains the ticker, ``i``, and ``total`` as substrings |
| (Property 6 in the design document). |
| """ |
| return ( |
| f"**Analyzing ticker {i} of {total}** — {ticker} " |
| f"(elapsed: {elapsed_seconds}s)" |
| ) |
|
|
|
|
| def run_analysis( |
| ticker_input, |
| risk_tolerance, |
| portfolio_value, |
| trading_style, |
| activity_log, |
| signals_state, |
| ): |
| """Analyze button event handler — streams UI updates as a generator. |
| |
| This is the task 7.1 slice of the full handler: it covers environment |
| and input validation plus initial state setup. The pipeline execution |
| loop (task 7.2), error handling / completion yields (task 7.3), and |
| the ``_render_signals_dashboard`` helper (task 7.4) are added by |
| subsequent tasks. |
| |
| Each ``yield`` produces the tuple wired to ``analyze_btn.click``'s |
| outputs in :func:`create_app`, in this order:: |
| |
| (analyze_btn, error_display, progress_text, |
| activity_feed, signals_dashboard, |
| activity_log, signals_state) |
| |
| Yields: |
| Tuples of :class:`gradio.update` calls and session-state values |
| that incrementally update the UI during analysis. |
| """ |
| |
| |
| |
| |
| if not VLLM_ENDPOINT_URL: |
| yield ( |
| gr.update(interactive=True), |
| gr.update( |
| value=( |
| "❌ Configuration error: `VLLM_ENDPOINT_URL` " |
| "environment variable is not set." |
| ), |
| visible=True, |
| ), |
| gr.update(visible=False), |
| gr.update(), |
| gr.update(), |
| activity_log, |
| signals_state, |
| ) |
| return |
|
|
| |
| validation = validate_tickers(ticker_input) |
| if not validation.valid: |
| yield ( |
| gr.update(interactive=True), |
| gr.update(value=f"❌ {validation.error_message}", visible=True), |
| gr.update(visible=False), |
| gr.update(), |
| gr.update(), |
| activity_log, |
| signals_state, |
| ) |
| return |
|
|
| |
| portfolio_error = validate_portfolio_value(portfolio_value) |
| if portfolio_error: |
| yield ( |
| gr.update(interactive=True), |
| gr.update(value=f"❌ {portfolio_error}", visible=True), |
| gr.update(visible=False), |
| gr.update(), |
| gr.update(), |
| activity_log, |
| signals_state, |
| ) |
| return |
|
|
| |
| |
| |
| |
| activity_log = [] |
| signals_state = [] |
| analysis_start = time.time() |
| tickers = validation.tickers |
| total = len(tickers) |
|
|
| |
| |
| start_entry = render_activity_entry( |
| datetime.now(), |
| "System", |
| f"Analysis started for {total} ticker(s)", |
| False, |
| ) |
| activity_log.append(start_entry) |
|
|
| |
| |
| |
| yield ( |
| gr.update(interactive=False), |
| gr.update(visible=False), |
| gr.update(value=f"**Analyzing ticker 1 of {total}**", visible=True), |
| render_activity_feed(activity_log), |
| "", |
| activity_log, |
| signals_state, |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| try: |
| |
| |
| |
| |
| from crew import ( |
| LLMConfig, |
| OrchestratorConfig, |
| TradePreferences, |
| WatchlistRunner, |
| ) |
| from crew.callbacks import ActivityEvent, ActivityFeedCallback, EventType |
|
|
| |
| config = OrchestratorConfig( |
| llm=LLMConfig(base_url=VLLM_ENDPOINT_URL), |
| ) |
|
|
| preferences = TradePreferences( |
| risk_tolerance=risk_tolerance, |
| trading_style=trading_style, |
| portfolio_value=float(portfolio_value), |
| ) |
|
|
| |
| |
| |
| |
| pending_events: list[ActivityEvent] = [] |
|
|
| def event_handler(event: ActivityEvent) -> None: |
| pending_events.append(event) |
|
|
| callback = ActivityFeedCallback(handler=event_handler) |
|
|
| |
| |
| |
| |
| from tools import ( |
| search_news, |
| get_price_change, |
| get_volume, |
| get_financials, |
| get_earnings, |
| get_peers, |
| get_price_history, |
| calculate_indicators, |
| calculate_position_size, |
| set_stop_loss, |
| ) |
|
|
| crew_tools = { |
| "market_scanner": [search_news, get_price_change, get_volume], |
| "fundamental_analyst": [get_financials, get_earnings, get_peers], |
| "technical_analyst": [get_price_history, calculate_indicators], |
| "risk_manager": [calculate_position_size, set_stop_loss], |
| } |
|
|
| runner = WatchlistRunner( |
| config=config, |
| tools=crew_tools, |
| callback=callback, |
| preferences=preferences, |
| ) |
|
|
| for i, ticker in enumerate(tickers, 1): |
| |
| |
| |
| |
| |
| elapsed = time.time() - analysis_start |
| if elapsed > TIMEOUT_SECONDS: |
| timeout_entry = render_activity_entry( |
| datetime.now(), |
| "System", |
| f"⚠️ Analysis timed out after {TIMEOUT_SECONDS}s", |
| False, |
| ) |
| activity_log.append(timeout_entry) |
| yield ( |
| gr.update(interactive=True), |
| gr.update( |
| value="⚠️ Analysis timed out. Try fewer tickers.", |
| visible=True, |
| ), |
| gr.update(visible=False), |
| render_activity_feed(activity_log), |
| _render_signals_dashboard(signals_state), |
| activity_log, |
| signals_state, |
| ) |
| return |
|
|
| |
| |
| |
| |
| |
| progress_msg = _format_progress_message( |
| ticker=ticker, |
| i=i, |
| total=total, |
| elapsed_seconds=int(elapsed), |
| ) |
|
|
| |
| |
| |
| |
| result = runner._run_single(ticker) |
|
|
| |
| |
| |
| for event in pending_events: |
| entry = render_activity_entry( |
| event.timestamp, |
| event.agent_name, |
| event.message, |
| is_spinner=(event.event_type == EventType.TASK_START), |
| ) |
| activity_log.append(entry) |
| pending_events.clear() |
|
|
| |
| |
| |
| |
| if result.success and result.signal is not None: |
| signals_state.append(result.signal) |
| else: |
| signals_state.append( |
| { |
| "ticker": ticker, |
| "error": result.error or "Unknown error", |
| } |
| ) |
|
|
| |
| |
| |
| yield ( |
| gr.update(interactive=False), |
| gr.update(visible=False), |
| gr.update(value=progress_msg, visible=True), |
| render_activity_feed(activity_log), |
| _render_signals_dashboard(signals_state), |
| activity_log, |
| signals_state, |
| ) |
|
|
| except Exception as e: |
| |
| |
| |
| |
| |
| |
| |
| |
| error_entry = render_activity_entry( |
| datetime.now(), |
| "System", |
| f"❌ Error: {str(e)}", |
| False, |
| ) |
| activity_log.append(error_entry) |
| yield ( |
| gr.update(interactive=True), |
| gr.update(value=f"❌ An error occurred: {str(e)}", visible=True), |
| gr.update(visible=False), |
| render_activity_feed(activity_log), |
| _render_signals_dashboard(signals_state), |
| activity_log, |
| signals_state, |
| ) |
| return |
|
|
| |
| |
| |
| |
| |
| elapsed_total = time.time() - analysis_start |
| complete_entry = render_activity_entry( |
| datetime.now(), |
| "System", |
| f"✅ Analysis complete ({int(elapsed_total)}s)", |
| False, |
| ) |
| activity_log.append(complete_entry) |
|
|
| yield ( |
| gr.update(interactive=True), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| render_activity_feed(activity_log), |
| _render_signals_dashboard(signals_state), |
| activity_log, |
| signals_state, |
| ) |
|
|
|
|
| def _render_signals_dashboard(signals: list) -> str: |
| """Render the full signals dashboard HTML (summary bar + cards). |
| |
| Iterates the collected results and produces a dashboard composed of |
| an aggregate :func:`render_summary` bar followed by one HTML card per |
| result: a :func:`render_signal_card` for :class:`TradingSignal`-like |
| objects and a :func:`render_error_card` for error dicts of the form |
| ``{"ticker": ..., "error": ...}``. |
| |
| The action label is read defensively via ``getattr(item.action, |
| "value", item.action)`` so this helper works both with the orchestrator's |
| ``Action`` enum (``Action.BUY.value == "BUY"``) and with plain string |
| actions, without importing from the ``crew`` package. |
| |
| Args: |
| signals: Ordered list of :class:`TradingSignal`-like objects and/or |
| error dicts accumulated during an analysis run. |
| |
| Returns: |
| Concatenated HTML string (summary bar followed by card HTML joined |
| by newlines), or an empty string when ``signals`` is empty. |
| """ |
| if not signals: |
| return "" |
|
|
| cards: list[str] = [] |
| buy_count = 0 |
| sell_count = 0 |
| hold_count = 0 |
|
|
| for item in signals: |
| |
| |
| if isinstance(item, dict) and "error" in item: |
| cards.append(render_error_card(item["ticker"], item["error"])) |
| continue |
|
|
| cards.append(render_signal_card(item)) |
|
|
| |
| |
| |
| |
| action_label = str(getattr(item.action, "value", item.action)).upper() |
| if action_label == "BUY": |
| buy_count += 1 |
| elif action_label == "SELL": |
| sell_count += 1 |
| elif action_label == "HOLD": |
| hold_count += 1 |
|
|
| total = len(signals) |
| summary = render_summary(total, buy_count, sell_count, hold_count) |
|
|
| return summary + "\n".join(cards) |
|
|
|
|
| def create_app() -> gr.Blocks: |
| """Build and return the Gradio Blocks application. |
| |
| Creates the outer shell: page title, dark Base theme with an emerald |
| primary hue and slate neutral hue, and the custom dark financial |
| terminal CSS from :func:`rendering.build_css`. |
| |
| Returns: |
| The configured :class:`gradio.Blocks` instance, ready to have UI |
| widgets and event handlers attached by subsequent tasks. |
| """ |
| custom_css = build_css() |
|
|
| with gr.Blocks( |
| title="FinAgent - AI Trading Signals", |
| theme=gr.themes.Base( |
| primary_hue="emerald", |
| neutral_hue="slate", |
| ), |
| css=custom_css, |
| ) as app: |
| |
| gr.Markdown( |
| "# 🤖 FinAgent\n" |
| "### AI-Powered Trading Signal Generator" |
| ) |
|
|
| |
| activity_log = gr.State([]) |
| signals_state = gr.State([]) |
| start_time = gr.State(None) |
|
|
| |
| with gr.Row(): |
| |
| with gr.Column(scale=1): |
| ticker_input = gr.Textbox( |
| label="Watchlist", |
| placeholder="AAPL, NVDA, TSLA, BTC-USD", |
| info="Comma-separated tickers (max 10)", |
| ) |
| risk_tolerance = gr.Dropdown( |
| choices=["Conservative", "Moderate", "Aggressive"], |
| value="Moderate", |
| label="Risk Tolerance", |
| ) |
| portfolio_value = gr.Number( |
| value=10000, |
| minimum=0, |
| label="Portfolio Value ($)", |
| ) |
| trading_style = gr.Dropdown( |
| choices=["Day Trading", "Swing Trading", "Position Trading"], |
| value="Swing Trading", |
| label="Trading Style", |
| ) |
| analyze_btn = gr.Button( |
| "🔍 Analyze", |
| variant="primary", |
| interactive=True, |
| ) |
| error_display = gr.Markdown(visible=False) |
|
|
| |
| with gr.Column(scale=2): |
| progress_text = gr.Markdown(visible=False) |
| activity_feed = gr.HTML( |
| label="Agent Activity", |
| value="<div class='activity-feed'></div>", |
| ) |
| signals_dashboard = gr.HTML( |
| label="Trading Signals", |
| value="", |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| analyze_btn.click( |
| fn=run_analysis, |
| inputs=[ |
| ticker_input, |
| risk_tolerance, |
| portfolio_value, |
| trading_style, |
| activity_log, |
| signals_state, |
| ], |
| outputs=[ |
| analyze_btn, |
| error_display, |
| progress_text, |
| activity_feed, |
| signals_dashboard, |
| activity_log, |
| signals_state, |
| ], |
| ) |
|
|
| |
| gr.Markdown( |
| "⚠️ **Disclaimer:** Trading signals are for informational purposes only " |
| "and do not constitute financial advice. Always do your own research." |
| ) |
|
|
| |
| |
| |
| |
| app.ticker_input = ticker_input |
| app.risk_tolerance = risk_tolerance |
| app.portfolio_value = portfolio_value |
| app.trading_style = trading_style |
| app.analyze_btn = analyze_btn |
| app.error_display = error_display |
| app.progress_text = progress_text |
| app.activity_feed = activity_feed |
| app.signals_dashboard = signals_dashboard |
| app.activity_log = activity_log |
| app.signals_state = signals_state |
| app.start_time = start_time |
|
|
| return app |
|
|
|
|
| if __name__ == "__main__": |
| |
| |
| create_app().launch(server_name="0.0.0.0") |
|
|