From 1f5ee3698eb38577dc58850a4c10e2a7cbbc5058 Mon Sep 17 00:00:00 2001 From: cillin Date: Mon, 16 Mar 2026 22:28:41 +0800 Subject: [PATCH] Add explain analysis workflow and UI --- backend/cli.py | 420 +++++- backend/core/state_sync.py | 29 +- backend/data/historical_price_manager.py | 35 +- backend/data/market_ingest.py | 149 ++ backend/data/market_store.py | 1074 +++++++++++++ backend/data/news_alignment.py | 64 + backend/data/polygon_client.py | 161 ++ backend/enrich/__init__.py | 2 + backend/enrich/llm_enricher.py | 296 ++++ backend/enrich/news_enricher.py | 362 +++++ backend/explain/__init__.py | 2 + backend/explain/category_engine.py | 69 + backend/explain/range_explainer.py | 214 +++ backend/explain/similarity_service.py | 202 +++ backend/explain/story_service.py | 127 ++ backend/services/gateway.py | 736 ++++++++- backend/services/market.py | 18 + backend/services/research_db.py | 280 ++++ backend/services/storage.py | 4 + backend/tests/test_cli.py | 236 +++ .../tests/test_gateway_explain_handlers.py | 384 +++++ .../tests/test_historical_price_manager.py | 65 + backend/tests/test_llm_enricher.py | 133 ++ backend/tests/test_market_store_report.py | 54 + backend/tests/test_news_enricher.py | 174 +++ backend/tests/test_range_explainer.py | 54 + env.template | 6 + frontend/package.json | 7 +- frontend/src/App.jsx | 522 ++++++- frontend/src/components/StockExplainView.jsx | 1328 ++++------------- frontend/src/components/WatchlistPanel.jsx | 4 +- .../explain/ExplainEventsSection.jsx | 157 ++ .../explain/ExplainMaintenanceSection.jsx | 249 ++++ .../explain/ExplainMentionsSection.jsx | 77 + .../components/explain/ExplainNewsSection.jsx | 308 ++++ .../explain/ExplainPriceSection.jsx | 155 ++ .../explain/ExplainRangeSection.jsx | 220 +++ .../explain/ExplainSignalsSection.jsx | 123 ++ .../explain/ExplainSimilarDaysSection.jsx | 111 ++ .../explain/ExplainStorySection.jsx | 58 + .../explain/ExplainSummarySection.jsx | 87 ++ .../explain/ExplainTradesSection.jsx | 74 + .../src/components/explain/explainUtils.js | 281 ++++ .../src/components/explain/useExplainModel.js | 664 +++++++++ .../explain/useExplainModel.test.jsx | 156 ++ frontend/vite.config.js | 4 +- pyproject.toml | 5 +- runs/smoke_fullstack/BOOTSTRAP.md | 9 +- runs/smoke_fullstack/state/server_state.json | 415 +----- 49 files changed, 8888 insertions(+), 1476 deletions(-) create mode 100644 backend/data/market_ingest.py create mode 100644 backend/data/market_store.py create mode 100644 backend/data/news_alignment.py create mode 100644 backend/data/polygon_client.py create mode 100644 backend/enrich/__init__.py create mode 100644 backend/enrich/llm_enricher.py create mode 100644 backend/enrich/news_enricher.py create mode 100644 backend/explain/__init__.py create mode 100644 backend/explain/category_engine.py create mode 100644 backend/explain/range_explainer.py create mode 100644 backend/explain/similarity_service.py create mode 100644 backend/explain/story_service.py create mode 100644 backend/services/research_db.py create mode 100644 backend/tests/test_cli.py create mode 100644 backend/tests/test_gateway_explain_handlers.py create mode 100644 backend/tests/test_historical_price_manager.py create mode 100644 backend/tests/test_llm_enricher.py create mode 100644 backend/tests/test_market_store_report.py create mode 100644 backend/tests/test_news_enricher.py create mode 100644 backend/tests/test_range_explainer.py create mode 100644 frontend/src/components/explain/ExplainEventsSection.jsx create mode 100644 frontend/src/components/explain/ExplainMaintenanceSection.jsx create mode 100644 frontend/src/components/explain/ExplainMentionsSection.jsx create mode 100644 frontend/src/components/explain/ExplainNewsSection.jsx create mode 100644 frontend/src/components/explain/ExplainPriceSection.jsx create mode 100644 frontend/src/components/explain/ExplainRangeSection.jsx create mode 100644 frontend/src/components/explain/ExplainSignalsSection.jsx create mode 100644 frontend/src/components/explain/ExplainSimilarDaysSection.jsx create mode 100644 frontend/src/components/explain/ExplainStorySection.jsx create mode 100644 frontend/src/components/explain/ExplainSummarySection.jsx create mode 100644 frontend/src/components/explain/ExplainTradesSection.jsx create mode 100644 frontend/src/components/explain/explainUtils.js create mode 100644 frontend/src/components/explain/useExplainModel.js create mode 100644 frontend/src/components/explain/useExplainModel.test.jsx diff --git a/backend/cli.py b/backend/cli.py index c30c9db..a12d543 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -12,7 +12,7 @@ import os import shutil import subprocess import sys -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Optional from zoneinfo import ZoneInfo @@ -21,18 +21,27 @@ import typer from rich.console import Console from rich.panel import Panel from rich.prompt import Confirm +from rich.table import Table +from dotenv import load_dotenv from backend.agents.prompt_loader import PromptLoader from backend.agents.workspace_manager import WorkspaceManager +from backend.data.market_ingest import ingest_symbols +from backend.data.market_store import MarketStore +from backend.enrich.llm_enricher import get_explain_model_info, llm_enrichment_enabled +from backend.enrich.news_enricher import enrich_symbols app = typer.Typer( name="evotraders", help="EvoTraders: A self-evolving multi-agent trading system", add_completion=False, ) +ingest_app = typer.Typer(help="Ingest Polygon market data into the research warehouse.") +app.add_typer(ingest_app, name="ingest") console = Console() _prompt_loader = PromptLoader() +load_dotenv() def get_project_root() -> Path: @@ -204,6 +213,189 @@ def initialize_workspace(config_name: str) -> Path: return workspace_manager.get_run_dir(config_name) +def _resolve_symbols(raw_tickers: Optional[str], config_name: Optional[str] = None) -> list[str]: + """Resolve symbols from explicit input or runtime bootstrap config.""" + if raw_tickers and raw_tickers.strip(): + return [ + item.strip().upper() + for item in raw_tickers.split(",") + if item.strip() + ] + + workspace_manager = WorkspaceManager(project_root=get_project_root()) + bootstrap_path = workspace_manager.get_run_dir(config_name or "default") / "BOOTSTRAP.md" + if bootstrap_path.exists(): + content = bootstrap_path.read_text(encoding="utf-8") + for line in content.splitlines(): + if line.strip().startswith("tickers:"): + raw = line.split(":", 1)[1] + return [ + item.strip().upper() + for item in raw.split(",") + if item.strip() + ] + return [] + + +def _filter_problematic_report_rows(rows: list[dict]) -> list[dict]: + """Keep tickers with incomplete coverage or without any LLM-enriched rows.""" + return [ + row + for row in rows + if float(row.get("coverage_pct") or 0.0) < 100.0 + or int(row.get("llm_count") or 0) == 0 + ] + + +def auto_update_market_store( + config_name: str, + *, + end_date: Optional[str] = None, +) -> None: + """Refresh the long-lived Polygon market store for the active watchlist.""" + api_key = os.getenv("POLYGON_API_KEY", "").strip() + if not api_key: + console.print( + "[dim]Skipping Polygon market store update: POLYGON_API_KEY not set[/dim]", + ) + return + + symbols = _resolve_symbols(None, config_name) + if not symbols: + console.print( + f"[dim]Skipping Polygon market store update: no tickers found for config '{config_name}'[/dim]", + ) + return + + target_end = end_date or datetime.now().date().isoformat() + console.print( + f"[cyan]Updating Polygon market store for {', '.join(symbols)} -> {target_end}[/cyan]", + ) + + try: + results = ingest_symbols( + symbols, + mode="incremental", + end_date=target_end, + ) + except Exception as exc: + console.print( + f"[yellow]Polygon market store update failed, continuing startup: {exc}[/yellow]", + ) + return + + for result in results: + console.print( + "[green]" + f"{result['symbol']}" + "[/green] " + f"prices={result['prices']} news={result['news']} aligned={result['aligned']}" + ) + + +def auto_prepare_backtest_market_store( + config_name: str, + *, + start_date: str, + end_date: str, +) -> None: + """Ensure the market store has the requested backtest window for the active watchlist.""" + api_key = os.getenv("POLYGON_API_KEY", "").strip() + if not api_key: + console.print( + "[dim]Skipping Polygon backtest preload: POLYGON_API_KEY not set[/dim]", + ) + return + + symbols = _resolve_symbols(None, config_name) + if not symbols: + console.print( + f"[dim]Skipping Polygon backtest preload: no tickers found for config '{config_name}'[/dim]", + ) + return + + console.print( + f"[cyan]Preparing Polygon market store for backtest {start_date} -> {end_date} " + f"({', '.join(symbols)})[/cyan]", + ) + + try: + results = ingest_symbols( + symbols, + mode="full", + start_date=start_date, + end_date=end_date, + ) + except Exception as exc: + console.print( + f"[yellow]Polygon backtest preload failed, continuing startup: {exc}[/yellow]", + ) + return + + for result in results: + console.print( + "[green]" + f"{result['symbol']}" + "[/green] " + f"prices={result['prices']} news={result['news']} aligned={result['aligned']}" + ) + + +def auto_enrich_market_store( + config_name: str, + *, + end_date: Optional[str] = None, + lookback_days: int = 120, + force: bool = False, +) -> None: + """Refresh explain-oriented enriched news for the active watchlist.""" + symbols = _resolve_symbols(None, config_name) + if not symbols: + console.print( + f"[dim]Skipping explain enrich: no tickers found for config '{config_name}'[/dim]", + ) + return + + target_end = end_date or datetime.now().date().isoformat() + try: + end_dt = datetime.strptime(target_end, "%Y-%m-%d") + except ValueError: + console.print( + f"[yellow]Skipping explain enrich: invalid end date {target_end}[/yellow]", + ) + return + + start_date = (end_dt - timedelta(days=max(1, lookback_days))).date().isoformat() + console.print( + f"[cyan]Refreshing explain enrich for {', '.join(symbols)} -> {target_end}[/cyan]", + ) + store = MarketStore() + try: + results = enrich_symbols( + store, + symbols, + start_date=start_date, + end_date=target_end, + limit=300, + skip_existing=not force, + ) + except Exception as exc: + console.print( + f"[yellow]Explain enrich failed, continuing startup: {exc}[/yellow]", + ) + return + + for result in results: + console.print( + "[green]" + f"{result['symbol']}" + "[/green] " + f"news={result['news_count']} queued={result['queued_count']} analyzed={result['analyzed']} " + f"skipped={result['skipped_existing_count']} deduped={result['deduped_count']} " + f"llm={result['llm_count']} local={result['local_count']}" + ) + + @app.command("init-workspace") def init_workspace( config_name: str = typer.Option( @@ -223,6 +415,213 @@ def init_workspace( ) +@ingest_app.command("full") +def ingest_full( + tickers: Optional[str] = typer.Option( + None, + "--tickers", + "-t", + help="Comma-separated tickers to ingest", + ), + start: Optional[str] = typer.Option( + None, + "--start", + help="Start date for full ingestion (YYYY-MM-DD)", + ), + end: Optional[str] = typer.Option( + None, + "--end", + help="End date for ingestion (YYYY-MM-DD)", + ), + config_name: str = typer.Option( + "default", + "--config-name", + "-c", + help="Fallback config to read tickers from BOOTSTRAP.md", + ), +): + """Run full Polygon ingestion for the specified symbols.""" + symbols = _resolve_symbols(tickers, config_name) + if not symbols: + console.print("[red]No tickers provided and none found in BOOTSTRAP.md[/red]") + raise typer.Exit(1) + + console.print(f"[cyan]Starting full Polygon ingest for {', '.join(symbols)}[/cyan]") + results = ingest_symbols(symbols, mode="full", start_date=start, end_date=end) + for result in results: + console.print( + f"[green]{result['symbol']}[/green] prices={result['prices']} news={result['news']} aligned={result['aligned']}" + ) + + +@ingest_app.command("update") +def ingest_update( + tickers: Optional[str] = typer.Option( + None, + "--tickers", + "-t", + help="Comma-separated tickers to update", + ), + end: Optional[str] = typer.Option( + None, + "--end", + help="Optional end date override (YYYY-MM-DD)", + ), + config_name: str = typer.Option( + "default", + "--config-name", + "-c", + help="Fallback config to read tickers from BOOTSTRAP.md", + ), +): + """Run incremental Polygon ingestion using stored watermarks.""" + symbols = _resolve_symbols(tickers, config_name) + if not symbols: + console.print("[red]No tickers provided and none found in BOOTSTRAP.md[/red]") + raise typer.Exit(1) + + console.print(f"[cyan]Starting incremental Polygon ingest for {', '.join(symbols)}[/cyan]") + results = ingest_symbols(symbols, mode="incremental", end_date=end) + for result in results: + console.print( + f"[green]{result['symbol']}[/green] prices={result['prices']} news={result['news']} aligned={result['aligned']}" + ) + + +@ingest_app.command("enrich") +def ingest_enrich( + tickers: Optional[str] = typer.Option( + None, + "--tickers", + "-t", + help="Comma-separated tickers to enrich", + ), + start: Optional[str] = typer.Option( + None, + "--start", + help="Optional start date for enrichment window (YYYY-MM-DD)", + ), + end: Optional[str] = typer.Option( + None, + "--end", + help="Optional end date for enrichment window (YYYY-MM-DD)", + ), + limit: int = typer.Option( + 300, + "--limit", + help="Maximum raw news rows per ticker to analyze", + ), + force: bool = typer.Option( + False, + "--force", + help="Re-analyze already enriched news instead of only missing rows", + ), + config_name: str = typer.Option( + "default", + "--config-name", + "-c", + help="Fallback config to read tickers from BOOTSTRAP.md", + ), +): + """Run explain-oriented news enrichment for symbols already in the market store.""" + symbols = _resolve_symbols(tickers, config_name) + if not symbols: + console.print("[red]No tickers provided and none found in BOOTSTRAP.md[/red]") + raise typer.Exit(1) + + console.print(f"[cyan]Starting explain enrich for {', '.join(symbols)}[/cyan]") + store = MarketStore() + results = enrich_symbols( + store, + symbols, + start_date=start, + end_date=end, + limit=max(10, limit), + skip_existing=not force, + ) + for result in results: + console.print( + f"[green]{result['symbol']}[/green] " + f"news={result['news_count']} queued={result['queued_count']} analyzed={result['analyzed']} " + f"skipped={result['skipped_existing_count']} deduped={result['deduped_count']} " + f"llm={result['llm_count']} local={result['local_count']}" + ) + + +@ingest_app.command("report") +def ingest_report( + tickers: Optional[str] = typer.Option( + None, + "--tickers", + "-t", + help="Optional comma-separated tickers to report", + ), + start: Optional[str] = typer.Option( + None, + "--start", + help="Optional start date for report window (YYYY-MM-DD)", + ), + end: Optional[str] = typer.Option( + None, + "--end", + help="Optional end date for report window (YYYY-MM-DD)", + ), + config_name: str = typer.Option( + "default", + "--config-name", + "-c", + help="Fallback config to read tickers from BOOTSTRAP.md", + ), + only_problematic: bool = typer.Option( + False, + "--only-problematic", + help="Only show tickers with incomplete coverage or no LLM-enriched news", + ), +): + """Show explain enrichment coverage and freshness per ticker.""" + symbols = _resolve_symbols(tickers, config_name) + store = MarketStore() + report_rows = store.get_enrich_report( + symbols=symbols or None, + start_date=start, + end_date=end, + ) + if only_problematic: + report_rows = _filter_problematic_report_rows(report_rows) + if not report_rows: + if only_problematic: + console.print("[green]No problematic enrich report rows found for the requested scope[/green]") + else: + console.print("[yellow]No enrich report rows found for the requested scope[/yellow]") + raise typer.Exit(0) + + model_info = get_explain_model_info() + model_label = model_info["label"] if llm_enrichment_enabled() else "disabled" + table = Table(title="Explain Enrichment Report") + table.add_column("Ticker", style="cyan") + table.add_column("Raw News", justify="right") + table.add_column("Analyzed", justify="right") + table.add_column("Coverage", justify="right") + table.add_column("LLM", justify="right") + table.add_column("Local", justify="right") + table.add_column("Latest Trade Date") + table.add_column("Latest Analysis") + table.caption = f"Explain LLM: {model_label}" + + for row in report_rows: + table.add_row( + row["symbol"], + str(row["raw_news_count"]), + str(row["analyzed_news_count"]), + f'{row["coverage_pct"]:.1f}%', + str(row["llm_count"]), + str(row["local_count"]), + str(row["latest_trade_date"] or "-"), + str(row["latest_analysis_at"] or "-"), + ) + console.print(table) + + @app.command() def backtest( start: Optional[str] = typer.Option( @@ -331,6 +730,16 @@ def backtest( # Run data updater run_data_updater(project_root) + auto_prepare_backtest_market_store( + config_name, + start_date=start, + end_date=end, + ) + auto_enrich_market_store( + config_name, + end_date=end, + force=False, + ) # Build command using backend.main cmd = [ @@ -514,6 +923,15 @@ def live( # Data update (if not mock mode) if not mock: run_data_updater(project_root) + auto_update_market_store( + config_name, + end_date=nyse_now.date().isoformat(), + ) + auto_enrich_market_store( + config_name, + end_date=nyse_now.date().isoformat(), + force=False, + ) else: console.print( "\n[dim]Mock mode enabled - skipping data update[/dim]\n", diff --git a/backend/core/state_sync.py b/backend/core/state_sync.py index 6220f07..43a1ad2 100644 --- a/backend/core/state_sync.py +++ b/backend/core/state_sync.py @@ -47,6 +47,10 @@ class StateSync: """Set current simulation date for backtest-compatible timestamps""" self._simulation_date = date + def clear_simulation_date(self): + """Disable backtest timestamp simulation and use wall-clock time.""" + self._simulation_date = None + def _get_timestamp_ms(self) -> int: """ Get timestamp in milliseconds. @@ -97,9 +101,21 @@ class StateSync: if not self._enabled: return - # Ensure timestamp exists (use simulation date if in backtest mode) + # Ensure timestamp exists. Prefer explicit millisecond timestamps so + # frontend displays local wall time correctly instead of date-only UTC. if "timestamp" not in event: - if self._simulation_date: + ts_ms = event.get("ts") + if ts_ms is not None: + try: + event["timestamp"] = datetime.fromtimestamp( + float(ts_ms) / 1000.0, + ).isoformat() + except (TypeError, ValueError, OSError): + if self._simulation_date: + event["timestamp"] = f"{self._simulation_date}" + else: + event["timestamp"] = datetime.now().isoformat() + elif self._simulation_date: event["timestamp"] = f"{self._simulation_date}" else: event["timestamp"] = datetime.now().isoformat() @@ -238,9 +254,12 @@ class StateSync: """Called at start of trading cycle""" self._state["current_date"] = date self._state["status"] = "running" - self.set_simulation_date( - date, - ) # Set for backtest-compatible timestamps + if self._state.get("server_mode") == "backtest": + self.set_simulation_date( + date, + ) # Set for backtest-compatible timestamps + else: + self.clear_simulation_date() await self.emit( { diff --git a/backend/data/historical_price_manager.py b/backend/data/historical_price_manager.py index d08c793..8e9e54f 100644 --- a/backend/data/historical_price_manager.py +++ b/backend/data/historical_price_manager.py @@ -7,6 +7,7 @@ from datetime import datetime from typing import Callable, Dict, List, Optional import pandas as pd +from backend.data.market_store import MarketStore from backend.data.provider_utils import normalize_symbol from backend.data.provider_router import get_provider_router @@ -26,6 +27,7 @@ class HistoricalPriceManager: self.close_prices = {} self.running = False self._router = get_provider_router() + self._market_store = MarketStore() def subscribe( self, @@ -58,21 +60,48 @@ class HistoricalPriceManager: logger.warning(f"Failed to load CSV for {symbol}: {e}") return None + def _load_from_market_db( + self, + symbol: str, + start_date: str, + end_date: str, + ) -> Optional[pd.DataFrame]: + """Load price data from the long-lived market research database.""" + try: + rows = self._market_store.get_ohlc(symbol, start_date, end_date) + if not rows: + return None + df = pd.DataFrame(rows) + if df.empty or "date" not in df.columns: + return None + df["Date"] = pd.to_datetime(df["date"]) + df.set_index("Date", inplace=True) + df.sort_index(inplace=True) + return df + except Exception as e: + logger.warning(f"Failed to load market DB data for {symbol}: {e}") + return None + def preload_data(self, start_date: str, end_date: str): - """Preload historical data from local CSV files.""" + """Preload historical data from market DB first, then local CSV.""" logger.info(f"Preloading data: {start_date} to {end_date}") for symbol in self.subscribed_symbols: if symbol in self._price_cache: continue - # Load from local CSV file directly + df = self._load_from_market_db(symbol, start_date, end_date) + if df is not None and not df.empty: + self._price_cache[symbol] = df + logger.info(f"Loaded {symbol} from market DB: {len(df)} records") + continue + df = self._load_from_csv(symbol) if df is not None and not df.empty: self._price_cache[symbol] = df logger.info(f"Loaded {symbol} from CSV: {len(df)} records") else: - logger.warning(f"No CSV data for {symbol}") + logger.warning(f"No market DB or CSV data for {symbol}") def set_date(self, date: str): """Set current trading date and update prices""" diff --git a/backend/data/market_ingest.py b/backend/data/market_ingest.py new file mode 100644 index 0000000..6c6bb26 --- /dev/null +++ b/backend/data/market_ingest.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +"""Ingest Polygon market data into the long-lived research warehouse.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Iterable + +from backend.data.market_store import MarketStore +from backend.data.news_alignment import align_news_for_symbol +from backend.data.polygon_client import ( + fetch_news, + fetch_ohlc, + fetch_ticker_details, +) +from backend.data.provider_utils import normalize_symbol + + +def _today_utc() -> str: + return datetime.now(timezone.utc).date().isoformat() + + +def _default_start(years: int = 2) -> str: + return (datetime.now(timezone.utc).date() - timedelta(days=years * 366)).isoformat() + + +def ingest_ticker_history( + symbol: str, + *, + start_date: str | None = None, + end_date: str | None = None, + store: MarketStore | None = None, +) -> dict: + """Fetch and persist Polygon OHLC + news for a ticker.""" + ticker = normalize_symbol(symbol) + start = start_date or _default_start() + end = end_date or _today_utc() + market_store = store or MarketStore() + + details = fetch_ticker_details(ticker) + market_store.upsert_ticker( + symbol=ticker, + name=details.get("name"), + sector=details.get("sic_description"), + is_active=bool(details.get("active", True)), + ) + + ohlc_rows = fetch_ohlc(ticker, start, end) + news_rows = fetch_news(ticker, start, end) + price_count = market_store.upsert_ohlc(ticker, ohlc_rows, source="polygon") + news_count = market_store.upsert_news(ticker, news_rows, source="polygon") + aligned_count = align_news_for_symbol(market_store, ticker) + market_store.update_fetch_watermark(symbol=ticker, price_date=end, news_date=end) + + return { + "symbol": ticker, + "start_date": start, + "end_date": end, + "prices": price_count, + "news": news_count, + "aligned": aligned_count, + } + + +def update_ticker_incremental( + symbol: str, + *, + end_date: str | None = None, + store: MarketStore | None = None, +) -> dict: + """Incrementally fetch OHLC + news since the last watermark.""" + ticker = normalize_symbol(symbol) + market_store = store or MarketStore() + watermarks = market_store.get_ticker_watermarks(ticker) + end = end_date or _today_utc() + start_prices = ( + (datetime.fromisoformat(watermarks["last_price_fetch"]) + timedelta(days=1)).date().isoformat() + if watermarks.get("last_price_fetch") + else _default_start() + ) + start_news = ( + (datetime.fromisoformat(watermarks["last_news_fetch"]) + timedelta(days=1)).date().isoformat() + if watermarks.get("last_news_fetch") + else _default_start() + ) + + details = fetch_ticker_details(ticker) + market_store.upsert_ticker( + symbol=ticker, + name=details.get("name"), + sector=details.get("sic_description"), + is_active=bool(details.get("active", True)), + ) + + ohlc_rows = [] if start_prices > end else fetch_ohlc(ticker, start_prices, end) + news_rows = [] if start_news > end else fetch_news(ticker, start_news, end) + price_count = market_store.upsert_ohlc(ticker, ohlc_rows, source="polygon") if ohlc_rows else 0 + news_count = market_store.upsert_news(ticker, news_rows, source="polygon") if news_rows else 0 + aligned_count = align_news_for_symbol(market_store, ticker) + market_store.update_fetch_watermark( + symbol=ticker, + price_date=end if ohlc_rows or watermarks.get("last_price_fetch") else None, + news_date=end if news_rows or watermarks.get("last_news_fetch") else None, + ) + + return { + "symbol": ticker, + "start_price_date": start_prices, + "start_news_date": start_news, + "end_date": end, + "prices": price_count, + "news": news_count, + "aligned": aligned_count, + } + + +def ingest_symbols( + symbols: Iterable[str], + *, + mode: str = "incremental", + start_date: str | None = None, + end_date: str | None = None, + store: MarketStore | None = None, +) -> list[dict]: + """Fetch Polygon data for a list of tickers.""" + market_store = store or MarketStore() + results = [] + for symbol in symbols: + ticker = normalize_symbol(symbol) + if not ticker: + continue + if mode == "full": + results.append( + ingest_ticker_history( + ticker, + start_date=start_date, + end_date=end_date, + store=market_store, + ) + ) + else: + results.append( + update_ticker_incremental( + ticker, + end_date=end_date, + store=market_store, + ) + ) + return results diff --git a/backend/data/market_store.py b/backend/data/market_store.py new file mode 100644 index 0000000..956ef31 --- /dev/null +++ b/backend/data/market_store.py @@ -0,0 +1,1074 @@ +# -*- coding: utf-8 -*- +"""Long-lived Polygon-backed market research storage.""" + +from __future__ import annotations + +import hashlib +import json +import os +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS tickers ( + symbol TEXT PRIMARY KEY, + name TEXT, + sector TEXT, + is_active INTEGER DEFAULT 1, + last_price_fetch TEXT, + last_news_fetch TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS ohlc ( + symbol TEXT NOT NULL, + date TEXT NOT NULL, + open REAL, + high REAL, + low REAL, + close REAL, + volume REAL, + vwap REAL, + transactions INTEGER, + source TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (symbol, date) +); +CREATE INDEX IF NOT EXISTS idx_ohlc_symbol_date ON ohlc(symbol, date DESC); + +CREATE TABLE IF NOT EXISTS news_raw ( + id TEXT PRIMARY KEY, + published_utc TEXT, + title TEXT, + summary TEXT, + publisher TEXT, + author TEXT, + article_url TEXT, + amp_url TEXT, + source TEXT, + related_json TEXT, + insights_json TEXT, + raw_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_news_raw_published ON news_raw(published_utc DESC); + +CREATE TABLE IF NOT EXISTS news_ticker ( + news_id TEXT NOT NULL, + symbol TEXT NOT NULL, + trade_date TEXT, + PRIMARY KEY (news_id, symbol) +); +CREATE INDEX IF NOT EXISTS idx_news_ticker_symbol_date ON news_ticker(symbol, trade_date DESC); + +CREATE TABLE IF NOT EXISTS news_analysis ( + news_id TEXT NOT NULL, + symbol TEXT NOT NULL, + trade_date TEXT, + relevance TEXT, + sentiment TEXT, + key_discussion TEXT, + summary TEXT, + reason_growth TEXT, + reason_decrease TEXT, + ret_t0 REAL, + ret_t1 REAL, + ret_t3 REAL, + ret_t5 REAL, + ret_t10 REAL, + analysis_source TEXT, + raw_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (news_id, symbol), + FOREIGN KEY (news_id) REFERENCES news_raw(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_news_analysis_symbol_date ON news_analysis(symbol, trade_date DESC); +CREATE INDEX IF NOT EXISTS idx_news_analysis_symbol_sentiment ON news_analysis(symbol, sentiment, trade_date DESC); +CREATE INDEX IF NOT EXISTS idx_news_analysis_symbol_relevance ON news_analysis(symbol, relevance, trade_date DESC); + +CREATE TABLE IF NOT EXISTS story_cache ( + symbol TEXT NOT NULL, + as_of_date TEXT NOT NULL, + content TEXT NOT NULL, + source TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (symbol, as_of_date) +); + +CREATE TABLE IF NOT EXISTS similar_day_cache ( + symbol TEXT NOT NULL, + target_date TEXT NOT NULL, + payload_json TEXT NOT NULL, + source TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (symbol, target_date) +); +""" + + +def get_market_db_path() -> Path: + """Resolve the long-lived market database path.""" + raw = os.getenv("MARKET_DB_PATH", "").strip() + if raw: + return Path(raw).expanduser() + return Path(__file__).resolve().parents[2] / "data" / "market_research.db" + + +def _json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + + +def _json_loads(value: str | None) -> Any: + if not value: + return None + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + +def _hash_news_id(symbol: str, article: dict[str, Any], fallback_index: int) -> str: + base = article.get("id") or article.get("article_url") or article.get("title") or f"{symbol}-{fallback_index}" + digest = hashlib.sha1(str(base).encode("utf-8")).hexdigest() + return article.get("id") or f"polygon:{symbol}:{digest}" + + +def _utc_timestamp() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +class MarketStore: + """SQLite-backed market research warehouse.""" + + def __init__(self, db_path: Path | None = None): + self.db_path = Path(db_path or get_market_db_path()) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._init_db() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + def _init_db(self): + with self._connect() as conn: + conn.executescript(SCHEMA) + + def upsert_ticker( + self, + *, + symbol: str, + name: str | None = None, + sector: str | None = None, + is_active: bool = True, + ) -> None: + timestamp = _utc_timestamp() + with self._connect() as conn: + conn.execute( + """ + INSERT INTO tickers + (symbol, name, sector, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol) DO UPDATE SET + name = COALESCE(excluded.name, tickers.name), + sector = COALESCE(excluded.sector, tickers.sector), + is_active = excluded.is_active, + updated_at = excluded.updated_at + """, + (symbol, name, sector, 1 if is_active else 0, timestamp, timestamp), + ) + + def update_fetch_watermark( + self, + *, + symbol: str, + price_date: str | None = None, + news_date: str | None = None, + ) -> None: + timestamp = _utc_timestamp() + with self._connect() as conn: + conn.execute( + """ + INSERT INTO tickers (symbol, created_at, updated_at, last_price_fetch, last_news_fetch) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(symbol) DO UPDATE SET + last_price_fetch = COALESCE(excluded.last_price_fetch, tickers.last_price_fetch), + last_news_fetch = COALESCE(excluded.last_news_fetch, tickers.last_news_fetch), + updated_at = excluded.updated_at + """, + (symbol, timestamp, timestamp, price_date, news_date), + ) + + def get_ticker_watermarks(self, symbol: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute( + """ + SELECT symbol, last_price_fetch, last_news_fetch + FROM tickers + WHERE symbol = ? + """, + (symbol,), + ).fetchone() + return dict(row) if row else { + "symbol": symbol, + "last_price_fetch": None, + "last_news_fetch": None, + } + + def upsert_ohlc(self, symbol: str, rows: Iterable[dict[str, Any]], *, source: str = "polygon") -> int: + timestamp = _utc_timestamp() + count = 0 + with self._connect() as conn: + for row in rows: + conn.execute( + """ + INSERT INTO ohlc + (symbol, date, open, high, low, close, volume, vwap, transactions, source, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol, date) DO UPDATE SET + open = excluded.open, + high = excluded.high, + low = excluded.low, + close = excluded.close, + volume = excluded.volume, + vwap = excluded.vwap, + transactions = excluded.transactions, + source = excluded.source, + updated_at = excluded.updated_at + """, + ( + symbol, + row.get("date"), + row.get("open"), + row.get("high"), + row.get("low"), + row.get("close"), + row.get("volume"), + row.get("vwap"), + row.get("transactions"), + source, + timestamp, + timestamp, + ), + ) + count += 1 + return count + + def upsert_news(self, symbol: str, articles: Iterable[dict[str, Any]], *, source: str = "polygon") -> int: + timestamp = _utc_timestamp() + count = 0 + with self._connect() as conn: + for index, article in enumerate(articles): + news_id = _hash_news_id(symbol, article, index) + tickers = article.get("tickers") or [symbol] + conn.execute( + """ + INSERT INTO news_raw + (id, published_utc, title, summary, publisher, author, article_url, amp_url, + source, related_json, insights_json, raw_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + published_utc = excluded.published_utc, + title = excluded.title, + summary = excluded.summary, + publisher = excluded.publisher, + author = excluded.author, + article_url = excluded.article_url, + amp_url = excluded.amp_url, + source = excluded.source, + related_json = excluded.related_json, + insights_json = excluded.insights_json, + raw_json = excluded.raw_json, + updated_at = excluded.updated_at + """, + ( + news_id, + article.get("published_utc"), + article.get("title"), + article.get("description") or article.get("summary"), + article.get("publisher"), + article.get("author"), + article.get("article_url"), + article.get("amp_url"), + source, + _json_dumps(tickers), + _json_dumps(article.get("insights")) if article.get("insights") else None, + _json_dumps(article), + timestamp, + timestamp, + ), + ) + for ticker in tickers: + conn.execute( + """ + INSERT OR IGNORE INTO news_ticker (news_id, symbol, trade_date) + VALUES (?, ?, NULL) + """, + (news_id, str(ticker).strip().upper()), + ) + count += 1 + return count + + def get_news_without_trade_date(self, symbol: str | None = None, *, limit: int = 5000) -> list[dict[str, Any]]: + sql = """ + SELECT nt.news_id, nt.symbol, nr.published_utc + FROM news_ticker nt + JOIN news_raw nr ON nr.id = nt.news_id + WHERE nt.trade_date IS NULL + """ + params: list[Any] = [] + if symbol: + sql += " AND nt.symbol = ?" + params.append(symbol) + sql += " ORDER BY nr.published_utc ASC LIMIT ?" + params.append(max(1, int(limit))) + with self._connect() as conn: + rows = conn.execute(sql, params).fetchall() + return [dict(row) for row in rows] + + def set_trade_dates(self, rows: Iterable[dict[str, str]]) -> int: + count = 0 + with self._connect() as conn: + for row in rows: + conn.execute( + """ + UPDATE news_ticker + SET trade_date = ? + WHERE news_id = ? AND symbol = ? + """, + (row["trade_date"], row["news_id"], row["symbol"]), + ) + count += 1 + return count + + def get_ohlc(self, symbol: str, start_date: str, end_date: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT symbol, date, open, high, low, close, volume, vwap, transactions, source + FROM ohlc + WHERE symbol = ? AND date >= ? AND date <= ? + ORDER BY date ASC + """, + (symbol, start_date, end_date), + ).fetchall() + return [dict(row) for row in rows] + + def upsert_news_analysis( + self, + symbol: str, + rows: Iterable[dict[str, Any]], + *, + analysis_source: str = "local", + ) -> int: + timestamp = _utc_timestamp() + normalized_symbol = str(symbol or "").strip().upper() + if not normalized_symbol: + return 0 + + count = 0 + with self._connect() as conn: + for row in rows: + news_id = str(row.get("news_id") or row.get("id") or "").strip() + if not news_id: + continue + conn.execute( + """ + INSERT INTO news_analysis + (news_id, symbol, trade_date, relevance, sentiment, key_discussion, summary, + reason_growth, reason_decrease, ret_t0, ret_t1, ret_t3, ret_t5, ret_t10, + analysis_source, raw_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(news_id, symbol) DO UPDATE SET + trade_date = excluded.trade_date, + relevance = excluded.relevance, + sentiment = excluded.sentiment, + key_discussion = excluded.key_discussion, + summary = excluded.summary, + reason_growth = excluded.reason_growth, + reason_decrease = excluded.reason_decrease, + ret_t0 = excluded.ret_t0, + ret_t1 = excluded.ret_t1, + ret_t3 = excluded.ret_t3, + ret_t5 = excluded.ret_t5, + ret_t10 = excluded.ret_t10, + analysis_source = excluded.analysis_source, + raw_json = excluded.raw_json, + updated_at = excluded.updated_at + """, + ( + news_id, + normalized_symbol, + row.get("trade_date"), + row.get("relevance"), + row.get("sentiment"), + row.get("key_discussion"), + row.get("summary"), + row.get("reason_growth"), + row.get("reason_decrease"), + row.get("ret_t0"), + row.get("ret_t1"), + row.get("ret_t3"), + row.get("ret_t5"), + row.get("ret_t10"), + row.get("analysis_source") or analysis_source, + _json_dumps(row.get("raw_json") or row), + timestamp, + timestamp, + ), + ) + count += 1 + return count + + def get_analyzed_news_ids( + self, + symbol: str, + *, + start_date: str | None = None, + end_date: str | None = None, + ) -> set[str]: + """Return already analyzed news ids for a symbol and optional date window.""" + sql = """ + SELECT na.news_id + FROM news_analysis na + LEFT JOIN news_ticker nt ON nt.news_id = na.news_id AND nt.symbol = na.symbol + LEFT JOIN news_raw nr ON nr.id = na.news_id + WHERE na.symbol = ? + """ + params: list[Any] = [symbol] + if start_date: + sql += " AND COALESCE(na.trade_date, nt.trade_date, substr(nr.published_utc, 1, 10)) >= ?" + params.append(start_date) + if end_date: + sql += " AND COALESCE(na.trade_date, nt.trade_date, substr(nr.published_utc, 1, 10)) <= ?" + params.append(end_date) + with self._connect() as conn: + rows = conn.execute(sql, params).fetchall() + return {str(row["news_id"]) for row in rows if row["news_id"]} + + def get_analyzed_news_sources( + self, + symbol: str, + *, + start_date: str | None = None, + end_date: str | None = None, + ) -> dict[str, str]: + """Return analyzed news ids mapped to analysis source.""" + sql = """ + SELECT na.news_id, na.analysis_source + FROM news_analysis na + LEFT JOIN news_ticker nt ON nt.news_id = na.news_id AND nt.symbol = na.symbol + LEFT JOIN news_raw nr ON nr.id = na.news_id + WHERE na.symbol = ? + """ + params: list[Any] = [symbol] + if start_date: + sql += " AND COALESCE(na.trade_date, nt.trade_date, substr(nr.published_utc, 1, 10)) >= ?" + params.append(start_date) + if end_date: + sql += " AND COALESCE(na.trade_date, nt.trade_date, substr(nr.published_utc, 1, 10)) <= ?" + params.append(end_date) + with self._connect() as conn: + rows = conn.execute(sql, params).fetchall() + return { + str(row["news_id"]): str(row["analysis_source"] or "").strip().lower() + for row in rows + if row["news_id"] + } + + @staticmethod + def _normalize_enriched_news_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + related = row["related_json"] if isinstance(row, sqlite3.Row) else row.get("related_json") + analysis_raw = row["analysis_raw_json"] if isinstance(row, sqlite3.Row) and "analysis_raw_json" in row.keys() else row.get("analysis_raw_json") + analysis_meta = _json_loads(analysis_raw) + return { + "id": row["id"], + "ticker": row["symbol"], + "date": row["published_utc"] or row["trade_date"], + "trade_date": row["trade_date"], + "source": row["publisher"] or row["raw_source"] or "polygon", + "title": row["title"], + "summary": row["analysis_summary"] or row["summary"], + "url": row["article_url"], + "related": _json_loads(related), + "category": row["category"] if isinstance(row, sqlite3.Row) and "category" in row.keys() else "", + "relevance": row["relevance"], + "sentiment": row["sentiment"], + "key_discussion": row["key_discussion"], + "reason_growth": row["reason_growth"], + "reason_decrease": row["reason_decrease"], + "ret_t0": row["ret_t0"], + "ret_t1": row["ret_t1"], + "ret_t3": row["ret_t3"], + "ret_t5": row["ret_t5"], + "ret_t10": row["ret_t10"], + "analysis_source": row["analysis_source"], + "analysis_model_label": analysis_meta.get("model_label") if isinstance(analysis_meta, dict) else None, + } + + def get_news_items_enriched( + self, + symbol: str, + *, + start_date: str | None = None, + end_date: str | None = None, + trade_date: str | None = None, + limit: int = 100, + ) -> list[dict[str, Any]]: + sql = """ + SELECT nr.id, + nt.symbol, + nr.published_utc, + nt.trade_date, + nr.publisher, + nr.source AS raw_source, + nr.title, + nr.summary, + nr.article_url, + nr.related_json, + na.relevance, + na.sentiment, + na.key_discussion, + na.summary AS analysis_summary, + na.reason_growth, + na.reason_decrease, + na.ret_t0, + na.ret_t1, + na.ret_t3, + na.ret_t5, + na.ret_t10, + na.analysis_source, + na.raw_json AS analysis_raw_json + FROM news_ticker nt + JOIN news_raw nr ON nr.id = nt.news_id + LEFT JOIN news_analysis na ON na.news_id = nt.news_id AND na.symbol = nt.symbol + WHERE nt.symbol = ? + """ + params: list[Any] = [symbol] + if trade_date: + sql += " AND COALESCE(nt.trade_date, na.trade_date, substr(nr.published_utc, 1, 10)) = ?" + params.append(trade_date) + else: + if start_date: + sql += " AND COALESCE(nt.trade_date, na.trade_date, substr(nr.published_utc, 1, 10)) >= ?" + params.append(start_date) + if end_date: + sql += " AND COALESCE(nt.trade_date, na.trade_date, substr(nr.published_utc, 1, 10)) <= ?" + params.append(end_date) + sql += " ORDER BY COALESCE(nr.published_utc, nt.trade_date) DESC LIMIT ?" + params.append(max(1, int(limit))) + with self._connect() as conn: + rows = conn.execute(sql, params).fetchall() + return [self._normalize_enriched_news_row(row) for row in rows] + + def get_news_items( + self, + symbol: str, + *, + start_date: str | None = None, + end_date: str | None = None, + limit: int = 100, + ) -> list[dict[str, Any]]: + sql = """ + SELECT nr.id, + nt.symbol, + nr.published_utc, + nt.trade_date, + nr.publisher, + nr.title, + nr.summary, + nr.article_url, + nr.related_json + FROM news_ticker nt + JOIN news_raw nr ON nr.id = nt.news_id + WHERE nt.symbol = ? + """ + params: list[Any] = [symbol] + if start_date: + sql += " AND COALESCE(nt.trade_date, substr(nr.published_utc, 1, 10)) >= ?" + params.append(start_date) + if end_date: + sql += " AND COALESCE(nt.trade_date, substr(nr.published_utc, 1, 10)) <= ?" + params.append(end_date) + sql += " ORDER BY COALESCE(nr.published_utc, nt.trade_date) DESC LIMIT ?" + params.append(max(1, int(limit))) + with self._connect() as conn: + rows = conn.execute(sql, params).fetchall() + return [ + { + "id": row["id"], + "ticker": row["symbol"], + "date": row["published_utc"] or row["trade_date"], + "trade_date": row["trade_date"], + "source": row["publisher"] or "polygon", + "title": row["title"], + "summary": row["summary"], + "url": row["article_url"], + "related": _json_loads(row["related_json"]), + "category": "", + } + for row in rows + ] + + def get_news_timeline( + self, + symbol: str, + *, + start_date: str | None = None, + end_date: str | None = None, + ) -> list[dict[str, Any]]: + sql = """ + SELECT COALESCE(nt.trade_date, substr(nr.published_utc, 1, 10)) AS date, + COUNT(*) AS count, + COUNT(DISTINCT nr.publisher) AS source_count, + MAX(nr.title) AS top_title + FROM news_ticker nt + JOIN news_raw nr ON nr.id = nt.news_id + WHERE nt.symbol = ? + """ + params: list[Any] = [symbol] + if start_date: + sql += " AND COALESCE(nt.trade_date, substr(nr.published_utc, 1, 10)) >= ?" + params.append(start_date) + if end_date: + sql += " AND COALESCE(nt.trade_date, substr(nr.published_utc, 1, 10)) <= ?" + params.append(end_date) + sql += """ + GROUP BY COALESCE(nt.trade_date, substr(nr.published_utc, 1, 10)) + ORDER BY date ASC + """ + with self._connect() as conn: + rows = conn.execute(sql, params).fetchall() + return [ + { + "date": row["date"], + "count": int(row["count"] or 0), + "source_count": int(row["source_count"] or 0), + "top_title": row["top_title"] or "", + } + for row in rows + if row["date"] + ] + + def get_news_timeline_enriched( + self, + symbol: str, + *, + start_date: str | None = None, + end_date: str | None = None, + ) -> list[dict[str, Any]]: + sql = """ + SELECT COALESCE(nt.trade_date, na.trade_date, substr(nr.published_utc, 1, 10)) AS date, + COUNT(*) AS count, + COUNT(DISTINCT nr.publisher) AS source_count, + MAX(nr.title) AS top_title, + SUM(CASE WHEN LOWER(COALESCE(na.sentiment, '')) = 'positive' THEN 1 ELSE 0 END) AS positive_count, + SUM(CASE WHEN LOWER(COALESCE(na.sentiment, '')) = 'negative' THEN 1 ELSE 0 END) AS negative_count, + SUM(CASE WHEN LOWER(COALESCE(na.sentiment, '')) IN ('neutral', '') OR na.sentiment IS NULL THEN 1 ELSE 0 END) AS neutral_count, + SUM(CASE WHEN LOWER(COALESCE(na.relevance, '')) IN ('high', 'relevant') THEN 1 ELSE 0 END) AS high_relevance_count + FROM news_ticker nt + JOIN news_raw nr ON nr.id = nt.news_id + LEFT JOIN news_analysis na ON na.news_id = nt.news_id AND na.symbol = nt.symbol + WHERE nt.symbol = ? + """ + params: list[Any] = [symbol] + if start_date: + sql += " AND COALESCE(nt.trade_date, na.trade_date, substr(nr.published_utc, 1, 10)) >= ?" + params.append(start_date) + if end_date: + sql += " AND COALESCE(nt.trade_date, na.trade_date, substr(nr.published_utc, 1, 10)) <= ?" + params.append(end_date) + sql += """ + GROUP BY COALESCE(nt.trade_date, na.trade_date, substr(nr.published_utc, 1, 10)) + ORDER BY date ASC + """ + with self._connect() as conn: + rows = conn.execute(sql, params).fetchall() + return [ + { + "date": row["date"], + "count": int(row["count"] or 0), + "source_count": int(row["source_count"] or 0), + "top_title": row["top_title"] or "", + "positive_count": int(row["positive_count"] or 0), + "negative_count": int(row["negative_count"] or 0), + "neutral_count": int(row["neutral_count"] or 0), + "high_relevance_count": int(row["high_relevance_count"] or 0), + } + for row in rows + if row["date"] + ] + + def get_news_by_ids(self, symbol: str, article_ids: Iterable[str]) -> list[dict[str, Any]]: + ids = [str(item).strip() for item in article_ids if str(item).strip()] + if not ids: + return [] + placeholders = ",".join("?" for _ in ids) + sql = f""" + SELECT nr.id, + nt.symbol, + nr.published_utc, + nt.trade_date, + nr.publisher, + nr.title, + nr.summary, + nr.article_url, + nr.related_json + FROM news_ticker nt + JOIN news_raw nr ON nr.id = nt.news_id + WHERE nt.symbol = ? AND nr.id IN ({placeholders}) + ORDER BY COALESCE(nr.published_utc, nt.trade_date) DESC + """ + with self._connect() as conn: + rows = conn.execute(sql, [symbol, *ids]).fetchall() + return [ + { + "id": row["id"], + "ticker": row["symbol"], + "date": row["published_utc"] or row["trade_date"], + "trade_date": row["trade_date"], + "source": row["publisher"] or "polygon", + "title": row["title"], + "summary": row["summary"], + "url": row["article_url"], + "related": _json_loads(row["related_json"]), + "category": "", + } + for row in rows + ] + + def get_news_by_ids_enriched( + self, + symbol: str, + article_ids: Iterable[str], + ) -> list[dict[str, Any]]: + ids = [str(item).strip() for item in article_ids if str(item).strip()] + if not ids: + return [] + placeholders = ",".join("?" for _ in ids) + sql = f""" + SELECT nr.id, + nt.symbol, + nr.published_utc, + nt.trade_date, + nr.publisher, + nr.source AS raw_source, + nr.title, + nr.summary, + nr.article_url, + nr.related_json, + na.relevance, + na.sentiment, + na.key_discussion, + na.summary AS analysis_summary, + na.reason_growth, + na.reason_decrease, + na.ret_t0, + na.ret_t1, + na.ret_t3, + na.ret_t5, + na.ret_t10, + na.analysis_source, + na.raw_json AS analysis_raw_json + FROM news_ticker nt + JOIN news_raw nr ON nr.id = nt.news_id + LEFT JOIN news_analysis na ON na.news_id = nt.news_id AND na.symbol = nt.symbol + WHERE nt.symbol = ? AND nr.id IN ({placeholders}) + ORDER BY COALESCE(nr.published_utc, nt.trade_date) DESC + """ + with self._connect() as conn: + rows = conn.execute(sql, [symbol, *ids]).fetchall() + return [self._normalize_enriched_news_row(row) for row in rows] + + def get_news_categories_enriched( + self, + symbol: str, + *, + start_date: str | None = None, + end_date: str | None = None, + limit: int = 200, + ) -> dict[str, dict[str, Any]]: + rows = self.get_news_items_enriched( + symbol, + start_date=start_date, + end_date=end_date, + limit=limit, + ) + categories: dict[str, dict[str, Any]] = {} + keyword_map = { + "market": [ + "market", "stock", "rally", "sell-off", "selloff", "trading", + "wall street", "s&p", "nasdaq", "dow", "index", "bull", "bear", + "correction", "volatility", + ], + "policy": [ + "regulation", "fed", "federal reserve", "tariff", "sanction", + "interest rate", "policy", "government", "congress", "sec", + "trade war", "ban", "legislation", "tax", + ], + "earnings": [ + "earnings", "revenue", "profit", "quarter", "eps", "guidance", + "forecast", "income", "sales", "beat", "miss", "outlook", + "financial results", + ], + "product_tech": [ + "product", "ai", "chip", "cloud", "launch", "patent", + "technology", "innovation", "release", "platform", "model", + "software", "hardware", "gpu", "autonomous", + ], + "competition": [ + "competitor", "rival", "market share", "overtake", "compete", + "competition", "vs", "versus", "battle", "challenge", + ], + "management": [ + "ceo", "executive", "resign", "layoff", "restructure", + "management", "leadership", "appoint", "hire", "board", + "chairman", + ], + } + for key in keyword_map: + categories[key] = { + "label": key, + "count": 0, + "article_ids": [], + "positive_ids": [], + "negative_ids": [], + "neutral_ids": [], + } + for row in rows: + text = " ".join( + str(row.get(field) or "") + for field in ( + "title", + "summary", + "key_discussion", + "reason_growth", + "reason_decrease", + ) + ).lower() + sentiment = str(row.get("sentiment") or "").strip().lower() + for category, keywords in keyword_map.items(): + if not any(keyword in text for keyword in keywords): + continue + bucket = categories[category] + bucket["count"] += 1 + bucket["article_ids"].append(row["id"]) + if sentiment == "positive": + bucket["positive_ids"].append(row["id"]) + elif sentiment == "negative": + bucket["negative_ids"].append(row["id"]) + else: + bucket["neutral_ids"].append(row["id"]) + return categories + + def get_story_cache( + self, + symbol: str, + *, + as_of_date: str, + ) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT symbol, as_of_date, content, source, created_at, updated_at + FROM story_cache + WHERE symbol = ? AND as_of_date = ? + """, + (symbol, as_of_date), + ).fetchone() + return dict(row) if row else None + + def upsert_story_cache( + self, + symbol: str, + *, + as_of_date: str, + content: str, + source: str = "local", + ) -> None: + timestamp = _utc_timestamp() + with self._connect() as conn: + conn.execute( + """ + INSERT INTO story_cache + (symbol, as_of_date, content, source, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol, as_of_date) DO UPDATE SET + content = excluded.content, + source = excluded.source, + updated_at = excluded.updated_at + """, + (symbol, as_of_date, content, source, timestamp, timestamp), + ) + + def delete_story_cache( + self, + symbol: str, + *, + as_of_date: str | None = None, + ) -> int: + with self._connect() as conn: + if as_of_date: + result = conn.execute( + """ + DELETE FROM story_cache + WHERE symbol = ? AND as_of_date = ? + """, + (symbol, as_of_date), + ) + else: + result = conn.execute( + """ + DELETE FROM story_cache + WHERE symbol = ? + """, + (symbol,), + ) + return int(result.rowcount or 0) + + def get_similar_day_cache( + self, + symbol: str, + *, + target_date: str, + ) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT symbol, target_date, payload_json, source, created_at, updated_at + FROM similar_day_cache + WHERE symbol = ? AND target_date = ? + """, + (symbol, target_date), + ).fetchone() + if not row: + return None + return { + "symbol": row["symbol"], + "target_date": row["target_date"], + "payload": _json_loads(row["payload_json"]), + "source": row["source"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + def upsert_similar_day_cache( + self, + symbol: str, + *, + target_date: str, + payload: dict[str, Any], + source: str = "local", + ) -> None: + timestamp = _utc_timestamp() + with self._connect() as conn: + conn.execute( + """ + INSERT INTO similar_day_cache + (symbol, target_date, payload_json, source, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol, target_date) DO UPDATE SET + payload_json = excluded.payload_json, + source = excluded.source, + updated_at = excluded.updated_at + """, + (symbol, target_date, _json_dumps(payload), source, timestamp, timestamp), + ) + + def delete_similar_day_cache( + self, + symbol: str, + *, + target_date: str | None = None, + ) -> int: + with self._connect() as conn: + if target_date: + result = conn.execute( + """ + DELETE FROM similar_day_cache + WHERE symbol = ? AND target_date = ? + """, + (symbol, target_date), + ) + else: + result = conn.execute( + """ + DELETE FROM similar_day_cache + WHERE symbol = ? + """, + (symbol,), + ) + return int(result.rowcount or 0) + + def get_enrich_report( + self, + symbols: list[str] | None = None, + *, + start_date: str | None = None, + end_date: str | None = None, + ) -> list[dict[str, Any]]: + """Summarize explain enrichment coverage and freshness per ticker.""" + sql = """ + SELECT nt.symbol AS symbol, + COUNT(DISTINCT nt.news_id) AS raw_news_count, + COUNT(DISTINCT na.news_id) AS analyzed_news_count, + SUM(CASE WHEN LOWER(COALESCE(na.analysis_source, '')) = 'llm' THEN 1 ELSE 0 END) AS llm_count, + SUM(CASE WHEN LOWER(COALESCE(na.analysis_source, '')) = 'local' THEN 1 ELSE 0 END) AS local_count, + MAX(na.updated_at) AS latest_analysis_at, + MAX(nt.trade_date) AS latest_trade_date + FROM news_ticker nt + LEFT JOIN news_analysis na ON na.news_id = nt.news_id AND na.symbol = nt.symbol + LEFT JOIN news_raw nr ON nr.id = nt.news_id + WHERE 1 = 1 + """ + params: list[Any] = [] + if symbols: + normalized = [str(symbol).strip().upper() for symbol in symbols if str(symbol).strip()] + if normalized: + placeholders = ",".join("?" for _ in normalized) + sql += f" AND nt.symbol IN ({placeholders})" + params.extend(normalized) + if start_date: + sql += " AND COALESCE(nt.trade_date, na.trade_date, substr(nr.published_utc, 1, 10)) >= ?" + params.append(start_date) + if end_date: + sql += " AND COALESCE(nt.trade_date, na.trade_date, substr(nr.published_utc, 1, 10)) <= ?" + params.append(end_date) + sql += " GROUP BY nt.symbol ORDER BY nt.symbol ASC" + + with self._connect() as conn: + rows = conn.execute(sql, params).fetchall() + + report: list[dict[str, Any]] = [] + for row in rows: + raw_news_count = int(row["raw_news_count"] or 0) + analyzed_news_count = int(row["analyzed_news_count"] or 0) + coverage_pct = ( + round((analyzed_news_count / raw_news_count) * 100, 1) + if raw_news_count > 0 + else 0.0 + ) + report.append( + { + "symbol": row["symbol"], + "raw_news_count": raw_news_count, + "analyzed_news_count": analyzed_news_count, + "coverage_pct": coverage_pct, + "llm_count": int(row["llm_count"] or 0), + "local_count": int(row["local_count"] or 0), + "latest_analysis_at": row["latest_analysis_at"], + "latest_trade_date": row["latest_trade_date"], + } + ) + return report diff --git a/backend/data/news_alignment.py b/backend/data/news_alignment.py new file mode 100644 index 0000000..695ee7e --- /dev/null +++ b/backend/data/news_alignment.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""Align persisted news to the nearest NYSE trading date.""" + +from __future__ import annotations + +from datetime import time + +import pandas as pd +import pandas_market_calendars as mcal + +from backend.data.market_store import MarketStore + + +NYSE_CALENDAR = mcal.get_calendar("NYSE") + + +def _next_trading_day(date_str: str) -> str: + start = pd.Timestamp(date_str).tz_localize(None) + sessions = NYSE_CALENDAR.valid_days( + start_date=(start - pd.Timedelta(days=1)).strftime("%Y-%m-%d"), + end_date=(start + pd.Timedelta(days=10)).strftime("%Y-%m-%d"), + ) + future = [ + pd.Timestamp(day).tz_localize(None).strftime("%Y-%m-%d") + for day in sessions + if pd.Timestamp(day).tz_localize(None) >= start + ] + return future[0] if future else date_str + + +def resolve_trade_date(published_utc: str | None) -> str | None: + """Map a published timestamp to an NYSE trade date.""" + if not published_utc: + return None + timestamp = pd.to_datetime(published_utc, utc=True, errors="coerce") + if pd.isna(timestamp): + return None + nyse_time = timestamp.tz_convert("America/New_York") + candidate = nyse_time.date().isoformat() + valid_days = NYSE_CALENDAR.valid_days(start_date=candidate, end_date=candidate) + if len(valid_days) == 0: + return _next_trading_day(candidate) + if nyse_time.time() >= time(16, 0): + return _next_trading_day((nyse_time + pd.Timedelta(days=1)).date().isoformat()) + return candidate + + +def align_news_for_symbol(store: MarketStore, symbol: str, *, limit: int = 5000) -> int: + """Fill missing trade_date values for one ticker.""" + pending = store.get_news_without_trade_date(symbol, limit=limit) + updates = [] + for row in pending: + trade_date = resolve_trade_date(row.get("published_utc")) + if trade_date: + updates.append( + { + "news_id": row["news_id"], + "symbol": row["symbol"], + "trade_date": trade_date, + } + ) + if not updates: + return 0 + return store.set_trade_dates(updates) diff --git a/backend/data/polygon_client.py b/backend/data/polygon_client.py new file mode 100644 index 0000000..879b246 --- /dev/null +++ b/backend/data/polygon_client.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""Polygon client used for long-lived market research ingestion.""" + +from __future__ import annotations + +import os +import time +from datetime import datetime, timezone +from typing import Any, Optional + +import requests + + +BASE = "https://api.polygon.io" + + +def _headers() -> dict[str, str]: + api_key = os.getenv("POLYGON_API_KEY", "").strip() + if not api_key: + raise ValueError("Missing required API key: POLYGON_API_KEY") + return {"Authorization": f"Bearer {api_key}"} + + +def http_get( + url: str, + params: Optional[dict[str, Any]] = None, + *, + max_retries: int = 8, + backoff: float = 2.0, +) -> requests.Response: + """HTTP GET with exponential backoff and 429 handling.""" + for attempt in range(max_retries): + try: + response = requests.get( + url, + params=params or {}, + headers=_headers(), + timeout=30, + ) + except requests.RequestException: + time.sleep((backoff**attempt) + 0.5) + if attempt == max_retries - 1: + raise + continue + + if response.status_code == 429: + retry_after = response.headers.get("Retry-After") + wait = ( + float(retry_after) + if retry_after and retry_after.isdigit() + else min((backoff**attempt) + 1.0, 60.0) + ) + time.sleep(wait) + if attempt == max_retries - 1: + response.raise_for_status() + continue + + if 500 <= response.status_code < 600: + time.sleep(min((backoff**attempt) + 1.0, 60.0)) + if attempt == max_retries - 1: + response.raise_for_status() + continue + + response.raise_for_status() + return response + raise RuntimeError("Unreachable") + + +def fetch_ticker_details(symbol: str) -> dict[str, Any]: + """Fetch company metadata from Polygon.""" + response = http_get(f"{BASE}/v3/reference/tickers/{symbol}") + return response.json().get("results", {}) or {} + + +def fetch_ohlc(symbol: str, start_date: str, end_date: str) -> list[dict[str, Any]]: + """Fetch daily OHLC data from Polygon.""" + response = http_get( + f"{BASE}/v2/aggs/ticker/{symbol}/range/1/day/{start_date}/{end_date}", + params={"adjusted": "true", "sort": "asc", "limit": 50000}, + ) + results = response.json().get("results") or [] + rows: list[dict[str, Any]] = [] + for item in results: + rows.append( + { + "date": datetime.fromtimestamp( + int(item["t"]) / 1000, + tz=timezone.utc, + ).date().isoformat(), + "open": item.get("o"), + "high": item.get("h"), + "low": item.get("l"), + "close": item.get("c"), + "volume": item.get("v"), + "vwap": item.get("vw"), + "transactions": item.get("n"), + } + ) + return rows + + +def fetch_news( + symbol: str, + start_date: str, + end_date: str, + *, + per_page: int = 50, + page_sleep: float = 1.2, + max_pages: Optional[int] = None, +) -> list[dict[str, Any]]: + """Fetch all Polygon news for a ticker, with pagination.""" + url = f"{BASE}/v2/reference/news" + params = { + "ticker": symbol, + "published_utc.gte": start_date, + "published_utc.lte": end_date, + "limit": per_page, + "order": "asc", + } + next_url: Optional[str] = None + pages = 0 + all_articles: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + + while True: + response = http_get(next_url or url, params=None if next_url else params) + data = response.json() + results = data.get("results") or [] + if not results: + break + + for item in results: + article_id = item.get("id") + if article_id and article_id in seen_ids: + continue + all_articles.append( + { + "id": article_id, + "publisher": (item.get("publisher") or {}).get("name"), + "title": item.get("title"), + "author": item.get("author"), + "published_utc": item.get("published_utc"), + "amp_url": item.get("amp_url"), + "article_url": item.get("article_url"), + "tickers": item.get("tickers"), + "description": item.get("description"), + "insights": item.get("insights"), + } + ) + if article_id: + seen_ids.add(article_id) + + next_url = data.get("next_url") + pages += 1 + if max_pages is not None and pages >= max_pages: + break + if not next_url: + break + time.sleep(page_sleep) + + return all_articles diff --git a/backend/enrich/__init__.py b/backend/enrich/__init__.py new file mode 100644 index 0000000..80448b4 --- /dev/null +++ b/backend/enrich/__init__.py @@ -0,0 +1,2 @@ +"""News enrichment utilities for explain-oriented market research.""" + diff --git a/backend/enrich/llm_enricher.py b/backend/enrich/llm_enricher.py new file mode 100644 index 0000000..f84039b --- /dev/null +++ b/backend/enrich/llm_enricher.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +"""Optional AgentScope-backed news enrichment with safe local fallback.""" + +from __future__ import annotations + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +from pydantic import BaseModel, Field + +from backend.config.env_config import canonicalize_model_provider, get_env_bool, get_env_str +from backend.llm.models import create_model + + +class EnrichedNewsItem(BaseModel): + """Structured output schema for one enriched article.""" + + id: str = Field(description="The source article id") + relevance: str = Field(description="One of high, medium, low") + sentiment: str = Field(description="One of positive, negative, neutral") + key_discussion: str = Field(description="Concise core discussion") + summary: str = Field(description="Concise factual summary") + reason_growth: str = Field(description="Growth-oriented reason if present") + reason_decrease: str = Field(description="Downside-oriented reason if present") + + +class EnrichedNewsBatch(BaseModel): + """Structured output schema for a batch of enriched articles.""" + + items: list[EnrichedNewsItem] + + +class RangeAnalysisPayload(BaseModel): + """Structured output schema for range explanation text.""" + + summary: str = Field(description="Concise Chinese range summary for the selected window") + trend_analysis: str = Field(description="Concise Chinese trend explanation for the selected window") + bullish_factors: list[str] = Field(description="Top bullish factors in Chinese") + bearish_factors: list[str] = Field(description="Top bearish factors in Chinese") + + +def get_explain_model_info() -> dict[str, str]: + """Resolve provider/model used by explain enrichment.""" + provider = canonicalize_model_provider( + get_env_str("EXPLAIN_ENRICH_MODEL_PROVIDER") + or get_env_str("MODEL_PROVIDER", "OPENAI"), + ) + model_name = get_env_str("EXPLAIN_ENRICH_MODEL_NAME") or get_env_str( + "MODEL_NAME", + "gpt-4o-mini", + ) + return { + "provider": provider, + "model_name": model_name, + "label": f"{provider}:{model_name}", + } + + +def _normalize_enrichment_payload(payload: Any) -> dict[str, Any] | None: + if isinstance(payload, BaseModel): + payload = payload.model_dump() + if not isinstance(payload, dict): + return None + return { + "relevance": str(payload.get("relevance") or "").strip().lower() or None, + "sentiment": str(payload.get("sentiment") or "").strip().lower() or None, + "key_discussion": str(payload.get("key_discussion") or "").strip() or None, + "summary": str(payload.get("summary") or "").strip() or None, + "reason_growth": str(payload.get("reason_growth") or "").strip() or None, + "reason_decrease": str(payload.get("reason_decrease") or "").strip() or None, + "raw_json": payload, + } + + +def _run_async(coro: Any) -> Any: + """Run an async AgentScope model call from sync code, even inside a running loop.""" + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(asyncio.run, coro) + return future.result() + + +def _get_explain_model(): + """Create an AgentScope model for explain enrichment.""" + model_info = get_explain_model_info() + return create_model( + model_name=model_info["model_name"], + provider=model_info["provider"], + stream=False, + generate_kwargs={"temperature": 0.1}, + ) + + +def llm_enrichment_enabled() -> bool: + """Return whether AgentScope-backed LLM enrichment should be attempted.""" + if not get_env_bool("EXPLAIN_ENRICH_USE_LLM", False): + return False + provider = get_explain_model_info()["provider"] + provider_key_map = { + "OPENAI": "OPENAI_API_KEY", + "ANTHROPIC": "ANTHROPIC_API_KEY", + "DASHSCOPE": "DASHSCOPE_API_KEY", + "ALIBABA": "DASHSCOPE_API_KEY", + "GEMINI": "GOOGLE_API_KEY", + "GOOGLE": "GOOGLE_API_KEY", + "DEEPSEEK": "DEEPSEEK_API_KEY", + "GROQ": "GROQ_API_KEY", + "OPENROUTER": "OPENROUTER_API_KEY", + } + env_key = provider_key_map.get(provider) + return bool(get_env_str(env_key)) if env_key else provider == "OLLAMA" + + +def llm_range_analysis_enabled() -> bool: + """Return whether LLM range analysis should be attempted.""" + raw_value = get_env_str("EXPLAIN_RANGE_USE_LLM") + if raw_value is not None and str(raw_value).strip() != "": + return get_env_bool("EXPLAIN_RANGE_USE_LLM", False) and llm_enrichment_enabled() + return llm_enrichment_enabled() + + +def analyze_news_row_with_llm(row: dict[str, Any]) -> dict[str, Any] | None: + """Generate explain-oriented structured analysis for one article.""" + if not llm_enrichment_enabled(): + return None + + model = _get_explain_model() + title = str(row.get("title") or "").strip() + summary = str(row.get("summary") or "").strip() + messages = [ + { + "role": "system", + "content": ( + "You produce concise structured financial news analysis. " + "Use only the requested fields and keep content factual." + ), + }, + { + "role": "user", + "content": ( + "Analyze this stock-news article for an explain UI.\n" + "Rules:\n" + "- relevance must be one of: high, medium, low\n" + "- sentiment must be one of: positive, negative, neutral\n" + "- keep each text field concise and factual\n" + f"- article id: {str(row.get('id') or '').strip()}\n" + f"Title: {title}\n" + f"Summary: {summary}\n" + ), + }, + ] + try: + response = _run_async(model(messages=messages, structured_model=EnrichedNewsItem)) + except Exception: + return None + + payload = _normalize_enrichment_payload(getattr(response, "metadata", None)) + if payload: + payload.setdefault("raw_json", {}) + payload["raw_json"]["model_provider"] = get_explain_model_info()["provider"] + payload["raw_json"]["model_name"] = get_explain_model_info()["model_name"] + payload["raw_json"]["model_label"] = get_explain_model_info()["label"] + return payload + + +def analyze_news_rows_with_llm(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + """Generate structured analysis for multiple articles in one request.""" + if not llm_enrichment_enabled() or not rows: + return {} + + payload_rows = [ + { + "id": str(row.get("id") or "").strip(), + "title": str(row.get("title") or "").strip(), + "summary": str(row.get("summary") or "").strip(), + } + for row in rows + if str(row.get("id") or "").strip() + ] + if not payload_rows: + return {} + + model = _get_explain_model() + messages = [ + { + "role": "system", + "content": ( + "You produce concise structured financial news analysis in JSON. " + "Preserve ids exactly and do not invent extra items." + ), + }, + { + "role": "user", + "content": ( + "Analyze these stock-news articles for an explain UI.\n" + "For each item return: id, relevance, sentiment, key_discussion, summary, " + "reason_growth, reason_decrease.\n" + "Rules:\n" + "- relevance must be one of: high, medium, low\n" + "- sentiment must be one of: positive, negative, neutral\n" + "- keep all text concise and factual\n" + f"Articles: {payload_rows}" + ), + }, + ] + try: + response = _run_async( + model(messages=messages, structured_model=EnrichedNewsBatch), + ) + except Exception: + return {} + + metadata = getattr(response, "metadata", None) + if isinstance(metadata, BaseModel): + metadata = metadata.model_dump() + items = metadata.get("items") if isinstance(metadata, dict) else None + if not isinstance(items, list): + return {} + + results: dict[str, dict[str, Any]] = {} + for item in items: + normalized = _normalize_enrichment_payload(item) + news_id = str((item.model_dump() if isinstance(item, BaseModel) else item).get("id") or "").strip() if isinstance(item, (dict, BaseModel)) else "" + if normalized and news_id: + normalized.setdefault("raw_json", {}) + normalized["raw_json"]["model_provider"] = get_explain_model_info()["provider"] + normalized["raw_json"]["model_name"] = get_explain_model_info()["model_name"] + normalized["raw_json"]["model_label"] = get_explain_model_info()["label"] + results[news_id] = normalized + return results + + +def analyze_range_with_llm(payload: dict[str, Any]) -> dict[str, Any] | None: + """Generate explain-oriented range summary and factor refinement.""" + if not llm_range_analysis_enabled(): + return None + + model = _get_explain_model() + messages = [ + { + "role": "system", + "content": ( + "You write concise Chinese stock range analysis for an explain UI. " + "Use only the supplied facts. Keep the tone factual and analyst-like." + ), + }, + { + "role": "user", + "content": ( + "请基于给定事实生成区间分析。\n" + "输出字段:summary, trend_analysis, bullish_factors, bearish_factors。\n" + "要求:\n" + "- 全部使用简体中文\n" + "- summary 1到2句,概括区间走势、新闻密度和主导主题\n" + "- trend_analysis 1句,解释区间内部阶段变化\n" + "- bullish_factors 和 bearish_factors 各返回最多3条短句\n" + "- 不要编造未提供的信息\n" + f"事实数据: {payload}" + ), + }, + ] + try: + response = _run_async( + model(messages=messages, structured_model=RangeAnalysisPayload), + ) + except Exception: + return None + + metadata = getattr(response, "metadata", None) + if isinstance(metadata, BaseModel): + metadata = metadata.model_dump() + if not isinstance(metadata, dict): + return None + + return { + "summary": str(metadata.get("summary") or "").strip() or None, + "trend_analysis": str(metadata.get("trend_analysis") or "").strip() or None, + "bullish_factors": [ + str(item).strip() + for item in list(metadata.get("bullish_factors") or []) + if str(item).strip() + ][:3], + "bearish_factors": [ + str(item).strip() + for item in list(metadata.get("bearish_factors") or []) + if str(item).strip() + ][:3], + "model_provider": get_explain_model_info()["provider"], + "model_name": get_explain_model_info()["model_name"], + "model_label": get_explain_model_info()["label"], + } diff --git a/backend/enrich/news_enricher.py b/backend/enrich/news_enricher.py new file mode 100644 index 0000000..d8e4eb3 --- /dev/null +++ b/backend/enrich/news_enricher.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- +"""Lightweight news enrichment for explain-oriented market analysis.""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from backend.config.env_config import get_env_int +from backend.enrich.llm_enricher import ( + analyze_news_row_with_llm, + analyze_news_rows_with_llm, + llm_enrichment_enabled, +) +from backend.data.market_store import MarketStore + + +POSITIVE_KEYWORDS = ( + "beat", "surge", "gain", "growth", "record", "upgrade", "strong", + "partnership", "approved", "launch", "expands", "profit", +) +NEGATIVE_KEYWORDS = ( + "miss", "drop", "fall", "cut", "downgrade", "weak", "warning", + "delay", "lawsuit", "probe", "tariff", "decline", "layoff", +) +HIGH_RELEVANCE_KEYWORDS = ( + "earnings", "guidance", "profit", "revenue", "ceo", "fda", "tariff", + "regulation", "acquisition", "buyback", "forecast", "launch", +) + + +def _dedupe_key(row: dict[str, Any]) -> str: + trade_date = str(row.get("trade_date") or row.get("date") or "")[:10] + title = str(row.get("title") or "").strip().lower() + summary = str(row.get("summary") or "").strip().lower()[:160] + raw = f"{trade_date}::{title}::{summary}" + return hashlib.sha1(raw.encode("utf-8")).hexdigest() + + +def _chunk_rows(rows: list[dict[str, Any]], size: int) -> list[list[dict[str, Any]]]: + chunk_size = max(1, int(size)) + return [rows[index:index + chunk_size] for index in range(0, len(rows), chunk_size)] + + +def classify_news_row(row: dict[str, Any]) -> dict[str, Any]: + """Return a lightweight explain-oriented analysis for one article.""" + llm_result = analyze_news_row_with_llm(row) + if isinstance(llm_result, dict): + merged = dict(llm_result) + merged.setdefault("summary", str(row.get("summary") or row.get("title") or "")[:280]) + merged.setdefault("raw_json", row) + merged["analysis_source"] = "llm" + return merged + + title = str(row.get("title") or "").strip() + summary = str(row.get("summary") or "").strip() + text = f"{title} {summary}".lower() + + positive_hits = [keyword for keyword in POSITIVE_KEYWORDS if keyword in text] + negative_hits = [keyword for keyword in NEGATIVE_KEYWORDS if keyword in text] + relevance_hits = [keyword for keyword in HIGH_RELEVANCE_KEYWORDS if keyword in text] + + if len(positive_hits) > len(negative_hits): + sentiment = "positive" + elif len(negative_hits) > len(positive_hits): + sentiment = "negative" + else: + sentiment = "neutral" + + relevance = "high" if relevance_hits else "medium" if title else "low" + summary_text = summary or title + key_discussion = "" + if relevance_hits: + key_discussion = f"核心主题集中在 {', '.join(relevance_hits[:3])}" + elif summary_text: + key_discussion = summary_text[:160] + + reason_growth = "" + reason_decrease = "" + if sentiment == "positive": + reason_growth = summary_text[:200] + elif sentiment == "negative": + reason_decrease = summary_text[:200] + + return { + "relevance": relevance, + "sentiment": sentiment, + "key_discussion": key_discussion, + "summary": summary_text[:280], + "reason_growth": reason_growth, + "reason_decrease": reason_decrease, + "analysis_source": "local", + "raw_json": row, + } + + +def attach_forward_returns( + *, + news_rows: list[dict[str, Any]], + ohlc_rows: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Attach forward-return labels to each analyzed row.""" + if not ohlc_rows: + return news_rows + + closes_by_date = { + str(row.get("date")): float(row.get("close")) + for row in ohlc_rows + if row.get("date") is not None and row.get("close") is not None + } + ordered_dates = [str(row.get("date")) for row in ohlc_rows if row.get("date") is not None] + date_index = {date: idx for idx, date in enumerate(ordered_dates)} + + horizons = { + "ret_t0": 0, + "ret_t1": 1, + "ret_t3": 3, + "ret_t5": 5, + "ret_t10": 10, + } + + enriched: list[dict[str, Any]] = [] + for row in news_rows: + trade_date = str(row.get("trade_date") or "")[:10] + base_close = closes_by_date.get(trade_date) + if not trade_date or base_close in (None, 0): + enriched.append(row) + continue + + next_row = dict(row) + base_index = date_index.get(trade_date) + if base_index is None: + enriched.append(next_row) + continue + + for field, offset in horizons.items(): + target_index = base_index + offset + if target_index >= len(ordered_dates): + next_row[field] = None + continue + target_close = closes_by_date.get(ordered_dates[target_index]) + next_row[field] = ( + (float(target_close) - float(base_close)) / float(base_close) + if target_close not in (None, 0) + else None + ) + enriched.append(next_row) + return enriched + + +def build_analysis_rows( + *, + symbol: str, + news_rows: list[dict[str, Any]], + ohlc_rows: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], dict[str, int]]: + """Transform raw news rows into market_store news_analysis payloads plus stats.""" + llm_results: dict[str, dict[str, Any]] = {} + if llm_enrichment_enabled(): + batch_size = get_env_int("EXPLAIN_ENRICH_BATCH_SIZE", 8) + for chunk in _chunk_rows(news_rows, batch_size): + llm_results.update(analyze_news_rows_with_llm(chunk)) + + staged_rows: list[dict[str, Any]] = [] + seen_dedupe_keys: set[str] = set() + deduped_count = 0 + llm_count = 0 + local_count = 0 + for row in news_rows: + news_id = str(row.get("id") or "").strip() + if not news_id: + continue + dedupe_key = _dedupe_key(row) + if dedupe_key in seen_dedupe_keys: + deduped_count += 1 + continue + seen_dedupe_keys.add(dedupe_key) + batch_result = llm_results.get(news_id) + if isinstance(batch_result, dict): + analysis = dict(batch_result) + analysis.setdefault("summary", str(row.get("summary") or row.get("title") or "")[:280]) + analysis.setdefault("raw_json", row) + analysis["analysis_source"] = "llm" + llm_count += 1 + else: + analysis = classify_news_row(row) + if analysis.get("analysis_source") == "llm": + llm_count += 1 + else: + local_count += 1 + staged_rows.append( + { + "news_id": news_id, + "trade_date": str(row.get("trade_date") or "")[:10] or None, + **analysis, + } + ) + return ( + attach_forward_returns(news_rows=staged_rows, ohlc_rows=ohlc_rows), + { + "deduped_count": deduped_count, + "llm_count": llm_count, + "local_count": local_count, + }, + ) + + +def enrich_news_for_symbol( + store: MarketStore, + symbol: str, + *, + start_date: str | None = None, + end_date: str | None = None, + limit: int = 200, + analysis_source: str = "local", + skip_existing: bool = True, + only_reanalyze_local: bool = False, +) -> dict[str, Any]: + """Read raw market news, compute explain fields, and persist them.""" + normalized_symbol = str(symbol or "").strip().upper() + if not normalized_symbol: + return {"symbol": "", "analyzed": 0} + + news_rows = store.get_news_items( + normalized_symbol, + start_date=start_date, + end_date=end_date, + limit=limit, + ) + total_news_count = len(news_rows) + skipped_existing_count = 0 + analyzed_sources: dict[str, str] = {} + skipped_missing_analysis_count = 0 + skipped_non_local_count = 0 + if news_rows and only_reanalyze_local: + analyzed_sources = store.get_analyzed_news_sources( + normalized_symbol, + start_date=start_date, + end_date=end_date, + ) + skipped_missing_analysis_count = sum( + 1 + for row in news_rows + if str(row.get("id") or "").strip() not in analyzed_sources + ) + skipped_non_local_count = sum( + 1 + for row in news_rows + if str(row.get("id") or "").strip() in analyzed_sources + and analyzed_sources.get(str(row.get("id") or "").strip()) != "local" + ) + skipped_existing_count = sum( + 1 + for row in news_rows + if str(row.get("id") or "").strip() not in analyzed_sources + or analyzed_sources.get(str(row.get("id") or "").strip()) != "local" + ) + news_rows = [ + row for row in news_rows + if analyzed_sources.get(str(row.get("id") or "").strip()) == "local" + ] + elif skip_existing and news_rows: + analyzed_ids = store.get_analyzed_news_ids( + normalized_symbol, + start_date=start_date, + end_date=end_date, + ) + skipped_existing_count = sum( + 1 + for row in news_rows + if str(row.get("id") or "").strip() in analyzed_ids + ) + news_rows = [ + row for row in news_rows + if str(row.get("id") or "").strip() not in analyzed_ids + ] + ohlc_start = start_date or (news_rows[-1]["trade_date"] if news_rows and news_rows[-1].get("trade_date") else None) + ohlc_end = end_date or (news_rows[0]["trade_date"] if news_rows and news_rows[0].get("trade_date") else None) + ohlc_rows = ( + store.get_ohlc(normalized_symbol, ohlc_start, ohlc_end) + if ohlc_start and ohlc_end + else [] + ) + analysis_rows, stats = build_analysis_rows( + symbol=normalized_symbol, + news_rows=news_rows, + ohlc_rows=ohlc_rows, + ) + analyzed = store.upsert_news_analysis( + normalized_symbol, + analysis_rows, + analysis_source=analysis_source, + ) + upgraded_dates = sorted( + { + str(row.get("trade_date") or "")[:10] + for row in analysis_rows + if str(row.get("analysis_source") or "").strip().lower() == "llm" + and str(row.get("trade_date") or "").strip() + } + ) + remaining_local_titles = [ + str(row.get("title") or row.get("news_id") or "").strip() + for row in news_rows + for analyzed_row in analysis_rows + if str(analyzed_row.get("news_id") or "").strip() == str(row.get("id") or "").strip() + and str(analyzed_row.get("analysis_source") or "").strip().lower() == "local" + ][:5] + return { + "symbol": normalized_symbol, + "analyzed": analyzed, + "news_count": total_news_count, + "queued_count": len(news_rows), + "skipped_existing_count": skipped_existing_count, + "deduped_count": stats["deduped_count"], + "llm_count": stats["llm_count"], + "local_count": stats["local_count"], + "only_reanalyze_local": only_reanalyze_local, + "upgraded_local_to_llm_count": ( + stats["llm_count"] + if only_reanalyze_local + else 0 + ), + "execution_summary": { + "upgraded_dates": upgraded_dates[:5], + "remaining_local_titles": remaining_local_titles, + "skipped_missing_analysis_count": skipped_missing_analysis_count, + "skipped_non_local_count": skipped_non_local_count, + }, + } + + +def enrich_symbols( + store: MarketStore, + symbols: list[str], + *, + start_date: str | None = None, + end_date: str | None = None, + limit: int = 200, + analysis_source: str = "local", + skip_existing: bool = True, + only_reanalyze_local: bool = False, +) -> list[dict[str, Any]]: + """Batch enrich multiple symbols for explain-oriented news analysis.""" + results = [] + for symbol in symbols: + normalized_symbol = str(symbol or "").strip().upper() + if not normalized_symbol: + continue + results.append( + enrich_news_for_symbol( + store, + normalized_symbol, + start_date=start_date, + end_date=end_date, + limit=limit, + analysis_source=analysis_source, + skip_existing=skip_existing, + only_reanalyze_local=only_reanalyze_local, + ) + ) + return results diff --git a/backend/explain/__init__.py b/backend/explain/__init__.py new file mode 100644 index 0000000..53c7d8b --- /dev/null +++ b/backend/explain/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Explain-oriented services for stock narratives and news research.""" diff --git a/backend/explain/category_engine.py b/backend/explain/category_engine.py new file mode 100644 index 0000000..0e6b086 --- /dev/null +++ b/backend/explain/category_engine.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""Rule-based news categorization for explain UI.""" + +from __future__ import annotations + +from typing import Any, Dict, Iterable + + +CATEGORY_KEYWORDS = { + "market": [ + "market", "stock", "rally", "sell-off", "selloff", "trading", + "wall street", "s&p", "nasdaq", "dow", "index", "bull", "bear", + "correction", "volatility", + ], + "policy": [ + "regulation", "fed", "federal reserve", "tariff", "sanction", + "interest rate", "policy", "government", "congress", "sec", + "trade war", "ban", "legislation", "tax", + ], + "earnings": [ + "earnings", "revenue", "profit", "quarter", "eps", "guidance", + "forecast", "income", "sales", "beat", "miss", "outlook", + "financial results", + ], + "product_tech": [ + "product", "ai", "chip", "cloud", "launch", "patent", + "technology", "innovation", "release", "platform", "model", + "software", "hardware", "gpu", "autonomous", + ], + "competition": [ + "competitor", "rival", "market share", "overtake", "compete", + "competition", "vs", "versus", "battle", "challenge", + ], + "management": [ + "ceo", "executive", "resign", "layoff", "restructure", + "management", "leadership", "appoint", "hire", "board", + "chairman", + ], +} + + +def categorize_news_rows(rows: Iterable[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + """Bucket news rows by keyword categories.""" + categories: Dict[str, Dict[str, Any]] = { + key: { + "label": key, + "count": 0, + "article_ids": [], + } + for key in CATEGORY_KEYWORDS + } + + for row in rows: + text = " ".join( + [ + str(row.get("title") or ""), + str(row.get("summary") or ""), + str(row.get("related") or ""), + str(row.get("category") or ""), + ] + ).lower() + article_id = row.get("id") + for category, keywords in CATEGORY_KEYWORDS.items(): + if any(keyword in text for keyword in keywords): + categories[category]["count"] += 1 + if article_id: + categories[category]["article_ids"].append(article_id) + + return categories diff --git a/backend/explain/range_explainer.py b/backend/explain/range_explainer.py new file mode 100644 index 0000000..3908c33 --- /dev/null +++ b/backend/explain/range_explainer.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +"""Local range explanation built from price and persisted news.""" + +from __future__ import annotations + +from typing import Any, Dict + +from backend.enrich.llm_enricher import analyze_range_with_llm +from backend.explain.category_engine import categorize_news_rows +from backend.tools.data_tools import get_prices + + +def _rank_event_score(row: Dict[str, Any]) -> float: + relevance = str(row.get("relevance") or "").strip().lower() + relevance_score = {"high": 3.0, "relevant": 3.0, "medium": 2.0, "low": 1.0}.get( + relevance, + 0.5, + ) + impact_score = abs(float(row.get("ret_t0") or 0.0)) * 100 + return relevance_score + impact_score + + +def summarize_bullish_factors( + news_rows: list[Dict[str, Any]], + *, + limit: int = 5, +) -> list[str]: + factors = [] + for row in news_rows: + if str(row.get("sentiment") or "").strip().lower() != "positive": + continue + candidate = row.get("reason_growth") or row.get("key_discussion") or row.get("summary") or row.get("title") + if candidate: + factors.append(str(candidate).strip()) + seen = set() + output = [] + for factor in factors: + if factor in seen: + continue + seen.add(factor) + output.append(factor[:200]) + if len(output) >= limit: + break + return output + + +def summarize_bearish_factors( + news_rows: list[Dict[str, Any]], + *, + limit: int = 5, +) -> list[str]: + factors = [] + for row in news_rows: + if str(row.get("sentiment") or "").strip().lower() != "negative": + continue + candidate = row.get("reason_decrease") or row.get("key_discussion") or row.get("summary") or row.get("title") + if candidate: + factors.append(str(candidate).strip()) + seen = set() + output = [] + for factor in factors: + if factor in seen: + continue + seen.add(factor) + output.append(factor[:200]) + if len(output) >= limit: + break + return output + + +def build_trend_analysis(prices: list[Any]) -> str: + if len(prices) < 2: + return "区间样本较短,暂不具备足够趋势信息。" + if len(prices) < 3: + open_price = float(prices[0].open) + close_price = float(prices[-1].close) + change = ((close_price - open_price) / open_price) * 100 if open_price else 0.0 + return f"短区间内价格变动 {change:+.2f}%,趋势信息有限。" + + mid = len(prices) // 2 + first_open = float(prices[0].open) + first_close = float(prices[mid].close) + second_open = float(prices[mid].open) + second_close = float(prices[-1].close) + first_half = ((first_close - first_open) / first_open) * 100 if first_open else 0.0 + second_half = ((second_close - second_open) / second_open) * 100 if second_open else 0.0 + return ( + f"前半段{'上涨' if first_half >= 0 else '下跌'} {abs(first_half):.2f}%," + f"后半段{'上涨' if second_half >= 0 else '下跌'} {abs(second_half):.2f}%," + "说明价格驱动在区间内部出现了阶段性切换。" + ) + + +def build_range_explanation( + *, + ticker: str, + start_date: str, + end_date: str, + news_rows: list[Dict[str, Any]], +) -> Dict[str, Any]: + """Explain a price range with local price and news heuristics.""" + prices = get_prices(ticker, start_date, end_date) + if not prices: + return { + "symbol": ticker, + "start_date": start_date, + "end_date": end_date, + "error": "No OHLC data for this range", + } + + open_price = float(prices[0].open) + close_price = float(prices[-1].close) + high_price = max(float(price.high) for price in prices) + low_price = min(float(price.low) for price in prices) + total_volume = sum(int(price.volume) for price in prices) + price_change_pct = ( + ((close_price - open_price) / open_price) * 100 if open_price else 0.0 + ) + + categories = categorize_news_rows(news_rows) + news_count = len(news_rows) + dominant_categories = sorted( + ( + {"category": key, "count": value["count"]} + for key, value in categories.items() + if value["count"] > 0 + ), + key=lambda item: item["count"], + reverse=True, + ) + + direction = "上涨" if price_change_pct > 0 else "下跌" if price_change_pct < 0 else "横盘" + category_text = ( + f"主要主题集中在 {', '.join(item['category'] for item in dominant_categories[:3])}。" + if dominant_categories + else "区间内未识别出明显的主题聚类。" + ) + summary = ( + f"{ticker} 在 {start_date} 至 {end_date} 区间内{direction} {abs(price_change_pct):.2f}%," + f"区间覆盖 {len(prices)} 个交易日,关联新闻 {news_count} 条。{category_text}" + ) + + bullish_factors = summarize_bullish_factors(news_rows) + bearish_factors = summarize_bearish_factors(news_rows) + trend_analysis = build_trend_analysis(prices) + llm_source = "local" + + range_payload = { + "ticker": ticker, + "start_date": start_date, + "end_date": end_date, + "price_change_pct": round(price_change_pct, 2), + "trading_days": len(prices), + "news_count": news_count, + "dominant_categories": dominant_categories[:5], + "bullish_factors": bullish_factors[:3], + "bearish_factors": bearish_factors[:3], + "trend_analysis": trend_analysis, + "top_news": [ + { + "date": row.get("trade_date") or str(row.get("date") or "")[:10], + "title": row.get("title") or "", + "summary": row.get("summary") or "", + "sentiment": row.get("sentiment") or "", + "relevance": row.get("relevance") or "", + "ret_t0": row.get("ret_t0"), + } + for row in sorted(news_rows, key=_rank_event_score, reverse=True)[:5] + ], + } + llm_analysis = analyze_range_with_llm(range_payload) + if isinstance(llm_analysis, dict): + summary = llm_analysis.get("summary") or summary + trend_analysis = llm_analysis.get("trend_analysis") or trend_analysis + bullish_factors = llm_analysis.get("bullish_factors") or bullish_factors + bearish_factors = llm_analysis.get("bearish_factors") or bearish_factors + llm_source = "llm" + + key_events = [ + { + "date": row.get("trade_date") or str(row.get("date") or "")[:10], + "title": row.get("title") or "Untitled news", + "summary": row.get("summary") or "", + "category": row.get("category") or "", + "id": row.get("id"), + "sentiment": row.get("sentiment"), + "ret_t0": row.get("ret_t0"), + } + for row in sorted(news_rows, key=_rank_event_score, reverse=True)[:8] + ] + + return { + "symbol": ticker, + "start_date": start_date, + "end_date": end_date, + "price_change_pct": round(price_change_pct, 2), + "open_price": open_price, + "close_price": close_price, + "high_price": high_price, + "low_price": low_price, + "total_volume": total_volume, + "trading_days": len(prices), + "news_count": news_count, + "dominant_categories": dominant_categories[:5], + "analysis": { + "summary": summary, + "key_events": key_events, + "bullish_factors": bullish_factors, + "bearish_factors": bearish_factors, + "trend_analysis": trend_analysis, + "analysis_source": llm_source, + "analysis_model_label": llm_analysis.get("model_label") if isinstance(llm_analysis, dict) else None, + }, + } diff --git a/backend/explain/similarity_service.py b/backend/explain/similarity_service.py new file mode 100644 index 0000000..d9147fb --- /dev/null +++ b/backend/explain/similarity_service.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +"""Same-ticker historical similar day search for explain view.""" + +from __future__ import annotations + +from math import sqrt +from typing import Any + +from backend.data.market_store import MarketStore + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return default + return parsed + + +def build_daily_feature_rows( + *, + symbol: str, + ohlc_rows: list[dict[str, Any]], + news_rows: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Aggregate price/news context into daily feature rows.""" + price_by_date = {str(row.get("date")): row for row in ohlc_rows if row.get("date")} + ordered_dates = [str(row.get("date")) for row in ohlc_rows if row.get("date")] + + news_by_date: dict[str, list[dict[str, Any]]] = {} + for row in news_rows: + trade_date = str(row.get("trade_date") or "")[:10] or str(row.get("date") or "")[:10] + if not trade_date: + continue + news_by_date.setdefault(trade_date, []).append(row) + + features: list[dict[str, Any]] = [] + previous_close: float | None = None + for idx, date in enumerate(ordered_dates): + price_row = price_by_date[date] + close_price = _safe_float(price_row.get("close")) + open_price = _safe_float(price_row.get("open"), close_price) + day_news = news_by_date.get(date, []) + positive_count = sum(1 for item in day_news if str(item.get("sentiment") or "").lower() == "positive") + negative_count = sum(1 for item in day_news if str(item.get("sentiment") or "").lower() == "negative") + high_relevance_count = sum( + 1 for item in day_news if str(item.get("relevance") or "").lower() in {"high", "relevant"} + ) + ret_1d = ( + ((close_price - previous_close) / previous_close) + if previous_close not in (None, 0) + else 0.0 + ) + intraday_ret = ((close_price - open_price) / open_price) if open_price else 0.0 + sentiment_score = ( + (positive_count - negative_count) / max(len(day_news), 1) + if day_news + else 0.0 + ) + future_t1 = None + future_t3 = None + if idx + 1 < len(ordered_dates) and close_price: + next_close = _safe_float(price_by_date[ordered_dates[idx + 1]].get("close")) + future_t1 = ((next_close - close_price) / close_price) if next_close else None + if idx + 3 < len(ordered_dates) and close_price: + next_close = _safe_float(price_by_date[ordered_dates[idx + 3]].get("close")) + future_t3 = ((next_close - close_price) / close_price) if next_close else None + + features.append( + { + "date": date, + "symbol": symbol, + "n_articles": len(day_news), + "positive_count": positive_count, + "negative_count": negative_count, + "high_relevance_count": high_relevance_count, + "sentiment_score": sentiment_score, + "ret_1d": ret_1d, + "intraday_ret": intraday_ret, + "close": close_price, + "ret_t1_after": future_t1, + "ret_t3_after": future_t3, + "news": [ + { + "title": row.get("title") or "", + "sentiment": row.get("sentiment") or "neutral", + } + for row in day_news[:3] + ], + } + ) + previous_close = close_price + return features + + +def compute_similarity_scores( + target_vector: list[float], + candidate_vectors: list[tuple[str, list[float], dict[str, Any]]], +) -> list[dict[str, Any]]: + """Return sorted similarity matches based on normalized Euclidean distance.""" + if not candidate_vectors: + return [] + dimensions = len(target_vector) + ranges = [] + for dimension in range(dimensions): + values = [vector[1][dimension] for vector in candidate_vectors] + [target_vector[dimension]] + min_value = min(values) + max_value = max(values) + ranges.append(max(max_value - min_value, 1e-9)) + + scored = [] + for date, vector, payload in candidate_vectors: + distance = sqrt( + sum( + ((target_vector[i] - vector[i]) / ranges[i]) ** 2 + for i in range(dimensions) + ) + ) + similarity = 1.0 / (1.0 + distance) + scored.append( + { + "date": date, + "score": round(similarity, 4), + **payload, + } + ) + return sorted(scored, key=lambda item: item["score"], reverse=True) + + +def find_similar_days( + store: MarketStore, + *, + symbol: str, + target_date: str, + top_k: int = 10, +) -> dict[str, Any]: + """Find same-ticker historical days most similar to a target day.""" + cached = store.get_similar_day_cache(symbol, target_date=target_date) + if cached and cached.get("payload"): + return cached["payload"] + + ohlc_rows = store.get_ohlc(symbol, "1900-01-01", target_date) + news_rows = store.get_news_items_enriched(symbol, end_date=target_date, limit=500) + daily_rows = build_daily_feature_rows(symbol=symbol, ohlc_rows=ohlc_rows, news_rows=news_rows) + feature_map = {row["date"]: row for row in daily_rows} + target_row = feature_map.get(target_date) + if not target_row: + return { + "symbol": symbol, + "target_date": target_date, + "items": [], + "error": "No feature row for target date", + } + + vector_keys = [ + "sentiment_score", + "n_articles", + "positive_count", + "negative_count", + "high_relevance_count", + "ret_1d", + "intraday_ret", + ] + target_vector = [_safe_float(target_row.get(key)) for key in vector_keys] + candidates = [] + for row in daily_rows: + date = row["date"] + if date == target_date: + continue + payload = { + "n_articles": row["n_articles"], + "sentiment_score": round(row["sentiment_score"], 4), + "ret_1d": round(row["ret_1d"] * 100, 2), + "intraday_ret": round(row["intraday_ret"] * 100, 2), + "ret_t1_after": round(row["ret_t1_after"] * 100, 2) if row["ret_t1_after"] is not None else None, + "ret_t3_after": round(row["ret_t3_after"] * 100, 2) if row["ret_t3_after"] is not None else None, + "top_reasons": [item["title"] for item in row["news"][:2] if item.get("title")], + "news": row["news"], + } + candidates.append( + ( + date, + [_safe_float(row.get(key)) for key in vector_keys], + payload, + ) + ) + + items = compute_similarity_scores(target_vector, candidates)[: max(1, min(int(top_k), 20))] + result = { + "symbol": symbol, + "target_date": target_date, + "target_features": { + "sentiment_score": round(target_row["sentiment_score"], 4), + "n_articles": target_row["n_articles"], + "ret_1d": round(target_row["ret_1d"] * 100, 2), + "intraday_ret": round(target_row["intraday_ret"] * 100, 2), + "high_relevance_count": target_row["high_relevance_count"], + }, + "items": items, + } + store.upsert_similar_day_cache(symbol, target_date=target_date, payload=result, source="local") + return result diff --git a/backend/explain/story_service.py b/backend/explain/story_service.py new file mode 100644 index 0000000..dadeb3b --- /dev/null +++ b/backend/explain/story_service.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +"""Stock story generation for explain view.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +from backend.data.market_store import MarketStore + + +def build_stock_story( + *, + symbol: str, + as_of_date: str, + price_rows: list[dict[str, Any]], + news_rows: list[dict[str, Any]], +) -> str: + """Build a compact markdown story from enriched news and recent price action.""" + lines = [f"## {symbol} Story", f"As of `{as_of_date}`"] + if not price_rows: + lines.append("") + lines.append("No OHLC data available for story generation.") + return "\n".join(lines) + + open_price = float(price_rows[0].get("open") or price_rows[0].get("close") or 0.0) + close_price = float(price_rows[-1].get("close") or 0.0) + price_change = ((close_price - open_price) / open_price) * 100 if open_price else 0.0 + high_price = max(float(row.get("high") or row.get("close") or 0.0) for row in price_rows) + low_price = min(float(row.get("low") or row.get("close") or 0.0) for row in price_rows) + + lines.append("") + lines.append( + f"The stock moved {'up' if price_change >= 0 else 'down'} " + f"{abs(price_change):.2f}% over the recent window, trading between " + f"${low_price:.2f} and ${high_price:.2f}." + ) + + positive = [row for row in news_rows if str(row.get("sentiment") or "").lower() == "positive"] + negative = [row for row in news_rows if str(row.get("sentiment") or "").lower() == "negative"] + lines.append("") + lines.append( + f"Recent coverage included {len(news_rows)} relevant articles " + f"({len(positive)} positive / {len(negative)} negative)." + ) + + if news_rows: + lines.append("") + lines.append("### Key Moments") + ranked_rows = sorted( + news_rows, + key=lambda row: ( + 0 if str(row.get("relevance") or "").lower() in {"high", "relevant"} else 1, + -abs(float(row.get("ret_t0") or 0.0)), + ), + ) + for row in ranked_rows[:5]: + trade_date = row.get("trade_date") or str(row.get("date") or "")[:10] + title = row.get("title") or "Untitled" + key_discussion = row.get("key_discussion") or row.get("summary") or "" + sentiment = str(row.get("sentiment") or "neutral").lower() + lines.append( + f"- `{trade_date}` [{sentiment}] {title}: {str(key_discussion).strip()[:220]}" + ) + + if positive: + lines.append("") + lines.append("### Bullish Threads") + for row in positive[:3]: + reason = row.get("reason_growth") or row.get("key_discussion") or row.get("summary") or row.get("title") + lines.append(f"- {str(reason).strip()[:220]}") + + if negative: + lines.append("") + lines.append("### Bearish Threads") + for row in negative[:3]: + reason = row.get("reason_decrease") or row.get("key_discussion") or row.get("summary") or row.get("title") + lines.append(f"- {str(reason).strip()[:220]}") + + return "\n".join(lines) + + +def get_or_create_stock_story( + store: MarketStore, + *, + symbol: str, + as_of_date: str, +) -> dict[str, Any]: + """Return cached story or build a new one from recent market context.""" + cached = store.get_story_cache(symbol, as_of_date=as_of_date) + if cached: + return { + "symbol": symbol, + "as_of_date": as_of_date, + "story": cached.get("content") or "", + "source": cached.get("source") or "cache", + } + + start_date = None + if len(as_of_date) >= 10: + target_date = datetime.strptime(as_of_date[:10], "%Y-%m-%d").date() + start_date = (target_date - timedelta(days=29)).isoformat() + + price_rows = ( + store.get_ohlc(symbol, start_date, as_of_date) + if start_date + else [] + ) + news_rows = store.get_news_items_enriched( + symbol, + start_date=start_date, + end_date=as_of_date, + limit=40, + ) + story = build_stock_story( + symbol=symbol, + as_of_date=as_of_date, + price_rows=price_rows, + news_rows=news_rows, + ) + store.upsert_story_cache(symbol, as_of_date=as_of_date, content=story, source="local") + return { + "symbol": symbol, + "as_of_date": as_of_date, + "story": story, + "source": "local", + } diff --git a/backend/services/gateway.py b/backend/services/gateway.py index df703f0..a6f0299 100644 --- a/backend/services/gateway.py +++ b/backend/services/gateway.py @@ -17,6 +17,12 @@ from backend.config.bootstrap_config import ( update_bootstrap_values_for_run, ) from backend.data.provider_utils import normalize_symbol +from backend.data.market_ingest import ingest_symbols +from backend.enrich.llm_enricher import llm_enrichment_enabled +from backend.enrich.news_enricher import enrich_news_for_symbol +from backend.explain.range_explainer import build_range_explanation +from backend.explain.similarity_service import find_similar_days +from backend.explain.story_service import get_or_create_stock_story from backend.utils.msg_adapter import FrontendAdapter from backend.utils.terminal_dashboard import get_dashboard from backend.core.pipeline import TradingPipeline @@ -25,6 +31,7 @@ from backend.services.market import MarketService from backend.services.storage import StorageService from backend.data.provider_router import get_provider_router from backend.tools.data_tools import get_prices +from backend.tools.data_tools import get_company_news logger = logging.getLogger(__name__) @@ -65,6 +72,7 @@ class Gateway: self._backtest_end_date: Optional[str] = None self._dashboard = get_dashboard() self._market_status_task: Optional[asyncio.Task] = None + self._watchlist_ingest_task: Optional[asyncio.Task] = None # Session tracking for live returns self._session_start_portfolio_value: Optional[float] = None @@ -182,6 +190,17 @@ class Gateway: def state(self) -> Dict[str, Any]: return self.state_sync.state + @staticmethod + def _news_rows_need_enrichment(rows: List[Dict[str, Any]]) -> bool: + if not rows: + return True + return all( + not row.get("sentiment") + and not row.get("relevance") + and not row.get("key_discussion") + for row in rows + ) + async def handle_client(self, websocket: ServerConnection): """Handle WebSocket client connection""" async with self.lock: @@ -250,6 +269,22 @@ class Gateway: await self._handle_get_stock_history(websocket, data) elif msg_type == "get_stock_explain_events": await self._handle_get_stock_explain_events(websocket, data) + elif msg_type == "get_stock_news": + await self._handle_get_stock_news(websocket, data) + elif msg_type == "get_stock_news_for_date": + await self._handle_get_stock_news_for_date(websocket, data) + elif msg_type == "get_stock_news_timeline": + await self._handle_get_stock_news_timeline(websocket, data) + elif msg_type == "get_stock_news_categories": + await self._handle_get_stock_news_categories(websocket, data) + elif msg_type == "get_stock_range_explain": + await self._handle_get_stock_range_explain(websocket, data) + elif msg_type == "get_stock_story": + await self._handle_get_stock_story(websocket, data) + elif msg_type == "get_stock_similar_days": + await self._handle_get_stock_similar_days(websocket, data) + elif msg_type == "run_stock_enrich": + await self._handle_run_stock_enrich(websocket, data) except websockets.ConnectionClosed: pass @@ -298,20 +333,38 @@ class Gateway: ) prices = await asyncio.to_thread( - get_prices, + self.storage.market_store.get_ohlc, ticker, start_date, end_date, ) - usage_snapshot = self._provider_router.get_usage_snapshot() - source = usage_snapshot.get("last_success", {}).get("prices") + source = "polygon" + if not prices: + prices = await asyncio.to_thread( + get_prices, + ticker, + start_date, + end_date, + ) + usage_snapshot = self._provider_router.get_usage_snapshot() + source = usage_snapshot.get("last_success", {}).get("prices") + if prices: + await asyncio.to_thread( + self.storage.market_store.upsert_ohlc, + ticker, + [price.model_dump() for price in prices], + source=source or "provider", + ) await websocket.send( json.dumps( { "type": "stock_history_loaded", "ticker": ticker, - "prices": [price.model_dump() for price in prices][-120:], + "prices": [ + price if isinstance(price, dict) else price.model_dump() + for price in prices + ][-120:], "source": source, "start_date": start_date, "end_date": end_date, @@ -342,6 +395,636 @@ class Gateway: ), ) + async def _handle_get_stock_news( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ): + ticker = normalize_symbol(data.get("ticker", "")) + if not ticker: + await websocket.send( + json.dumps( + { + "type": "stock_news_loaded", + "ticker": "", + "news": [], + "source": None, + "error": "invalid ticker", + }, + ensure_ascii=False, + ), + ) + return + + lookback_days = data.get("lookback_days", 30) + limit = data.get("limit", 12) + try: + lookback_days = max(7, min(int(lookback_days), 180)) + except (TypeError, ValueError): + lookback_days = 30 + try: + limit = max(1, min(int(limit), 30)) + except (TypeError, ValueError): + limit = 12 + + end_date = self.state_sync.state.get("current_date") + if not end_date: + end_date = datetime.now().strftime("%Y-%m-%d") + + try: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + except ValueError: + end_dt = datetime.now() + end_date = end_dt.strftime("%Y-%m-%d") + + start_date = (end_dt - timedelta(days=lookback_days)).strftime( + "%Y-%m-%d", + ) + + news_rows = await asyncio.to_thread( + self.storage.market_store.get_news_items_enriched, + ticker, + start_date=start_date, + end_date=end_date, + limit=limit, + ) + source = "polygon" + if self._news_rows_need_enrichment(news_rows): + news = await asyncio.to_thread( + get_company_news, + ticker, + end_date, + start_date, + limit, + ) + if news: + usage_snapshot = self._provider_router.get_usage_snapshot() + source = usage_snapshot.get("last_success", {}).get("company_news") + await asyncio.to_thread( + self.storage.market_store.upsert_news, + ticker, + [item.model_dump() for item in news], + source=source or "provider", + ) + await asyncio.to_thread( + enrich_news_for_symbol, + self.storage.market_store, + ticker, + start_date=start_date, + end_date=end_date, + limit=max(limit, 50), + ) + news_rows = await asyncio.to_thread( + self.storage.market_store.get_news_items_enriched, + ticker, + start_date=start_date, + end_date=end_date, + limit=limit, + ) + source = source or "market_store" + + await websocket.send( + json.dumps( + { + "type": "stock_news_loaded", + "ticker": ticker, + "news": news_rows[-limit:], + "source": source, + "start_date": start_date, + "end_date": end_date, + }, + ensure_ascii=False, + default=str, + ), + ) + + async def _handle_get_stock_news_for_date( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ): + ticker = normalize_symbol(data.get("ticker", "")) + trade_date = str(data.get("date") or "").strip() + if not ticker or not trade_date: + await websocket.send( + json.dumps( + { + "type": "stock_news_for_date_loaded", + "ticker": ticker, + "date": trade_date, + "news": [], + "error": "ticker and date are required", + }, + ensure_ascii=False, + ), + ) + return + + limit = data.get("limit", 20) + try: + limit = max(1, min(int(limit), 50)) + except (TypeError, ValueError): + limit = 20 + + news_rows = await asyncio.to_thread( + self.storage.market_store.get_news_items_enriched, + ticker, + trade_date=trade_date, + limit=limit, + ) + if self._news_rows_need_enrichment(news_rows): + await asyncio.to_thread( + enrich_news_for_symbol, + self.storage.market_store, + ticker, + start_date=trade_date, + end_date=trade_date, + limit=limit, + ) + news_rows = await asyncio.to_thread( + self.storage.market_store.get_news_items_enriched, + ticker, + trade_date=trade_date, + limit=limit, + ) + + await websocket.send( + json.dumps( + { + "type": "stock_news_for_date_loaded", + "ticker": ticker, + "date": trade_date, + "news": news_rows, + "source": "market_store", + }, + ensure_ascii=False, + default=str, + ), + ) + + async def _handle_get_stock_news_timeline( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ): + ticker = normalize_symbol(data.get("ticker", "")) + if not ticker: + await websocket.send( + json.dumps( + { + "type": "stock_news_timeline_loaded", + "ticker": "", + "timeline": [], + "error": "invalid ticker", + }, + ensure_ascii=False, + ), + ) + return + + lookback_days = data.get("lookback_days", 90) + try: + lookback_days = max(7, min(int(lookback_days), 365)) + except (TypeError, ValueError): + lookback_days = 90 + + end_date = self.state_sync.state.get("current_date") + if not end_date: + end_date = datetime.now().strftime("%Y-%m-%d") + + try: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + except ValueError: + end_dt = datetime.now() + end_date = end_dt.strftime("%Y-%m-%d") + + start_date = (end_dt - timedelta(days=lookback_days)).strftime( + "%Y-%m-%d", + ) + timeline = await asyncio.to_thread( + self.storage.market_store.get_news_timeline_enriched, + ticker, + start_date=start_date, + end_date=end_date, + ) + if not timeline: + await asyncio.to_thread( + enrich_news_for_symbol, + self.storage.market_store, + ticker, + start_date=start_date, + end_date=end_date, + limit=200, + ) + timeline = await asyncio.to_thread( + self.storage.market_store.get_news_timeline_enriched, + ticker, + start_date=start_date, + end_date=end_date, + ) + await websocket.send( + json.dumps( + { + "type": "stock_news_timeline_loaded", + "ticker": ticker, + "timeline": timeline, + "start_date": start_date, + "end_date": end_date, + }, + ensure_ascii=False, + default=str, + ), + ) + + async def _handle_get_stock_news_categories( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ): + ticker = normalize_symbol(data.get("ticker", "")) + if not ticker: + await websocket.send( + json.dumps( + { + "type": "stock_news_categories_loaded", + "ticker": "", + "categories": {}, + "error": "invalid ticker", + }, + ensure_ascii=False, + ), + ) + return + + lookback_days = data.get("lookback_days", 90) + try: + lookback_days = max(7, min(int(lookback_days), 365)) + except (TypeError, ValueError): + lookback_days = 90 + + end_date = self.state_sync.state.get("current_date") + if not end_date: + end_date = datetime.now().strftime("%Y-%m-%d") + + try: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + except ValueError: + end_dt = datetime.now() + end_date = end_dt.strftime("%Y-%m-%d") + + start_date = (end_dt - timedelta(days=lookback_days)).strftime( + "%Y-%m-%d", + ) + news_rows = await asyncio.to_thread( + self.storage.market_store.get_news_items_enriched, + ticker, + start_date=start_date, + end_date=end_date, + limit=200, + ) + if self._news_rows_need_enrichment(news_rows): + await asyncio.to_thread( + enrich_news_for_symbol, + self.storage.market_store, + ticker, + start_date=start_date, + end_date=end_date, + limit=200, + ) + categories = await asyncio.to_thread( + self.storage.market_store.get_news_categories_enriched, + ticker, + start_date=start_date, + end_date=end_date, + limit=200, + ) + await websocket.send( + json.dumps( + { + "type": "stock_news_categories_loaded", + "ticker": ticker, + "categories": categories, + "start_date": start_date, + "end_date": end_date, + }, + ensure_ascii=False, + default=str, + ), + ) + + async def _handle_get_stock_range_explain( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ): + ticker = normalize_symbol(data.get("ticker", "")) + start_date = str(data.get("start_date") or "").strip() + end_date = str(data.get("end_date") or "").strip() + if not ticker or not start_date or not end_date: + await websocket.send( + json.dumps( + { + "type": "stock_range_explain_loaded", + "ticker": ticker, + "result": {"error": "ticker, start_date, end_date are required"}, + }, + ensure_ascii=False, + ), + ) + return + + article_ids = data.get("article_ids") + if isinstance(article_ids, list) and article_ids: + news_rows = await asyncio.to_thread( + self.storage.market_store.get_news_by_ids_enriched, + ticker, + article_ids, + ) + if self._news_rows_need_enrichment(news_rows): + await asyncio.to_thread( + enrich_news_for_symbol, + self.storage.market_store, + ticker, + start_date=start_date, + end_date=end_date, + limit=100, + ) + news_rows = await asyncio.to_thread( + self.storage.market_store.get_news_by_ids_enriched, + ticker, + article_ids, + ) + else: + news_rows = await asyncio.to_thread( + self.storage.market_store.get_news_items_enriched, + ticker, + start_date=start_date, + end_date=end_date, + limit=100, + ) + if not news_rows: + await asyncio.to_thread( + enrich_news_for_symbol, + self.storage.market_store, + ticker, + start_date=start_date, + end_date=end_date, + limit=100, + ) + news_rows = await asyncio.to_thread( + self.storage.market_store.get_news_items_enriched, + ticker, + start_date=start_date, + end_date=end_date, + limit=100, + ) + + result = await asyncio.to_thread( + build_range_explanation, + ticker=ticker, + start_date=start_date, + end_date=end_date, + news_rows=news_rows, + ) + await websocket.send( + json.dumps( + { + "type": "stock_range_explain_loaded", + "ticker": ticker, + "result": result, + }, + ensure_ascii=False, + default=str, + ), + ) + + async def _handle_get_stock_story( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ): + ticker = normalize_symbol(data.get("ticker", "")) + if not ticker: + await websocket.send( + json.dumps( + { + "type": "stock_story_loaded", + "ticker": "", + "story": "", + "error": "invalid ticker", + }, + ensure_ascii=False, + ), + ) + return + + as_of_date = str( + data.get("as_of_date") + or self.state_sync.state.get("current_date") + or datetime.now().strftime("%Y-%m-%d") + ).strip()[:10] + await asyncio.to_thread( + enrich_news_for_symbol, + self.storage.market_store, + ticker, + end_date=as_of_date, + limit=80, + ) + result = await asyncio.to_thread( + get_or_create_stock_story, + self.storage.market_store, + symbol=ticker, + as_of_date=as_of_date, + ) + await websocket.send( + json.dumps( + { + "type": "stock_story_loaded", + "ticker": ticker, + "as_of_date": as_of_date, + "story": result.get("story") or "", + "source": result.get("source") or "local", + }, + ensure_ascii=False, + default=str, + ), + ) + + async def _handle_get_stock_similar_days( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ): + ticker = normalize_symbol(data.get("ticker", "")) + target_date = str(data.get("date") or "").strip()[:10] + if not ticker or not target_date: + await websocket.send( + json.dumps( + { + "type": "stock_similar_days_loaded", + "ticker": ticker, + "date": target_date, + "items": [], + "error": "ticker and date are required", + }, + ensure_ascii=False, + ), + ) + return + + top_k = data.get("top_k", 8) + try: + top_k = max(1, min(int(top_k), 20)) + except (TypeError, ValueError): + top_k = 8 + + await asyncio.to_thread( + enrich_news_for_symbol, + self.storage.market_store, + ticker, + end_date=target_date, + limit=200, + ) + result = await asyncio.to_thread( + find_similar_days, + self.storage.market_store, + symbol=ticker, + target_date=target_date, + top_k=top_k, + ) + await websocket.send( + json.dumps( + { + "type": "stock_similar_days_loaded", + "ticker": ticker, + "date": target_date, + **result, + }, + ensure_ascii=False, + default=str, + ), + ) + + async def _handle_run_stock_enrich( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ): + ticker = normalize_symbol(data.get("ticker", "")) + start_date = str(data.get("start_date") or "").strip()[:10] + end_date = str(data.get("end_date") or "").strip()[:10] + story_date = str(data.get("story_date") or end_date or "").strip()[:10] + target_date = str(data.get("target_date") or "").strip()[:10] + force = bool(data.get("force", False)) + rebuild_story = bool(data.get("rebuild_story", True)) + rebuild_similar_days = bool(data.get("rebuild_similar_days", True)) + only_local_to_llm = bool(data.get("only_local_to_llm", False)) + limit = data.get("limit", 200) + + try: + limit = max(10, min(int(limit), 500)) + except (TypeError, ValueError): + limit = 200 + + if not ticker or not start_date or not end_date: + await websocket.send( + json.dumps( + { + "type": "stock_enrich_completed", + "ticker": ticker, + "start_date": start_date, + "end_date": end_date, + "error": "ticker, start_date, end_date are required", + }, + ensure_ascii=False, + ), + ) + return + + if only_local_to_llm and not llm_enrichment_enabled(): + await websocket.send( + json.dumps( + { + "type": "stock_enrich_completed", + "ticker": ticker, + "start_date": start_date, + "end_date": end_date, + "error": "only_local_to_llm requires EXPLAIN_ENRICH_USE_LLM=true and a configured LLM provider", + }, + ensure_ascii=False, + ), + ) + return + + result = await asyncio.to_thread( + enrich_news_for_symbol, + self.storage.market_store, + ticker, + start_date=start_date, + end_date=end_date, + limit=limit, + skip_existing=not force, + only_reanalyze_local=only_local_to_llm, + ) + + story_status = None + if rebuild_story and story_date: + await asyncio.to_thread( + self.storage.market_store.delete_story_cache, + ticker, + as_of_date=story_date, + ) + story_result = await asyncio.to_thread( + get_or_create_stock_story, + self.storage.market_store, + symbol=ticker, + as_of_date=story_date, + ) + story_status = { + "as_of_date": story_date, + "source": story_result.get("source") or "local", + } + + similar_status = None + if rebuild_similar_days and target_date: + await asyncio.to_thread( + self.storage.market_store.delete_similar_day_cache, + ticker, + target_date=target_date, + ) + similar_result = await asyncio.to_thread( + find_similar_days, + self.storage.market_store, + symbol=ticker, + target_date=target_date, + top_k=8, + ) + similar_status = { + "target_date": target_date, + "count": len(similar_result.get("items") or []), + "error": similar_result.get("error"), + } + + await websocket.send( + json.dumps( + { + "type": "stock_enrich_completed", + "ticker": ticker, + "start_date": start_date, + "end_date": end_date, + "story_date": story_date or None, + "target_date": target_date or None, + "force": force, + "only_local_to_llm": only_local_to_llm, + "stats": result, + "story_status": story_status, + "similar_status": similar_status, + }, + ensure_ascii=False, + default=str, + ), + ) + async def _handle_start_backtest(self, data: Dict[str, Any]): if not self.is_backtest: return @@ -410,6 +1093,7 @@ class Gateway: }, ) await self._handle_reload_runtime_assets() + self._schedule_watchlist_market_store_refresh(tickers) @staticmethod def _normalize_watchlist(raw_tickers: Any) -> List[str]: @@ -538,6 +1222,48 @@ class Gateway: trades=trades, ) + def _schedule_watchlist_market_store_refresh( + self, + tickers: List[str], + ) -> None: + """Kick off a non-blocking Polygon refresh for the updated watchlist.""" + if not tickers: + return + if self._watchlist_ingest_task and not self._watchlist_ingest_task.done(): + self._watchlist_ingest_task.cancel() + self._watchlist_ingest_task = asyncio.create_task( + self._refresh_market_store_for_watchlist(tickers), + ) + + async def _refresh_market_store_for_watchlist( + self, + tickers: List[str], + ) -> None: + """Refresh the long-lived market store after a watchlist update.""" + try: + await self.state_sync.on_system_message( + f"正在同步自选股市场数据: {', '.join(tickers)}", + ) + results = await asyncio.to_thread( + ingest_symbols, + tickers, + mode="incremental", + ) + summary = ", ".join( + f"{item['symbol']} prices={item['prices']} news={item['news']}" + for item in results + ) + await self.state_sync.on_system_message( + f"自选股市场数据已同步: {summary}", + ) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning("Watchlist market store refresh failed: %s", exc) + await self.state_sync.on_system_message( + f"自选股市场数据同步失败: {exc}", + ) + async def broadcast(self, message: Dict[str, Any]): """Broadcast message to all connected clients""" if not self.connected_clients: @@ -896,4 +1622,6 @@ class Gateway: self._backtest_task.cancel() if self._market_status_task: self._market_status_task.cancel() + if self._watchlist_ingest_task: + self._watchlist_ingest_task.cancel() self._dashboard.stop() diff --git a/backend/services/market.py b/backend/services/market.py index 68ac6a9..f523808 100644 --- a/backend/services/market.py +++ b/backend/services/market.py @@ -65,6 +65,18 @@ class MarketService: self._session_start_values: Optional[Dict[str, float]] = None self._session_start_timestamp: Optional[int] = None + def get_live_quote_provider(self) -> Optional[str]: + """Return the active live quote provider for UI/debugging.""" + if self.backtest_mode: + return "backtest" + if self.mock_mode: + return "mock" + if self._price_manager and hasattr(self._price_manager, "provider"): + provider = getattr(self._price_manager, "provider", None) + if isinstance(provider, str) and provider.strip(): + return provider.strip().lower() + return None + @property def mode_name(self) -> str: if self.backtest_mode: @@ -532,6 +544,7 @@ class MarketService: "status": MarketStatus.OPEN, "status_text": "Backtest Mode", "is_trading_day": True, + "live_quote_provider": self.get_live_quote_provider(), } now = self._now_nyse() @@ -544,6 +557,7 @@ class MarketService: "status": MarketStatus.CLOSED, "status_text": "Market Closed (Non-trading Day)", "is_trading_day": False, + "live_quote_provider": self.get_live_quote_provider(), } market_open, market_close = self._get_market_hours(today) @@ -553,6 +567,7 @@ class MarketService: "status": MarketStatus.CLOSED, "status_text": "Market Closed", "is_trading_day": is_trading, + "live_quote_provider": self.get_live_quote_provider(), } # Determine status based on current time @@ -563,6 +578,7 @@ class MarketService: "is_trading_day": True, "market_open": market_open.isoformat(), "market_close": market_close.isoformat(), + "live_quote_provider": self.get_live_quote_provider(), } elif now > market_close: return { @@ -571,6 +587,7 @@ class MarketService: "is_trading_day": True, "market_open": market_open.isoformat(), "market_close": market_close.isoformat(), + "live_quote_provider": self.get_live_quote_provider(), } else: return { @@ -579,6 +596,7 @@ class MarketService: "is_trading_day": True, "market_open": market_open.isoformat(), "market_close": market_close.isoformat(), + "live_quote_provider": self.get_live_quote_provider(), } async def check_and_broadcast_market_status(self): diff --git a/backend/services/research_db.py b/backend/services/research_db.py new file mode 100644 index 0000000..387e34f --- /dev/null +++ b/backend/services/research_db.py @@ -0,0 +1,280 @@ +# -*- coding: utf-8 -*- +"""Query-oriented storage for explain/research data.""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Iterable + +from backend.data.schema import CompanyNews + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS news_items ( + id TEXT PRIMARY KEY, + ticker TEXT NOT NULL, + published_at TEXT, + trade_date TEXT, + source TEXT, + title TEXT NOT NULL, + summary TEXT, + url TEXT, + related TEXT, + category TEXT, + raw_json TEXT NOT NULL, + ingest_run_date TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_news_items_ticker_date +ON news_items (ticker, trade_date DESC, published_at DESC); +""" + + +def _json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + + +def _resolve_news_id(ticker: str, item: CompanyNews, fallback_index: int) -> str: + base = item.url or item.title or f"{ticker}-{fallback_index}" + return f"{ticker}:{base}" + + +def _resolve_trade_date(date_value: str | None) -> str | None: + if not date_value: + return None + normalized = str(date_value).strip() + if not normalized: + return None + if "T" in normalized: + return normalized.split("T", 1)[0] + if " " in normalized: + return normalized.split(" ", 1)[0] + return normalized[:10] + + +class ResearchDb: + """Small SQLite helper for explain-oriented news storage.""" + + def __init__(self, db_path: Path): + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._init_db() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + def _init_db(self): + with self._connect() as conn: + conn.executescript(SCHEMA) + + def upsert_news_items( + self, + *, + ticker: str, + items: Iterable[CompanyNews], + ingest_run_date: str | None = None, + ) -> list[dict[str, Any]]: + """Persist provider news and return normalized rows.""" + normalized_rows: list[dict[str, Any]] = [] + timestamp = datetime.utcnow().isoformat(timespec="seconds") + symbol = str(ticker or "").strip().upper() + if not symbol: + return normalized_rows + + with self._connect() as conn: + for index, item in enumerate(items): + news_id = _resolve_news_id(symbol, item, index) + trade_date = _resolve_trade_date(item.date) + payload = item.model_dump() + row = { + "id": news_id, + "ticker": symbol, + "published_at": item.date, + "trade_date": trade_date, + "source": item.source, + "title": item.title, + "summary": item.summary, + "url": item.url, + "related": item.related, + "category": item.category, + "raw_json": _json_dumps(payload), + "ingest_run_date": ingest_run_date, + "created_at": timestamp, + } + conn.execute( + """ + INSERT INTO news_items + (id, ticker, published_at, trade_date, source, title, summary, url, + related, category, raw_json, ingest_run_date, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + ticker = excluded.ticker, + published_at = excluded.published_at, + trade_date = excluded.trade_date, + source = excluded.source, + title = excluded.title, + summary = excluded.summary, + url = excluded.url, + related = excluded.related, + category = excluded.category, + raw_json = excluded.raw_json, + ingest_run_date = excluded.ingest_run_date + """, + ( + row["id"], + row["ticker"], + row["published_at"], + row["trade_date"], + row["source"], + row["title"], + row["summary"], + row["url"], + row["related"], + row["category"], + row["raw_json"], + row["ingest_run_date"], + row["created_at"], + ), + ) + normalized_rows.append(row) + return normalized_rows + + def get_news_items( + self, + *, + ticker: str, + start_date: str | None = None, + end_date: str | None = None, + limit: int = 20, + ) -> list[dict[str, Any]]: + """Return normalized news rows for explain UI.""" + symbol = str(ticker or "").strip().upper() + if not symbol: + return [] + + sql = """ + SELECT id, ticker, published_at, trade_date, source, title, summary, + url, related, category + FROM news_items + WHERE ticker = ? + """ + params: list[Any] = [symbol] + if start_date: + sql += " AND COALESCE(trade_date, substr(published_at, 1, 10)) >= ?" + params.append(start_date) + if end_date: + sql += " AND COALESCE(trade_date, substr(published_at, 1, 10)) <= ?" + params.append(end_date) + sql += " ORDER BY COALESCE(published_at, trade_date) DESC LIMIT ?" + params.append(max(1, int(limit))) + + with self._connect() as conn: + rows = conn.execute(sql, params).fetchall() + + return [ + { + "id": row["id"], + "ticker": row["ticker"], + "date": row["published_at"] or row["trade_date"], + "trade_date": row["trade_date"], + "source": row["source"], + "title": row["title"], + "summary": row["summary"], + "url": row["url"], + "related": row["related"], + "category": row["category"], + } + for row in rows + ] + + def get_news_timeline( + self, + *, + ticker: str, + start_date: str | None = None, + end_date: str | None = None, + ) -> list[dict[str, Any]]: + """Aggregate news counts per trade date for chart markers.""" + symbol = str(ticker or "").strip().upper() + if not symbol: + return [] + + sql = """ + SELECT COALESCE(trade_date, substr(published_at, 1, 10)) AS date, + COUNT(*) AS count, + COUNT(DISTINCT source) AS source_count, + MAX(title) AS top_title + FROM news_items + WHERE ticker = ? + """ + params: list[Any] = [symbol] + if start_date: + sql += " AND COALESCE(trade_date, substr(published_at, 1, 10)) >= ?" + params.append(start_date) + if end_date: + sql += " AND COALESCE(trade_date, substr(published_at, 1, 10)) <= ?" + params.append(end_date) + sql += """ + GROUP BY COALESCE(trade_date, substr(published_at, 1, 10)) + ORDER BY date ASC + """ + + with self._connect() as conn: + rows = conn.execute(sql, params).fetchall() + + return [ + { + "date": row["date"], + "count": int(row["count"] or 0), + "source_count": int(row["source_count"] or 0), + "top_title": row["top_title"] or "", + } + for row in rows + if row["date"] + ] + + def get_news_by_ids( + self, + *, + ticker: str, + article_ids: Iterable[str], + ) -> list[dict[str, Any]]: + """Return selected persisted news items.""" + symbol = str(ticker or "").strip().upper() + ids = [str(article_id).strip() for article_id in article_ids if str(article_id).strip()] + if not symbol or not ids: + return [] + + placeholders = ",".join("?" for _ in ids) + sql = f""" + SELECT id, ticker, published_at, trade_date, source, title, summary, + url, related, category + FROM news_items + WHERE ticker = ? AND id IN ({placeholders}) + ORDER BY COALESCE(published_at, trade_date) DESC + """ + with self._connect() as conn: + rows = conn.execute(sql, [symbol, *ids]).fetchall() + + return [ + { + "id": row["id"], + "ticker": row["ticker"], + "date": row["published_at"] or row["trade_date"], + "trade_date": row["trade_date"], + "source": row["source"], + "title": row["title"], + "summary": row["summary"], + "url": row["url"], + "related": row["related"], + "category": row["category"], + } + for row in rows + ] diff --git a/backend/services/storage.py b/backend/services/storage.py index a633da7..79d5d23 100644 --- a/backend/services/storage.py +++ b/backend/services/storage.py @@ -10,6 +10,8 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional +from backend.data.market_store import MarketStore +from .research_db import ResearchDb from .runtime_db import RuntimeDb logger = logging.getLogger(__name__) @@ -64,6 +66,8 @@ class StorageService: self.state_dir.mkdir(parents=True, exist_ok=True) self.server_state_file = self.state_dir / "server_state.json" self.runtime_db = RuntimeDb(self.state_dir / "runtime.db") + self.research_db = ResearchDb(self.state_dir / "research.db") + self.market_store = MarketStore() # Feed history (for agent messages) self.max_feed_history = 200 diff --git a/backend/tests/test_cli.py b/backend/tests/test_cli.py new file mode 100644 index 0000000..986be98 --- /dev/null +++ b/backend/tests/test_cli.py @@ -0,0 +1,236 @@ +# -*- coding: utf-8 -*- +from pathlib import Path + +from backend import cli + + +def test_live_runs_incremental_market_store_update_before_start(monkeypatch, tmp_path): + project_root = tmp_path + (project_root / ".env").write_text("FINNHUB_API_KEY=test\n", encoding="utf-8") + + calls = [] + + monkeypatch.setattr(cli, "get_project_root", lambda: project_root) + monkeypatch.setattr(cli, "handle_history_cleanup", lambda config_name, auto_clean=False: None) + monkeypatch.setattr(cli, "run_data_updater", lambda project_root: calls.append(("run_data_updater", project_root))) + monkeypatch.setattr( + cli, + "auto_update_market_store", + lambda config_name, end_date=None: calls.append(("auto_update_market_store", config_name, end_date)), + ) + monkeypatch.setattr( + cli, + "auto_enrich_market_store", + lambda config_name, end_date=None, lookback_days=120, force=False: calls.append( + ("auto_enrich_market_store", config_name, end_date, lookback_days, force) + ), + ) + monkeypatch.setattr(cli.os, "chdir", lambda path: calls.append(("chdir", Path(path)))) + + def fake_run(cmd, check=True, **kwargs): + calls.append(("subprocess.run", cmd, check)) + return 0 + + monkeypatch.setattr(cli.subprocess, "run", fake_run) + + cli.live( + mock=False, + config_name="smoke_fullstack", + host="0.0.0.0", + port=8765, + trigger_time="now", + poll_interval=10, + clean=False, + enable_memory=False, + ) + + assert any(item[0] == "run_data_updater" for item in calls) + assert any( + item[0] == "auto_update_market_store" and item[1] == "smoke_fullstack" + for item in calls + ) + assert any( + item[0] == "auto_enrich_market_store" and item[1] == "smoke_fullstack" + for item in calls + ) + run_call = next(item for item in calls if item[0] == "subprocess.run") + assert run_call[1][:6] == [ + cli.sys.executable, + "-u", + "-m", + "backend.main", + "--mode", + "live", + ] + + +def test_backtest_runs_full_market_store_prepare_before_start(monkeypatch, tmp_path): + project_root = tmp_path + calls = [] + + monkeypatch.setattr(cli, "get_project_root", lambda: project_root) + monkeypatch.setattr(cli, "handle_history_cleanup", lambda config_name, auto_clean=False: None) + monkeypatch.setattr(cli, "run_data_updater", lambda project_root: calls.append(("run_data_updater", project_root))) + monkeypatch.setattr( + cli, + "auto_prepare_backtest_market_store", + lambda config_name, start_date, end_date: calls.append( + ("auto_prepare_backtest_market_store", config_name, start_date, end_date) + ), + ) + monkeypatch.setattr( + cli, + "auto_enrich_market_store", + lambda config_name, end_date=None, lookback_days=120, force=False: calls.append( + ("auto_enrich_market_store", config_name, end_date, lookback_days, force) + ), + ) + monkeypatch.setattr(cli.os, "chdir", lambda path: calls.append(("chdir", Path(path)))) + + def fake_run(cmd, check=True, **kwargs): + calls.append(("subprocess.run", cmd, check)) + return 0 + + monkeypatch.setattr(cli.subprocess, "run", fake_run) + + cli.backtest( + start="2026-03-01", + end="2026-03-10", + config_name="smoke_fullstack", + host="0.0.0.0", + port=8765, + poll_interval=10, + clean=False, + enable_memory=False, + ) + + assert any(item[0] == "run_data_updater" for item in calls) + assert any( + item[0] == "auto_prepare_backtest_market_store" + and item[1:] == ("smoke_fullstack", "2026-03-01", "2026-03-10") + for item in calls + ) + assert any( + item[0] == "auto_enrich_market_store" + and item[1] == "smoke_fullstack" + and item[2] == "2026-03-10" + for item in calls + ) + run_call = next(item for item in calls if item[0] == "subprocess.run") + assert run_call[1][:6] == [ + cli.sys.executable, + "-u", + "-m", + "backend.main", + "--mode", + "backtest", + ] + + +def test_ingest_enrich_runs_batch_enrichment(monkeypatch): + calls = [] + + monkeypatch.setattr(cli, "_resolve_symbols", lambda raw_tickers, config_name=None: ["AAPL", "MSFT"]) + + class DummyStore: + pass + + monkeypatch.setattr(cli, "MarketStore", lambda: DummyStore()) + monkeypatch.setattr( + cli, + "enrich_symbols", + lambda store, symbols, start_date=None, end_date=None, limit=200, analysis_source="local", skip_existing=True: calls.append( + ("enrich_symbols", symbols, start_date, end_date, limit, analysis_source, skip_existing) + ) or [ + { + "symbol": symbol, + "news_count": 3, + "queued_count": 3, + "analyzed": 3, + "skipped_existing_count": 0, + "deduped_count": 0, + "llm_count": 0, + "local_count": 3, + } + for symbol in symbols + ], + ) + + cli.ingest_enrich( + tickers=None, + start="2026-03-01", + end="2026-03-10", + limit=150, + force=False, + config_name="smoke_fullstack", + ) + + assert calls == [ + ("enrich_symbols", ["AAPL", "MSFT"], "2026-03-01", "2026-03-10", 150, "local", True) + ] + + +def test_ingest_report_reads_market_store_report(monkeypatch): + calls = [] + printed = [] + + monkeypatch.setattr(cli, "_resolve_symbols", lambda raw_tickers, config_name=None: ["AAPL"]) + + class DummyStore: + def get_enrich_report(self, symbols=None, start_date=None, end_date=None): + calls.append(("get_enrich_report", symbols, start_date, end_date)) + return [ + { + "symbol": "AAPL", + "raw_news_count": 10, + "analyzed_news_count": 8, + "coverage_pct": 80.0, + "llm_count": 5, + "local_count": 3, + "latest_trade_date": "2026-03-16", + "latest_analysis_at": "2026-03-16T09:00:00", + } + ] + + monkeypatch.setattr(cli, "MarketStore", lambda: DummyStore()) + monkeypatch.setattr(cli, "get_explain_model_info", lambda: {"provider": "DASHSCOPE", "model_name": "qwen-max", "label": "DASHSCOPE:qwen-max"}) + monkeypatch.setattr(cli, "llm_enrichment_enabled", lambda: True) + monkeypatch.setattr(cli.console, "print", lambda value: printed.append(value)) + + cli.ingest_report( + tickers=None, + start="2026-03-01", + end="2026-03-16", + config_name="smoke_fullstack", + only_problematic=False, + ) + + assert calls == [ + ("get_enrich_report", ["AAPL"], "2026-03-01", "2026-03-16") + ] + assert printed + assert getattr(printed[0], "caption", "") == "Explain LLM: DASHSCOPE:qwen-max" + + +def test_filter_problematic_report_rows_keeps_low_coverage_and_no_llm(): + rows = [ + { + "symbol": "AAPL", + "coverage_pct": 100.0, + "llm_count": 2, + }, + { + "symbol": "MSFT", + "coverage_pct": 80.0, + "llm_count": 1, + }, + { + "symbol": "NVDA", + "coverage_pct": 100.0, + "llm_count": 0, + }, + ] + + filtered = cli._filter_problematic_report_rows(rows) + + assert [row["symbol"] for row in filtered] == ["MSFT", "NVDA"] diff --git a/backend/tests/test_gateway_explain_handlers.py b/backend/tests/test_gateway_explain_handlers.py new file mode 100644 index 0000000..f25feec --- /dev/null +++ b/backend/tests/test_gateway_explain_handlers.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +import json +from types import SimpleNamespace + +import pytest + +from backend.services.gateway import Gateway +import backend.services.gateway as gateway_module + + +class DummyWebSocket: + def __init__(self): + self.messages = [] + + async def send(self, payload: str): + self.messages.append(json.loads(payload)) + + +class DummyStateSync: + def __init__(self, current_date="2026-03-16"): + self.state = {"current_date": current_date} + self.system_messages = [] + + def set_broadcast_fn(self, _fn): + return None + + def update_state(self, *_args, **_kwargs): + return None + + async def on_system_message(self, message): + self.system_messages.append(message) + + +class FakeMarketStore: + def __init__(self): + self.calls = [] + + def get_news_timeline_enriched(self, symbol, *, start_date=None, end_date=None): + self.calls.append(("get_news_timeline_enriched", symbol, start_date, end_date)) + return [{"date": end_date, "count": 2, "source_count": 1, "top_title": "Top", "positive_count": 1}] + + def get_news_items(self, symbol, *, start_date=None, end_date=None, limit=100): + self.calls.append(("get_news_items", symbol, start_date, end_date, limit)) + return [ + { + "id": "news-1", + "ticker": symbol, + "date": end_date, + "trade_date": end_date, + "title": "Title", + "summary": "Summary", + "source": "polygon", + } + ] + + def get_news_items_enriched(self, symbol, *, start_date=None, end_date=None, trade_date=None, limit=100): + self.calls.append(("get_news_items_enriched", symbol, start_date, end_date, trade_date, limit)) + target_date = trade_date or end_date + return [ + { + "id": "news-1", + "ticker": symbol, + "date": target_date, + "trade_date": target_date, + "title": "Title", + "summary": "Summary", + "source": "polygon", + "sentiment": "negative", + "relevance": "high", + "key_discussion": "Key discussion", + } + ] + + def get_news_by_ids_enriched(self, symbol, article_ids): + self.calls.append(("get_news_by_ids_enriched", symbol, list(article_ids))) + return [{"id": article_ids[0], "ticker": symbol, "date": "2026-03-16", "sentiment": "negative"}] + + def get_news_categories_enriched(self, symbol, *, start_date=None, end_date=None, limit=200): + self.calls.append(("get_news_categories_enriched", symbol, start_date, end_date, limit)) + return {"macro": {"label": "宏观", "count": 1, "article_ids": ["news-1"], "positive_ids": [], "negative_ids": ["news-1"], "neutral_ids": []}} + + def get_story_cache(self, symbol, *, as_of_date): + self.calls.append(("get_story_cache", symbol, as_of_date)) + return None + + def upsert_story_cache(self, symbol, *, as_of_date, content, source="local"): + self.calls.append(("upsert_story_cache", symbol, as_of_date, source)) + + def delete_story_cache(self, symbol, *, as_of_date=None): + self.calls.append(("delete_story_cache", symbol, as_of_date)) + return 1 + + def get_similar_day_cache(self, symbol, *, target_date): + self.calls.append(("get_similar_day_cache", symbol, target_date)) + return None + + def upsert_similar_day_cache(self, symbol, *, target_date, payload, source="local"): + self.calls.append(("upsert_similar_day_cache", symbol, target_date, source)) + + def delete_similar_day_cache(self, symbol, *, target_date=None): + self.calls.append(("delete_similar_day_cache", symbol, target_date)) + return 1 + + def get_ohlc(self, symbol, start_date, end_date): + self.calls.append(("get_ohlc", symbol, start_date, end_date)) + return [ + {"date": start_date, "open": 100, "high": 105, "low": 99, "close": 103}, + {"date": end_date, "open": 103, "high": 108, "low": 102, "close": 107}, + ] + + +def make_gateway(market_store=None): + storage = SimpleNamespace(market_store=market_store or FakeMarketStore()) + pipeline = SimpleNamespace(state_sync=None) + market_service = SimpleNamespace() + state_sync = DummyStateSync() + return Gateway( + market_service=market_service, + storage_service=storage, + pipeline=pipeline, + state_sync=state_sync, + config={"mode": "live"}, + ) + + +@pytest.mark.asyncio +async def test_handle_get_stock_news_timeline_uses_market_store_symbol_argument(): + market_store = FakeMarketStore() + gateway = make_gateway(market_store) + websocket = DummyWebSocket() + + await gateway._handle_get_stock_news_timeline( + websocket, + {"ticker": "AAPL", "lookback_days": 30}, + ) + + assert market_store.calls == [ + ("get_news_timeline_enriched", "AAPL", "2026-02-14", "2026-03-16") + ] + assert websocket.messages[-1]["type"] == "stock_news_timeline_loaded" + assert websocket.messages[-1]["ticker"] == "AAPL" + + +@pytest.mark.asyncio +async def test_handle_get_stock_news_categories_uses_market_store_symbol_argument(monkeypatch): + market_store = FakeMarketStore() + gateway = make_gateway(market_store) + websocket = DummyWebSocket() + + await gateway._handle_get_stock_news_categories( + websocket, + {"ticker": "AAPL", "lookback_days": 30}, + ) + + assert market_store.calls == [ + ("get_news_items_enriched", "AAPL", "2026-02-14", "2026-03-16", None, 200), + ("get_news_categories_enriched", "AAPL", "2026-02-14", "2026-03-16", 200) + ] + assert websocket.messages[-1]["type"] == "stock_news_categories_loaded" + assert websocket.messages[-1]["categories"]["macro"]["count"] == 1 + + +@pytest.mark.asyncio +async def test_handle_get_stock_range_explain_uses_market_store_rows(monkeypatch): + market_store = FakeMarketStore() + gateway = make_gateway(market_store) + websocket = DummyWebSocket() + + def fake_build_range_explanation(*, ticker, start_date, end_date, news_rows): + return { + "ticker": ticker, + "start_date": start_date, + "end_date": end_date, + "news_count": len(news_rows), + } + + monkeypatch.setattr( + gateway_module, + "build_range_explanation", + fake_build_range_explanation, + ) + + await gateway._handle_get_stock_range_explain( + websocket, + {"ticker": "AAPL", "start_date": "2026-03-10", "end_date": "2026-03-16"}, + ) + + assert market_store.calls == [ + ("get_news_items_enriched", "AAPL", "2026-03-10", "2026-03-16", None, 100) + ] + assert websocket.messages[-1] == { + "type": "stock_range_explain_loaded", + "ticker": "AAPL", + "result": { + "ticker": "AAPL", + "start_date": "2026-03-10", + "end_date": "2026-03-16", + "news_count": 1, + }, + } + + +@pytest.mark.asyncio +async def test_handle_get_stock_range_explain_uses_article_ids_path(monkeypatch): + market_store = FakeMarketStore() + gateway = make_gateway(market_store) + websocket = DummyWebSocket() + + monkeypatch.setattr( + gateway_module, + "build_range_explanation", + lambda **kwargs: {"news_count": len(kwargs["news_rows"])}, + ) + + await gateway._handle_get_stock_range_explain( + websocket, + { + "ticker": "AAPL", + "start_date": "2026-03-10", + "end_date": "2026-03-16", + "article_ids": ["news-99"], + }, + ) + + assert market_store.calls == [("get_news_by_ids_enriched", "AAPL", ["news-99"])] + assert websocket.messages[-1]["result"]["news_count"] == 1 + + +@pytest.mark.asyncio +async def test_handle_get_stock_news_for_date_uses_trade_date_lookup(): + market_store = FakeMarketStore() + gateway = make_gateway(market_store) + websocket = DummyWebSocket() + + await gateway._handle_get_stock_news_for_date( + websocket, + {"ticker": "AAPL", "date": "2026-03-16", "limit": 10}, + ) + + assert market_store.calls == [ + ("get_news_items_enriched", "AAPL", None, None, "2026-03-16", 10) + ] + assert websocket.messages[-1]["type"] == "stock_news_for_date_loaded" + assert websocket.messages[-1]["date"] == "2026-03-16" + + +@pytest.mark.asyncio +async def test_handle_get_stock_story_returns_story_payload(monkeypatch): + market_store = FakeMarketStore() + gateway = make_gateway(market_store) + websocket = DummyWebSocket() + + monkeypatch.setattr( + gateway_module, + "enrich_news_for_symbol", + lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 3}, + ) + + await gateway._handle_get_stock_story( + websocket, + {"ticker": "AAPL", "as_of_date": "2026-03-16"}, + ) + + assert websocket.messages[-1]["type"] == "stock_story_loaded" + assert websocket.messages[-1]["ticker"] == "AAPL" + assert "AAPL Story" in websocket.messages[-1]["story"] + + +@pytest.mark.asyncio +async def test_handle_get_stock_similar_days_returns_items(monkeypatch): + market_store = FakeMarketStore() + gateway = make_gateway(market_store) + websocket = DummyWebSocket() + + monkeypatch.setattr( + gateway_module, + "enrich_news_for_symbol", + lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 3}, + ) + + await gateway._handle_get_stock_similar_days( + websocket, + {"ticker": "AAPL", "date": "2026-03-16", "top_k": 5}, + ) + + assert websocket.messages[-1]["type"] == "stock_similar_days_loaded" + assert websocket.messages[-1]["ticker"] == "AAPL" + assert isinstance(websocket.messages[-1]["items"], list) + + +@pytest.mark.asyncio +async def test_handle_run_stock_enrich_rebuilds_caches(monkeypatch): + market_store = FakeMarketStore() + gateway = make_gateway(market_store) + websocket = DummyWebSocket() + + monkeypatch.setattr( + gateway_module, + "enrich_news_for_symbol", + lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 2, "queued_count": 2}, + ) + + await gateway._handle_run_stock_enrich( + websocket, + { + "ticker": "AAPL", + "start_date": "2026-03-10", + "end_date": "2026-03-16", + "force": True, + "rebuild_story": True, + "rebuild_similar_days": True, + "story_date": "2026-03-16", + "target_date": "2026-03-16", + }, + ) + + assert ("delete_story_cache", "AAPL", "2026-03-16") in market_store.calls + assert ("delete_similar_day_cache", "AAPL", "2026-03-16") in market_store.calls + assert websocket.messages[-1]["type"] == "stock_enrich_completed" + assert websocket.messages[-1]["stats"]["analyzed"] == 2 + + +@pytest.mark.asyncio +async def test_handle_run_stock_enrich_rejects_local_to_llm_without_llm(monkeypatch): + gateway = make_gateway(FakeMarketStore()) + websocket = DummyWebSocket() + + monkeypatch.setattr(gateway_module, "llm_enrichment_enabled", lambda: False) + + await gateway._handle_run_stock_enrich( + websocket, + { + "ticker": "AAPL", + "start_date": "2026-03-10", + "end_date": "2026-03-16", + "only_local_to_llm": True, + }, + ) + + assert websocket.messages[-1]["type"] == "stock_enrich_completed" + assert "requires EXPLAIN_ENRICH_USE_LLM=true" in websocket.messages[-1]["error"] + + +def test_schedule_watchlist_market_store_refresh_creates_task(monkeypatch): + gateway = make_gateway() + captured = {} + + class DummyTask: + def done(self): + return False + + def cancel(self): + captured["cancelled"] = True + + def fake_create_task(coro): + captured["coro_name"] = coro.cr_code.co_name + coro.close() + return DummyTask() + + monkeypatch.setattr(gateway_module.asyncio, "create_task", fake_create_task) + + gateway._schedule_watchlist_market_store_refresh(["AAPL", "MSFT"]) + + assert captured["coro_name"] == "_refresh_market_store_for_watchlist" + + +@pytest.mark.asyncio +async def test_refresh_market_store_for_watchlist_emits_system_messages(monkeypatch): + gateway = make_gateway() + + monkeypatch.setattr( + gateway_module, + "ingest_symbols", + lambda symbols, mode="incremental": [ + {"symbol": symbol, "prices": 3, "news": 4, "aligned": 4} + for symbol in symbols + ], + ) + + await gateway._refresh_market_store_for_watchlist(["AAPL", "MSFT"]) + + assert gateway.state_sync.system_messages[0] == "正在同步自选股市场数据: AAPL, MSFT" + assert "自选股市场数据已同步:" in gateway.state_sync.system_messages[1] + assert "AAPL prices=3 news=4" in gateway.state_sync.system_messages[1] diff --git a/backend/tests/test_historical_price_manager.py b/backend/tests/test_historical_price_manager.py new file mode 100644 index 0000000..4dd129d --- /dev/null +++ b/backend/tests/test_historical_price_manager.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +from unittest.mock import patch + +import pandas as pd + +from backend.data.historical_price_manager import HistoricalPriceManager + + +def test_preload_data_prefers_market_db(): + manager = HistoricalPriceManager() + manager.subscribe(["AAPL"]) + + market_rows = [ + { + "symbol": "AAPL", + "date": "2026-03-09", + "open": 100.0, + "high": 103.0, + "low": 99.0, + "close": 102.0, + "volume": 10_000, + "vwap": 101.0, + "transactions": 500, + "source": "polygon", + } + ] + + with ( + patch.object(manager._market_store, "get_ohlc", return_value=market_rows), + patch.object(manager._router, "load_local_price_frame") as load_csv, + ): + manager.preload_data("2026-03-01", "2026-03-10") + + load_csv.assert_not_called() + assert "AAPL" in manager._price_cache + assert float(manager._price_cache["AAPL"].iloc[0]["close"]) == 102.0 + + +def test_preload_data_falls_back_to_csv(): + manager = HistoricalPriceManager() + manager.subscribe(["MSFT"]) + + csv_df = pd.DataFrame( + { + "time": ["2026-03-09"], + "open": [200.0], + "high": [205.0], + "low": [198.0], + "close": [204.0], + "volume": [20_000], + } + ) + csv_df["time"] = pd.to_datetime(csv_df["time"]) + csv_df["Date"] = csv_df["time"] + csv_df.set_index("Date", inplace=True) + + with ( + patch.object(manager._market_store, "get_ohlc", return_value=[]), + patch.object(manager._router, "load_local_price_frame", return_value=csv_df) as load_csv, + ): + manager.preload_data("2026-03-01", "2026-03-10") + + load_csv.assert_called_once_with("MSFT") + assert "MSFT" in manager._price_cache + assert float(manager._price_cache["MSFT"].iloc[0]["close"]) == 204.0 diff --git a/backend/tests/test_llm_enricher.py b/backend/tests/test_llm_enricher.py new file mode 100644 index 0000000..a0f40cb --- /dev/null +++ b/backend/tests/test_llm_enricher.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- + +from backend.enrich import llm_enricher + + +class DummyResponse: + def __init__(self, metadata): + self.metadata = metadata + + +class DummyModel: + def __init__(self, metadata): + self.metadata = metadata + self.calls = [] + + async def __call__(self, messages, structured_model=None, **kwargs): + self.calls.append( + { + "messages": messages, + "structured_model": structured_model, + "kwargs": kwargs, + } + ) + return DummyResponse(self.metadata) + + +def test_analyze_news_row_with_llm_uses_agentscope_model(monkeypatch): + model = DummyModel( + { + "id": "news-1", + "relevance": "high", + "sentiment": "positive", + "key_discussion": "Demand remains resilient", + "summary": "Structured summary", + "reason_growth": "Orders improved", + "reason_decrease": "", + } + ) + monkeypatch.setattr(llm_enricher, "llm_enrichment_enabled", lambda: True) + monkeypatch.setattr(llm_enricher, "_get_explain_model", lambda: model) + monkeypatch.setattr( + llm_enricher, + "get_explain_model_info", + lambda: {"provider": "DASHSCOPE", "model_name": "qwen-max", "label": "DASHSCOPE:qwen-max"}, + ) + + result = llm_enricher.analyze_news_row_with_llm( + { + "id": "news-1", + "title": "Apple expands AI features", + "summary": "New devices and software updates were announced.", + } + ) + + assert result["sentiment"] == "positive" + assert result["summary"] == "Structured summary" + assert result["raw_json"]["model_label"] == "DASHSCOPE:qwen-max" + assert model.calls + assert model.calls[0]["structured_model"] is llm_enricher.EnrichedNewsItem + + +def test_analyze_news_rows_with_llm_uses_agentscope_structured_batch(monkeypatch): + model = DummyModel( + { + "items": [ + { + "id": "news-1", + "relevance": "high", + "sentiment": "negative", + "key_discussion": "Margin pressure", + "summary": "Batch summary", + "reason_growth": "", + "reason_decrease": "Costs rose", + } + ] + } + ) + monkeypatch.setattr(llm_enricher, "llm_enrichment_enabled", lambda: True) + monkeypatch.setattr(llm_enricher, "_get_explain_model", lambda: model) + monkeypatch.setattr( + llm_enricher, + "get_explain_model_info", + lambda: {"provider": "DASHSCOPE", "model_name": "qwen-max", "label": "DASHSCOPE:qwen-max"}, + ) + + result = llm_enricher.analyze_news_rows_with_llm( + [ + { + "id": "news-1", + "title": "Apple margins pressured", + "summary": "Costs increased this quarter.", + } + ] + ) + + assert result["news-1"]["sentiment"] == "negative" + assert result["news-1"]["reason_decrease"] == "Costs rose" + assert result["news-1"]["raw_json"]["model_label"] == "DASHSCOPE:qwen-max" + assert model.calls + assert model.calls[0]["structured_model"] is llm_enricher.EnrichedNewsBatch + + +def test_analyze_range_with_llm_uses_agentscope_structured_output(monkeypatch): + model = DummyModel( + { + "summary": "该股在区间内震荡下行,相关新闻主要集中在盈利预期和供应链扰动。", + "trend_analysis": "前半段受利空新闻压制,后半段跌幅收敛。", + "bullish_factors": ["估值消化后出现部分承接"], + "bearish_factors": ["盈利预期下修", "供应链扰动持续"], + } + ) + monkeypatch.setattr(llm_enricher, "llm_range_analysis_enabled", lambda: True) + monkeypatch.setattr(llm_enricher, "_get_explain_model", lambda: model) + monkeypatch.setattr( + llm_enricher, + "get_explain_model_info", + lambda: {"provider": "DASHSCOPE", "model_name": "qwen-max", "label": "DASHSCOPE:qwen-max"}, + ) + + result = llm_enricher.analyze_range_with_llm( + { + "ticker": "AAPL", + "start_date": "2026-03-10", + "end_date": "2026-03-16", + "price_change_pct": -3.42, + } + ) + + assert result["summary"].startswith("该股在区间内震荡下行") + assert result["model_label"] == "DASHSCOPE:qwen-max" + assert result["bearish_factors"] == ["盈利预期下修", "供应链扰动持续"] + assert model.calls + assert model.calls[0]["structured_model"] is llm_enricher.RangeAnalysisPayload diff --git a/backend/tests/test_market_store_report.py b/backend/tests/test_market_store_report.py new file mode 100644 index 0000000..2887516 --- /dev/null +++ b/backend/tests/test_market_store_report.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- + +from pathlib import Path + +from backend.data.market_store import MarketStore + + +def test_get_enrich_report_summarizes_coverage(tmp_path: Path): + store = MarketStore(tmp_path / "market_research.db") + store.upsert_news( + "AAPL", + [ + { + "id": "news-1", + "published_utc": "2026-03-10T12:00:00Z", + "title": "Apple earnings beat", + "summary": "Revenue topped expectations", + "tickers": ["AAPL"], + }, + { + "id": "news-2", + "published_utc": "2026-03-11T12:00:00Z", + "title": "Apple supply chain warning", + "summary": "Outlook softened", + "tickers": ["AAPL"], + }, + ], + ) + store.set_trade_dates( + [ + {"news_id": "news-1", "symbol": "AAPL", "trade_date": "2026-03-10"}, + {"news_id": "news-2", "symbol": "AAPL", "trade_date": "2026-03-11"}, + ] + ) + store.upsert_news_analysis( + "AAPL", + [ + { + "news_id": "news-1", + "trade_date": "2026-03-10", + "summary": "LLM enriched", + "analysis_source": "llm", + } + ], + analysis_source="llm", + ) + + rows = store.get_enrich_report(["AAPL"]) + assert len(rows) == 1 + assert rows[0]["symbol"] == "AAPL" + assert rows[0]["raw_news_count"] == 2 + assert rows[0]["analyzed_news_count"] == 1 + assert rows[0]["coverage_pct"] == 50.0 + assert rows[0]["llm_count"] == 1 diff --git a/backend/tests/test_news_enricher.py b/backend/tests/test_news_enricher.py new file mode 100644 index 0000000..0d56b18 --- /dev/null +++ b/backend/tests/test_news_enricher.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- + +from backend.enrich import news_enricher + + +def test_classify_news_row_falls_back_to_local_rules(monkeypatch): + monkeypatch.setattr(news_enricher, "analyze_news_row_with_llm", lambda row: None) + result = news_enricher.classify_news_row( + { + "title": "Apple shares drop after weak guidance", + "summary": "Investors reacted negatively to softer-than-expected outlook.", + } + ) + assert result["analysis_source"] == "local" + assert result["sentiment"] == "negative" + assert result["summary"] + + +def test_classify_news_row_prefers_llm_when_available(monkeypatch): + monkeypatch.setattr( + news_enricher, + "analyze_news_row_with_llm", + lambda row: { + "relevance": "high", + "sentiment": "positive", + "key_discussion": "Demand resilience", + "summary": "LLM summary", + "reason_growth": "Orders remain strong", + "reason_decrease": "", + "raw_json": {"provider": "llm"}, + }, + ) + result = news_enricher.classify_news_row( + { + "title": "Apple expands AI features", + "summary": "New devices and software updates were announced.", + } + ) + assert result["analysis_source"] == "llm" + assert result["sentiment"] == "positive" + assert result["summary"] == "LLM summary" + + +def test_build_analysis_rows_prefers_batch_llm_and_dedupes(monkeypatch): + monkeypatch.setattr(news_enricher, "llm_enrichment_enabled", lambda: True) + monkeypatch.setattr(news_enricher, "get_env_int", lambda key, default=0: 8) + monkeypatch.setattr( + news_enricher, + "analyze_news_rows_with_llm", + lambda rows: { + "news-1": { + "relevance": "high", + "sentiment": "positive", + "key_discussion": "Batch result", + "summary": "Batch summary", + "reason_growth": "Growth", + "reason_decrease": "", + "raw_json": {"provider": "batch"}, + } + }, + ) + monkeypatch.setattr(news_enricher, "analyze_news_row_with_llm", lambda row: None) + rows = news_enricher.build_analysis_rows( + symbol="AAPL", + news_rows=[ + {"id": "news-1", "trade_date": "2026-03-10", "title": "Same title", "summary": "Same summary"}, + {"id": "news-2", "trade_date": "2026-03-10", "title": "Same title", "summary": "Same summary"}, + ], + ohlc_rows=[], + ) + rows, stats = rows + assert len(rows) == 1 + assert rows[0]["analysis_source"] == "llm" + assert rows[0]["summary"] == "Batch summary" + assert stats["deduped_count"] == 1 + assert stats["llm_count"] == 1 + + +def test_enrich_news_for_symbol_skips_existing(monkeypatch): + class DummyStore: + def get_news_items(self, symbol, start_date=None, end_date=None, limit=200): + return [ + {"id": "news-1", "trade_date": "2026-03-10", "title": "One", "summary": "One"}, + {"id": "news-2", "trade_date": "2026-03-11", "title": "Two", "summary": "Two"}, + ] + + def get_analyzed_news_ids(self, symbol, start_date=None, end_date=None): + return {"news-1"} + + def get_ohlc(self, symbol, start_date, end_date): + return [] + + def upsert_news_analysis(self, symbol, rows, analysis_source="local"): + self.rows = rows + return len(rows) + + monkeypatch.setattr( + news_enricher, + "build_analysis_rows", + lambda symbol, news_rows, ohlc_rows: ( + [ + { + "news_id": row["id"], + "trade_date": row["trade_date"], + "summary": row["summary"], + "analysis_source": "local", + } + for row in news_rows + ], + {"deduped_count": 0, "llm_count": 0, "local_count": len(news_rows)}, + ), + ) + store = DummyStore() + result = news_enricher.enrich_news_for_symbol(store, "AAPL") + assert result["news_count"] == 2 + assert result["queued_count"] == 1 + assert result["skipped_existing_count"] == 1 + assert len(store.rows) == 1 + assert store.rows[0]["news_id"] == "news-2" + + +def test_enrich_news_for_symbol_only_reanalyzes_local(monkeypatch): + class DummyStore: + def get_news_items(self, symbol, start_date=None, end_date=None, limit=200): + return [ + {"id": "news-1", "trade_date": "2026-03-10", "title": "One", "summary": "One"}, + {"id": "news-2", "trade_date": "2026-03-11", "title": "Two", "summary": "Two"}, + {"id": "news-3", "trade_date": "2026-03-12", "title": "Three", "summary": "Three"}, + ] + + def get_analyzed_news_sources(self, symbol, start_date=None, end_date=None): + return {"news-1": "local", "news-2": "llm"} + + def get_ohlc(self, symbol, start_date, end_date): + return [] + + def upsert_news_analysis(self, symbol, rows, analysis_source="local"): + self.rows = rows + return len(rows) + + monkeypatch.setattr( + news_enricher, + "build_analysis_rows", + lambda symbol, news_rows, ohlc_rows: ( + [ + { + "news_id": row["id"], + "trade_date": row["trade_date"], + "summary": row["summary"], + "analysis_source": "llm" if row["id"] == "news-1" else "local", + } + for row in news_rows + ], + {"deduped_count": 0, "llm_count": 1, "local_count": 0}, + ), + ) + + store = DummyStore() + result = news_enricher.enrich_news_for_symbol( + store, + "AAPL", + only_reanalyze_local=True, + ) + + assert result["news_count"] == 3 + assert result["queued_count"] == 1 + assert result["skipped_existing_count"] == 2 + assert result["only_reanalyze_local"] is True + assert result["upgraded_local_to_llm_count"] == 1 + assert result["execution_summary"]["upgraded_dates"] == ["2026-03-10"] + assert result["execution_summary"]["remaining_local_titles"] == [] + assert result["execution_summary"]["skipped_missing_analysis_count"] == 1 + assert result["execution_summary"]["skipped_non_local_count"] == 1 + assert [row["news_id"] for row in store.rows] == ["news-1"] diff --git a/backend/tests/test_range_explainer.py b/backend/tests/test_range_explainer.py new file mode 100644 index 0000000..2fc0a2f --- /dev/null +++ b/backend/tests/test_range_explainer.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- + +from types import SimpleNamespace + +from backend.explain import range_explainer + + +def test_build_range_explanation_prefers_llm_text_when_available(monkeypatch): + monkeypatch.setattr( + range_explainer, + "get_prices", + lambda ticker, start_date, end_date: [ + SimpleNamespace(open=100, close=98, high=102, low=97, volume=1000), + SimpleNamespace(open=98, close=96, high=99, low=95, volume=1100), + SimpleNamespace(open=96, close=97, high=98, low=94, volume=1200), + ], + ) + monkeypatch.setattr( + range_explainer, + "analyze_range_with_llm", + lambda payload: { + "summary": "区间内整体偏弱,主题集中在盈利预期和供应链风险。", + "trend_analysis": "前半段快速下探,后半段出现修复。", + "bullish_factors": ["回调后出现承接"], + "bearish_factors": ["盈利预期承压"], + "model_label": "DASHSCOPE:qwen-max", + }, + ) + + result = range_explainer.build_range_explanation( + ticker="AAPL", + start_date="2026-03-10", + end_date="2026-03-16", + news_rows=[ + { + "id": "news-1", + "trade_date": "2026-03-10", + "title": "Apple margin pressure concerns grow", + "summary": "Investors focused on weaker margin outlook.", + "sentiment": "negative", + "relevance": "high", + "ret_t0": -0.02, + "reason_decrease": "盈利预期承压", + "category": "earnings", + } + ], + ) + + assert result["analysis"]["summary"] == "区间内整体偏弱,主题集中在盈利预期和供应链风险。" + assert result["analysis"]["trend_analysis"] == "前半段快速下探,后半段出现修复。" + assert result["analysis"]["bullish_factors"] == ["回调后出现承接"] + assert result["analysis"]["analysis_source"] == "llm" + assert result["analysis"]["analysis_model_label"] == "DASHSCOPE:qwen-max" + assert result["news_count"] == 1 diff --git a/env.template b/env.template index f24f23d..569945e 100644 --- a/env.template +++ b/env.template @@ -13,11 +13,17 @@ FIN_DATA_SOURCE = # Preferred source: finnhub / financial_datasets / yfinance / ENABLED_DATA_SOURCES = # Optional allowlist, comma-separated, e.g. financial_datasets,finnhub,yfinance,local_csv | 可启用数据源列表 FINANCIAL_DATASETS_API_KEY= #required | 必填 FINNHUB_API_KEY= #optional | 可选 +POLYGON_API_KEY= #optional for market warehouse ingest | Polygon长期市场库采集可选 +MARKET_DB_PATH= #optional path for long-lived market_research.db | 长期市场数据库路径可选 # Model API OPENAI_API_KEY= OPENAI_BASE_URL= MODEL_NAME=qwen3-max-preview +EXPLAIN_ENRICH_USE_LLM=false +EXPLAIN_ENRICH_MODEL_PROVIDER= +EXPLAIN_ENRICH_MODEL_NAME= +EXPLAIN_RANGE_USE_LLM= #记忆模块(Embedding and llm calls for Reme memory) # default to use aliyun dashscope url, more details: https://help.aliyun.com/zh/model-studio/what-is-model-studio diff --git a/frontend/package.json b/frontend/package.json index 3efd982..a864bcf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,8 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest", "preview": "vite preview", "preview:host": "vite preview --host" }, @@ -37,6 +39,7 @@ }, "devDependencies": { "@eslint/js": "^9.33.0", + "@testing-library/react": "^16.3.2", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.0", @@ -45,11 +48,13 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "jsdom": "^29.0.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.2", "vite": "^7.1.2", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.1.0" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4951f16..014baa5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"; +import React, { Suspense, lazy, useEffect, useMemo, useRef, useState, useCallback } from "react"; // Configuration and constants import { AGENTS, INITIAL_TICKERS } from './config/constants'; @@ -13,19 +13,40 @@ import { useFeedProcessor } from './hooks/useFeedProcessor'; import GlobalStyles from './styles/GlobalStyles'; // Components -import RoomView from './components/RoomView'; import NetValueChart from './components/NetValueChart'; -import AgentFeed from './components/AgentFeed'; import StockLogo from './components/StockLogo'; -import StatisticsView from './components/StatisticsView'; -import PerformanceView from './components/PerformanceView'; -import StockExplainView from './components/StockExplainView.jsx'; import Header from './components/Header.jsx'; import WatchlistPanel from './components/WatchlistPanel.jsx'; // Utils import { formatNumber, formatTickerPrice } from './utils/formatters'; +const RoomView = lazy(() => import('./components/RoomView')); +const AgentFeed = lazy(() => import('./components/AgentFeed')); +const StatisticsView = lazy(() => import('./components/StatisticsView')); +const StockExplainView = lazy(() => import('./components/StockExplainView.jsx')); + +function ViewLoadingFallback({ label = '加载中...' }) { + return ( +
+ {label} +
+ ); +} + /** * Live Trading Intelligence Platform - Read-Only Dashboard * Geek Style - Terminal-inspired, minimal, monochrome @@ -73,6 +94,7 @@ export default function LiveTradingApp() { const [priceHistoryByTicker, setPriceHistoryByTicker] = useState({}); const [ohlcHistoryByTicker, setOhlcHistoryByTicker] = useState({}); const [explainEventsByTicker, setExplainEventsByTicker] = useState({}); + const [newsByTicker, setNewsByTicker] = useState({}); const [selectedExplainSymbol, setSelectedExplainSymbol] = useState(''); const [historySourceByTicker, setHistorySourceByTicker] = useState({}); @@ -237,35 +259,59 @@ export default function LiveTradingApp() { const raw = typeof marketStatus.status_text === 'string' ? marketStatus.status_text.trim() : ''; const normalized = raw.toLowerCase(); - if (normalized === 'market closed (non-trading day)') { - return '休市'; + const byStatus = { + open: '开盘', + closed: '休市', + premarket: '盘前', + afterhours: '盘后', + }; + const byText = { + 'market closed (non-trading day)': '休市', + 'market open': '开盘', + 'market closed': '收盘', + 'pre-market': '盘前', + 'after-hours': '盘后', + 'after hours': '盘后', + 'backtest mode': '回测模式', + }; + if (normalized && byText[normalized]) { + return byText[normalized]; } - if (normalized === 'market open') { - return '开盘'; + if (marketStatus.status && byStatus[marketStatus.status]) { + return byStatus[marketStatus.status]; } - if (normalized === 'market closed') { - return '收盘'; - } - - return raw || (marketStatus.status === 'open' ? '开盘' : '收盘'); + return raw || '状态未知'; }, [marketStatus]); - const priceSourceLabel = useMemo(() => { + const providerLabelMap = useMemo(() => ({ + yfinance: 'YFinance', + finnhub: 'Finnhub', + financial_datasets: 'Financial Datasets', + local_csv: 'CSV', + polygon: 'Polygon', + mock: 'Mock', + backtest: 'Backtest' + }), []); + + const livePriceSourceLabel = useMemo(() => { + const source = marketStatus?.live_quote_provider; + if (!source) { + return null; + } + + const normalized = String(source).trim().toLowerCase(); + return `实时 ${providerLabelMap[normalized] || String(source).trim()}`; + }, [marketStatus, providerLabelMap]); + + const historicalPriceSourceLabel = useMemo(() => { const source = dataSources?.last_success?.prices; if (!source) { return null; } const normalized = String(source).trim().toLowerCase(); - const labels = { - yfinance: '数据源 Yahoo', - finnhub: '数据源 Finnhub', - financial_datasets: '数据源 Financial Datasets', - local_csv: '数据源 CSV' - }; - - return labels[normalized] || `数据源 ${String(source).trim()}`; - }, [dataSources]); + return `历史 ${providerLabelMap[normalized] || String(source).trim()}`; + }, [dataSources, providerLabelMap]); const parseWatchlistInput = useCallback((value) => { if (typeof value !== 'string') { @@ -413,6 +459,131 @@ export default function LiveTradingApp() { }); }, []); + const requestStockNews = useCallback((symbol) => { + const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; + if (!normalized || !clientRef.current) { + return false; + } + return clientRef.current.send({ + type: 'get_stock_news', + ticker: normalized, + lookback_days: 45, + limit: 12 + }); + }, []); + + const requestStockNewsForDate = useCallback((symbol, date) => { + const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; + if (!normalized || !date || !clientRef.current) { + return false; + } + return clientRef.current.send({ + type: 'get_stock_news_for_date', + ticker: normalized, + date, + limit: 20 + }); + }, []); + + const requestStockNewsTimeline = useCallback((symbol) => { + const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; + if (!normalized || !clientRef.current) { + return false; + } + return clientRef.current.send({ + type: 'get_stock_news_timeline', + ticker: normalized, + lookback_days: 90 + }); + }, []); + + const requestStockNewsCategories = useCallback((symbol) => { + const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; + if (!normalized || !clientRef.current) { + return false; + } + return clientRef.current.send({ + type: 'get_stock_news_categories', + ticker: normalized, + lookback_days: 90 + }); + }, []); + + const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => { + const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; + if (!normalized || !startDate || !endDate || !clientRef.current) { + return false; + } + return clientRef.current.send({ + type: 'get_stock_range_explain', + ticker: normalized, + start_date: startDate, + end_date: endDate, + article_ids: Array.isArray(articleIds) ? articleIds : [] + }); + }, []); + + const requestStockStory = useCallback((symbol, asOfDate = null) => { + const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; + if (!normalized || !clientRef.current) { + return false; + } + return clientRef.current.send({ + type: 'get_stock_story', + ticker: normalized, + as_of_date: asOfDate + }); + }, []); + + const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => { + const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; + if (!normalized || !date || !clientRef.current) { + return false; + } + return clientRef.current.send({ + type: 'get_stock_similar_days', + ticker: normalized, + date, + top_k: topK + }); + }, []); + + const requestStockEnrich = useCallback((symbol, options = {}) => { + const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; + if (!normalized || !clientRef.current) { + return false; + } + const startDate = typeof options.startDate === 'string' ? options.startDate.trim() : ''; + const endDate = typeof options.endDate === 'string' ? options.endDate.trim() : ''; + if (!startDate || !endDate) { + return false; + } + setNewsByTicker((prev) => ({ + ...prev, + [normalized]: { + ...(prev[normalized] || {}), + maintenanceStatus: { + running: true, + error: null, + updatedAt: new Date().toISOString(), + stats: null + } + } + })); + return clientRef.current.send({ + type: 'run_stock_enrich', + ticker: normalized, + start_date: startDate, + end_date: endDate, + force: Boolean(options.force), + only_local_to_llm: Boolean(options.onlyLocalToLlm), + rebuild_story: Boolean(options.rebuildStory), + rebuild_similar_days: Boolean(options.rebuildSimilarDays), + story_date: options.storyDate || null, + target_date: options.targetDate || null + }); + }, []); + // Switch away from LIVE tab when market closes useEffect(() => { if (!isLiveEnabled && chartTab === 'live') { @@ -439,7 +610,21 @@ export default function LiveTradingApp() { } requestStockHistory(selectedExplainSymbol); requestStockExplainEvents(selectedExplainSymbol); - }, [currentView, requestStockExplainEvents, requestStockHistory, selectedExplainSymbol]); + requestStockNews(selectedExplainSymbol); + requestStockNewsTimeline(selectedExplainSymbol); + requestStockNewsCategories(selectedExplainSymbol); + requestStockStory(selectedExplainSymbol, currentDate); + }, [ + currentDate, + currentView, + requestStockExplainEvents, + requestStockHistory, + requestStockNews, + requestStockNewsCategories, + requestStockNewsTimeline, + requestStockStory, + selectedExplainSymbol + ]); // Clock - use virtual time if available (for mock mode) useEffect(() => { @@ -802,6 +987,193 @@ export default function LiveTradingApp() { })); }, + stock_news_loaded: (e) => { + const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; + if (!symbol) { + return; + } + setNewsByTicker((prev) => ({ + ...prev, + [symbol]: { + ...(prev[symbol] || {}), + items: Array.isArray(e.news) ? e.news : [], + source: e.source || null, + startDate: e.start_date || null, + endDate: e.end_date || null + } + })); + requestStockNewsTimeline(symbol); + }, + + stock_news_for_date_loaded: (e) => { + const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; + const date = typeof e.date === 'string' ? e.date.trim() : ''; + if (!symbol || !date) { + return; + } + setNewsByTicker((prev) => ({ + ...prev, + [symbol]: { + ...(prev[symbol] || {}), + byDate: { + ...((prev[symbol] && prev[symbol].byDate) || {}), + [date]: Array.isArray(e.news) ? e.news : [] + } + } + })); + }, + + stock_news_timeline_loaded: (e) => { + const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; + if (!symbol) { + return; + } + setNewsByTicker((prev) => ({ + ...prev, + [symbol]: { + ...(prev[symbol] || {}), + timeline: Array.isArray(e.timeline) ? e.timeline : [], + timelineStartDate: e.start_date || null, + timelineEndDate: e.end_date || null + } + })); + }, + + stock_news_categories_loaded: (e) => { + const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; + if (!symbol) { + return; + } + setNewsByTicker((prev) => ({ + ...prev, + [symbol]: { + ...(prev[symbol] || {}), + categories: e.categories || {}, + categoriesStartDate: e.start_date || null, + categoriesEndDate: e.end_date || null + } + })); + }, + + stock_range_explain_loaded: (e) => { + const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; + if (!symbol) { + return; + } + const result = e.result && typeof e.result === 'object' ? e.result : null; + if (!result?.start_date || !result?.end_date) { + return; + } + const cacheKey = `${result.start_date}:${result.end_date}`; + setNewsByTicker((prev) => ({ + ...prev, + [symbol]: { + ...(prev[symbol] || {}), + rangeExplainCache: { + ...((prev[symbol] && prev[symbol].rangeExplainCache) || {}), + [cacheKey]: result + } + } + })); + }, + + stock_story_loaded: (e) => { + const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; + const asOfDate = typeof e.as_of_date === 'string' ? e.as_of_date.trim() : ''; + if (!symbol || !asOfDate) { + return; + } + setNewsByTicker((prev) => ({ + ...prev, + [symbol]: { + ...(prev[symbol] || {}), + storyCache: { + ...((prev[symbol] && prev[symbol].storyCache) || {}), + [asOfDate]: { + story: e.story || '', + source: e.source || null, + asOfDate + } + } + } + })); + }, + + stock_similar_days_loaded: (e) => { + const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; + const date = typeof e.target_date === 'string' + ? e.target_date.trim() + : typeof e.date === 'string' + ? e.date.trim() + : ''; + if (!symbol || !date) { + return; + } + setNewsByTicker((prev) => ({ + ...prev, + [symbol]: { + ...(prev[symbol] || {}), + similarDaysCache: { + ...((prev[symbol] && prev[symbol].similarDaysCache) || {}), + [date]: { + target_features: e.target_features || {}, + items: Array.isArray(e.items) ? e.items : [], + error: e.error || null + } + } + } + })); + }, + + stock_enrich_completed: (e) => { + const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; + if (!symbol) { + return; + } + const completedAt = new Date().toISOString(); + const historyEntry = { + timestamp: completedAt, + startDate: e.start_date || '', + endDate: e.end_date || '', + force: Boolean(e.force), + onlyLocalToLlm: Boolean(e.only_local_to_llm), + error: e.error || null, + stats: e.stats || null, + storyStatus: e.story_status || null, + similarStatus: e.similar_status || null + }; + setNewsByTicker((prev) => ({ + ...prev, + [symbol]: { + ...(prev[symbol] || {}), + items: [], + byDate: {}, + timeline: [], + categories: {}, + rangeExplainCache: {}, + storyCache: {}, + similarDaysCache: {}, + maintenanceStatus: { + running: false, + error: e.error || null, + updatedAt: completedAt, + stats: e.stats || null, + storyStatus: e.story_status || null, + similarStatus: e.similar_status || null + }, + maintenanceHistory: [ + historyEntry, + ...(((prev[symbol] && prev[symbol].maintenanceHistory) || []).slice(0, 7)) + ] + } + })); + if (!e.error) { + requestStockNews(symbol); + requestStockNewsTimeline(symbol); + requestStockNewsCategories(symbol); + } + }, + // Real-time price updates price_update: (e) => { try { @@ -1146,7 +1518,14 @@ export default function LiveTradingApp() { clientRef.current.disconnect(); } }; - }, [addSystemMessage, buildTickersFromSymbols, processFeedEvent, processHistoricalFeed]); // Only reconnect if handlers change + }, [ + addSystemMessage, + buildTickersFromSymbols, + processFeedEvent, + processHistoricalFeed, + requestStockNewsCategories, + requestStockNewsTimeline + ]); // Only reconnect if handlers change // Resizing handlers const handleMouseDown = (e) => { @@ -1318,16 +1697,24 @@ export default function LiveTradingApp() { )} - {priceSourceLabel && ( + {livePriceSourceLabel && ( <> · - {priceSourceLabel} + {livePriceSourceLabel} + + + )} + {historicalPriceSourceLabel && ( + <> + · + + {historicalPriceSourceLabel} )} · - {lastUpdate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} + {now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} setCurrentView('explain')} > - 个股解释 + 个股分析 - ); - })} - - -
- {Object.entries(EVENT_CATEGORY_META) - .filter(([key]) => (eventCategoryCounts[key] || 0) > 0 || key === 'all') - .map(([key, meta]) => { - const isActive = key === activeEventCategory; - return ( - - ); - })} -
- - {visibleExplainEvents.length === 0 ? ( -
当前日期下没有符合筛选条件的事件
- ) : ( -
- {visibleExplainEvents.map((event) => { - const accent = event.tone === 'positive' ? '#00C853' : event.tone === 'negative' ? '#FF1744' : '#000000'; - const categoryMeta = EVENT_CATEGORY_META[event.category] || EVENT_CATEGORY_META.other; - return ( -
-
-
- - {categoryMeta.label} - - {event.title} -
- - {formatDateTime(event.timestamp)} - -
-
- - - {event.meta} - -
-
- {event.body} -
-
- ); - })} -
- )} - - )} - - -
-
-

最近提及

-
- 从交易讨论和分析 feed 提取 -
-
- - {recentMentions.length === 0 ? ( -
最近没有在讨论中提到该股票
- ) : ( -
- {recentMentions.map((message, index) => ( -
-
-
-
{message.agent || '未知角色'}
-
- {message.conferenceTitle || (message.feedType === 'conference' ? '投资讨论' : '即时消息')} -
-
-
- {formatDateTime(message.timestamp)} -
-
-
- {String(message.content || '')} -
-
- ))} -
- )} -
- -
-
-

成交记录

-
- {tickerTrades.length} 笔与 {selectedSymbol} 相关的交易 -
-
- - {tickerTrades.length === 0 ? ( -
该股票暂无成交记录
- ) : ( -
- - - - - - - - - - - {tickerTrades.slice(0, 10).map((trade, index) => ( - - - - - - - ))} - -
时间方向数量价格
{formatDateTime(trade.timestamp)} - {trade.side} - {trade.qty}${Number(trade.price).toFixed(2)}
-
- )} -
+ setForceEnrich((prev) => !prev)} + onlyLocalToLlm={onlyLocalToLlm} + onToggleOnlyLocalToLlm={() => setOnlyLocalToLlm((prev) => !prev)} + rebuildStory={rebuildStory} + onToggleRebuildStory={() => setRebuildStory((prev) => !prev)} + rebuildSimilarDays={rebuildSimilarDays} + onToggleRebuildSimilarDays={() => setRebuildSimilarDays((prev) => !prev)} + isRunning={Boolean(newsSnapshot?.maintenanceStatus?.running)} + onRunEnrich={handleRunStockEnrich} + maintenanceStatus={newsSnapshot?.maintenanceStatus || null} + maintenanceHistory={newsSnapshot?.maintenanceHistory || []} + onSelectHistory={handleSelectHistory} + onReplayHistory={handleReplayHistory} + isOpen={isMaintenanceOpen} + onToggle={() => setIsMaintenanceOpen((prev) => !prev)} + /> )} diff --git a/frontend/src/components/WatchlistPanel.jsx b/frontend/src/components/WatchlistPanel.jsx index d1ae366..071b664 100644 --- a/frontend/src/components/WatchlistPanel.jsx +++ b/frontend/src/components/WatchlistPanel.jsx @@ -20,7 +20,7 @@ export default function WatchlistPanel({ onSave }) { return ( -
+
{isOpen && ( diff --git a/frontend/src/components/explain/ExplainEventsSection.jsx b/frontend/src/components/explain/ExplainEventsSection.jsx new file mode 100644 index 0000000..30b3d04 --- /dev/null +++ b/frontend/src/components/explain/ExplainEventsSection.jsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { formatDateTime } from '../../utils/formatters'; + +export default function ExplainEventsSection({ + explainTimeline, + isOpen, + onToggle, + availableEventDates, + selectedEventDate, + onSelectEventDate, + eventCategoryCounts, + activeEventCategory, + onSelectEventCategory, + eventCategoryMeta, + visibleExplainEvents, +}) { + return ( +
+
+

关键事件时间线

+
+
+ 图上点击事件点可切换对应日期 +
+ +
+
+ + {explainTimeline.length === 0 ? ( +
当前还没有可以串起来看的关键事件。
+ ) : !isOpen ? ( +
关键事件默认收起,需要时再展开查看和筛选。
+ ) : ( +
+
+ {availableEventDates.map((dateKey) => { + const isActive = dateKey === selectedEventDate; + return ( + + ); + })} +
+ +
+ {Object.entries(eventCategoryMeta) + .filter(([key]) => (eventCategoryCounts[key] || 0) > 0 || key === 'all') + .map(([key, meta]) => { + const isActive = key === activeEventCategory; + return ( + + ); + })} +
+ + {visibleExplainEvents.length === 0 ? ( +
当前日期下没有符合筛选条件的事件
+ ) : ( +
+ {visibleExplainEvents.map((event) => { + const accent = event.tone === 'positive' ? '#00C853' : event.tone === 'negative' ? '#FF1744' : '#000000'; + const categoryMeta = eventCategoryMeta[event.category] || eventCategoryMeta.other; + return ( +
+
+
+ + {categoryMeta.label} + + {event.title} +
+ + {formatDateTime(event.timestamp)} + +
+
+ + + {event.meta} + +
+
+ {event.body} +
+
+ ); + })} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/explain/ExplainMaintenanceSection.jsx b/frontend/src/components/explain/ExplainMaintenanceSection.jsx new file mode 100644 index 0000000..ffe5ece --- /dev/null +++ b/frontend/src/components/explain/ExplainMaintenanceSection.jsx @@ -0,0 +1,249 @@ +import React from 'react'; + +function toggleButtonStyle(active, accent = '#111111') { + return { + border: `1px solid ${accent}`, + background: active ? accent : '#ffffff', + color: active ? '#ffffff' : accent, + padding: '6px 10px', + fontFamily: 'inherit', + fontSize: 11, + fontWeight: 700, + cursor: 'pointer' + }; +} + +export default function ExplainMaintenanceSection({ + selectedSymbol, + enrichStartDate, + enrichEndDate, + onChangeStartDate, + onChangeEndDate, + forceEnrich, + onToggleForce, + onlyLocalToLlm, + onToggleOnlyLocalToLlm, + rebuildStory, + onToggleRebuildStory, + rebuildSimilarDays, + onToggleRebuildSimilarDays, + isRunning, + onRunEnrich, + maintenanceStatus, + maintenanceHistory, + onSelectHistory, + onReplayHistory, + isOpen, + onToggle, +}) { + const stats = maintenanceStatus?.stats || null; + const summary = stats?.execution_summary || null; + + return ( +
+
+

分析数据维护

+
+
+ 当前标的 {selectedSymbol || '-'} +
+ +
+
+ + {!isOpen ? ( +
刷新工具默认收起,需要时再展开重新分析数据或查看历史。
+ ) : ( +
+
+ + +
+ +
+ + + + +
+ +
+ + {maintenanceStatus?.updatedAt ? ( + + 最近一次执行: {maintenanceStatus.updatedAt} + + ) : null} +
+ + {maintenanceStatus?.error ? ( +
+ 执行失败: {maintenanceStatus.error} +
+ ) : null} + + {stats ? ( + <> +
+ {[ + ['新闻总数', stats.news_count], + ['待处理', stats.queued_count], + ['已分析', stats.analyzed], + ['已跳过', stats.skipped_existing_count], + ['去重数', stats.deduped_count], + ['LLM分析', stats.llm_count], + ['规则分析', stats.local_count], + ['升级数', stats.upgraded_local_to_llm_count], + ].map(([label, value]) => ( +
+
{label}
+
{value ?? '-'}
+
+ ))} +
+ + {summary ? ( +
+ {summary.upgraded_dates?.length ? ( +
升级日期: {summary.upgraded_dates.join(', ')}
+ ) : null} + {summary.remaining_local_titles?.length ? ( +
仍为规则分析: {summary.remaining_local_titles.join(' / ')}
+ ) : null} + {typeof summary.skipped_non_local_count === 'number' ? ( +
跳过非规则分析: {summary.skipped_non_local_count}
+ ) : null} + {typeof summary.skipped_missing_analysis_count === 'number' ? ( +
跳过无历史分析: {summary.skipped_missing_analysis_count}
+ ) : null} +
+ ) : null} + + ) : null} + + {Array.isArray(maintenanceHistory) && maintenanceHistory.length > 0 ? ( +
+
最近刷新历史
+ {maintenanceHistory.slice(0, 5).map((item, index) => ( +
+
+ {item.startDate || '-'} ~ {item.endDate || '-'} + {' · '} + {item.onlyLocalToLlm ? '规则分析→LLM分析' : item.force ? '覆盖重跑' : '补缺失'} + {item.storyStatus ? ' · 主线叙事' : ''} + {item.similarStatus ? ' · 相似交易日' : ''} +
+
+ {item.timestamp || '-'} + {item.error + ? ` · 失败: ${item.error}` + : ` · 已分析 ${item.stats?.analyzed ?? 0},已升级 ${item.stats?.upgraded_local_to_llm_count ?? 0}`} +
+
+ + + {!item.error ? ( + + {item.stats?.execution_summary?.upgraded_dates?.length + ? `升级日 ${item.stats.execution_summary.upgraded_dates.join(', ')}` + : '无升级日期摘要'} + + ) : null} +
+
+ ))} +
+ ) : null} +
+ )} +
+ ); +} diff --git a/frontend/src/components/explain/ExplainMentionsSection.jsx b/frontend/src/components/explain/ExplainMentionsSection.jsx new file mode 100644 index 0000000..0c1ac78 --- /dev/null +++ b/frontend/src/components/explain/ExplainMentionsSection.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { formatDateTime } from '../../utils/formatters'; + +export default function ExplainMentionsSection({ + recentMentions, + isOpen, + onToggle, +}) { + return ( +
+
+

讨论提及

+
+
+ 从交易讨论和分析 feed 提取 +
+ +
+
+ + {recentMentions.length === 0 ? ( +
最近没有在讨论里提到这只股票。
+ ) : !isOpen ? ( +
讨论摘录默认收起,需要时再展开查看。
+ ) : ( +
+ {recentMentions.map((message, index) => ( +
+
+
+
{message.agent || '未知角色'}
+
+ {message.conferenceTitle || (message.feedType === 'conference' ? '投资讨论' : '即时消息')} +
+
+
+ {formatDateTime(message.timestamp)} +
+
+
+ {String(message.content || '')} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/explain/ExplainNewsSection.jsx b/frontend/src/components/explain/ExplainNewsSection.jsx new file mode 100644 index 0000000..75f0e7f --- /dev/null +++ b/frontend/src/components/explain/ExplainNewsSection.jsx @@ -0,0 +1,308 @@ +import React from 'react'; +import { formatDateTime } from '../../utils/formatters'; + +function categoryLabel(value) { + const normalized = String(value || '').trim().toLowerCase(); + const labels = { + market: '市场交易', + policy: '政策监管', + earnings: '业绩财报', + product_tech: '产品技术', + competition: '竞争格局', + management: '管理层动态', + }; + return labels[normalized] || value || ''; +} + +function relevanceLabel(value) { + const normalized = String(value || '').trim().toLowerCase(); + const labels = { + high: '高相关', + medium: '中相关', + low: '低相关', + relevant: '高相关', + }; + return labels[normalized] || value || ''; +} + +function analysisSourceLabel(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'llm') return 'LLM分析'; + if (normalized === 'local') return '规则分析'; + return value || ''; +} + +function sentimentStyle(sentiment) { + const normalized = String(sentiment || '').trim().toLowerCase(); + if (normalized === 'positive') { + return { border: '#16a34a', background: '#f0fdf4', color: '#166534', label: '利多' }; + } + if (normalized === 'negative') { + return { border: '#dc2626', background: '#fef2f2', color: '#991b1b', label: '利空' }; + } + return { border: '#6b7280', background: '#f9fafb', color: '#4b5563', label: '中性' }; +} + +export default function ExplainNewsSection({ + newsSnapshot, + visibleNewsByCategory, + visibleNews, + activeNewsCategory, + onSelectNewsCategory, + activeNewsSentiment, + onSelectNewsSentiment, + newsCategories, + tickerNews, + isOpen, + onToggle, +}) { + return ( +
+
+

新闻面板

+
+
+ {newsSnapshot?.source ? `最近 ${visibleNewsByCategory.length} 条 · ${newsSnapshot.source}` : `最近 ${visibleNewsByCategory.length} 条真实新闻`} +
+ +
+
+ + {!isOpen ? ( +
新闻面板已收起,需要时再展开查看分类、情绪和新闻卡片。
+ ) : ( + <> +
+ + {Object.entries(newsCategories) + .filter(([, meta]) => Number(meta?.count || 0) > 0) + .map(([key, meta]) => { + const isActive = activeNewsCategory === key; + const pos = Number(meta?.positive_ids?.length || 0); + const neg = Number(meta?.negative_ids?.length || 0); + return ( + + ); + })} +
+ +
+ {[ + { key: 'all', label: '全部情绪' }, + { key: 'positive', label: '利多' }, + { key: 'negative', label: '利空' }, + { key: 'neutral', label: '中性' } + ].map((item) => { + const isActive = activeNewsSentiment === item.key; + return ( + + ); + })} +
+ + {tickerNews.length === 0 ? ( +
当前数据源没有返回相关新闻
+ ) : ( +
+ {visibleNewsByCategory.map((item) => ( +
+ {(() => { + const sentimentMeta = sentimentStyle(item.sentiment); + return ( +
+ + {sentimentMeta.label} + + {item.relevance ? ( + + {relevanceLabel(item.relevance)} + + ) : null} + {item.analysisSource ? ( + + {analysisSourceLabel(item.analysisSource)} + + ) : null} + {item.analysisModelLabel ? ( + + {item.analysisModelLabel} + + ) : null} + {typeof item.retT0 === 'number' ? ( + = 0 ? '#15803d' : '#b91c1c', fontWeight: 700 }}> + T0 {item.retT0 >= 0 ? '+' : ''}{(item.retT0 * 100).toFixed(2)}% + + ) : null} +
+ ); + })()} +
+
+ {item.category ? ( + + {categoryLabel(item.category)} + + ) : null} + {item.title} +
+ + {formatDateTime(item.date)} + +
+ +
+ + {item.source} + + {item.related ? ( + + 关联: {item.related} + + ) : null} +
+ +
+ {item.summary || '该新闻没有可用摘要。'} +
+ + {item.keyDiscussion ? ( +
+ 核心讨论: {item.keyDiscussion} +
+ ) : null} + + {item.reasonGrowth ? ( +
+ 利多逻辑: {item.reasonGrowth} +
+ ) : null} + + {item.reasonDecrease ? ( +
+ 利空逻辑: {item.reasonDecrease} +
+ ) : null} + + {item.url ? ( + + ) : null} +
+ ))} +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/explain/ExplainPriceSection.jsx b/frontend/src/components/explain/ExplainPriceSection.jsx new file mode 100644 index 0000000..3e50d86 --- /dev/null +++ b/frontend/src/components/explain/ExplainPriceSection.jsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { formatTickerPrice } from '../../utils/formatters'; + +export default function ExplainPriceSection({ + ohlcSeries, + priceSeries, + selectedHistorySource, + chartModel, + selectedTicker, + onSelectEventDate, + isOpen, + onToggle, +}) { + return ( +
+
+

价格与事件

+
+
+ {ohlcSeries.length > 1 + ? `最近 ${ohlcSeries.length} 根日线K线${selectedHistorySource ? ` · ${selectedHistorySource}` : ''}` + : `最近 ${priceSeries.length} 个价格点聚合为 ${chartModel.bucketCount || 0} 根简化K线`} +
+ +
+
+ + {ohlcSeries.length === 0 && priceSeries.length === 0 ? ( +
当前还没有可绘制的价格历史
+ ) : !isOpen ? ( +
价格区已收起,需要时再展开查看图表和事件点。
+ ) : ( +
+ + + + + + + + + + + + {chartModel.candles.length > 1 ? chartModel.candles.map((candle) => { + const rising = candle.close >= candle.open; + const stroke = rising ? '#00C853' : '#FF1744'; + const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)'; + return ( + + + + + ); + }) : chartModel.path && ( + <> + + + + )} + + {chartModel.markers.map((marker) => { + const fill = marker.tone === 'positive' + ? '#00C853' + : marker.tone === 'negative' + ? '#FF1744' + : marker.tone === 'news' + ? '#2563eb' + : '#000000'; + return ( + onSelectEventDate(marker.dateKey)} + style={{ cursor: 'pointer' }} + > + + + {`${marker.title} · ${marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`} + + ); + })} + + + {chartModel.maxPrice != null ? `高点 $${formatTickerPrice(chartModel.maxPrice)}` : ''} + + + {chartModel.minPrice != null ? `低点 $${formatTickerPrice(chartModel.minPrice)}` : ''} + + + {selectedTicker?.price != null ? `现价 $${formatTickerPrice(selectedTicker.price)}` : ''} + + + +
+
+ 图表说明:{ohlcSeries.length > 1 ? '历史日线K线' : '基于盘中价格点聚合的简化K线'} +
+
蓝点:新闻日期
+
黑点:讨论提及
+
绿点:偏多信号或做多成交
+
红点:偏空信号或做空成交
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/explain/ExplainRangeSection.jsx b/frontend/src/components/explain/ExplainRangeSection.jsx new file mode 100644 index 0000000..93b4cdc --- /dev/null +++ b/frontend/src/components/explain/ExplainRangeSection.jsx @@ -0,0 +1,220 @@ +import React from 'react'; +import { formatTickerPrice } from '../../utils/formatters'; + +function renderSentimentLabel(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'positive') return '利多'; + if (normalized === 'negative') return '利空'; + if (normalized === 'neutral') return '中性'; + return value || ''; +} + +function renderCategoryLabel(value) { + const normalized = String(value || '').trim().toLowerCase(); + const labels = { + market: '市场交易', + policy: '政策监管', + earnings: '业绩财报', + product_tech: '产品技术', + competition: '竞争格局', + management: '管理层动态', + }; + return labels[normalized] || value || ''; +} + +function renderAnalysisSourceLabel(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'llm') return 'LLM分析'; + if (normalized === 'local') return '规则分析'; + return value || ''; +} + +function MetricRow({ label, value, valueColor = '#111111' }) { + return ( +
+ {label} + {value} +
+ ); +} + +function TagList({ items, tone = 'neutral', emptyText }) { + const palette = { + positive: { border: '#86efac', background: '#f0fdf4', color: '#166534' }, + negative: { border: '#fca5a5', background: '#fef2f2', color: '#991b1b' }, + neutral: { border: '#d1d5db', background: '#f9fafb', color: '#374151' }, + }; + const colors = palette[tone] || palette.neutral; + + if (!Array.isArray(items) || items.length === 0) { + return
{emptyText}
; + } + + return ( +
+ {items.map((item, index) => ( +
+ {item} +
+ ))} +
+ ); +} + +export default function ExplainRangeSection({ + selectedRangeWindow, + selectedRangeExplain, + isOpen, + onToggle, +}) { + return ( +
+
+

区间涨跌分析

+
+
+ {selectedRangeWindow + ? `${selectedRangeWindow.startDate} ~ ${selectedRangeWindow.endDate}` + : '先在图上选择一个事件日期'} +
+ {selectedRangeExplain?.analysis?.analysis_source ? ( +
+ {selectedRangeExplain.analysis.analysis_source === 'llm' + ? `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)} · ${selectedRangeExplain.analysis.analysis_model_label || 'LLM'}` + : `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)}`} +
+ ) : null} + +
+
+ + {!selectedRangeWindow ? ( +
选择图上的日期后,会自动生成最近 7 天的区间涨跌分析。
+ ) : !isOpen ? ( +
区间涨跌分析已收起,需要时再展开查看摘要和快照。
+ ) : !selectedRangeExplain ? ( +
正在生成区间涨跌分析...
+ ) : selectedRangeExplain.error ? ( +
{selectedRangeExplain.error}
+ ) : ( +
+
+
+ 区间摘要 +
+
+ {selectedRangeExplain.analysis?.summary || '暂无区间摘要'} +
+ {selectedRangeExplain.analysis?.trend_analysis ? ( +
+ 趋势拆解: {selectedRangeExplain.analysis.trend_analysis} +
+ ) : null} +
+ {(selectedRangeExplain.analysis?.key_events || []).slice(0, 6).map((event, index) => ( +
+
+ {event.date || '-'} {event.category ? `· ${renderCategoryLabel(event.category)}` : ''} {event.sentiment ? `· ${renderSentimentLabel(event.sentiment)}` : ''} +
+
{event.title}
+
{event.summary || '暂无摘要'}
+
+ ))} +
+
+ +
+
+ 区间快照 +
+
+
+
事实概览
+ = 0 ? '+' : ''}${Number(selectedRangeExplain.price_change_pct || 0).toFixed(2)}%`} + valueColor={Number(selectedRangeExplain.price_change_pct) >= 0 ? '#00C853' : '#FF1744'} + /> + + + + +
+ +
+
主题分布
+ {(selectedRangeExplain.dominant_categories || []).length > 0 ? ( +
+ {selectedRangeExplain.dominant_categories.map((item) => ( +
+ {renderCategoryLabel(item.category)} · {item.count} +
+ ))} +
+ ) : ( +
+ 当前没有识别出明显的主题聚类。 +
+ )} +
+ +
+
驱动因素
+
+
利多因素
+ +
+
+
利空因素
+ +
+
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/explain/ExplainSignalsSection.jsx b/frontend/src/components/explain/ExplainSignalsSection.jsx new file mode 100644 index 0000000..7d2f083 --- /dev/null +++ b/frontend/src/components/explain/ExplainSignalsSection.jsx @@ -0,0 +1,123 @@ +import React from 'react'; + +export default function ExplainSignalsSection({ + tickerSignals, + signalSummary, + latestSignal, + eventDateKey, + isOpen, + onToggle, +}) { + return ( +
+
+

分析师观点

+
+
+ 最近 {tickerSignals.length} 条相关信号 +
+ +
+
+ + {!isOpen ? ( +
分析师观点已收起,需要时再展开查看信号统计和明细。
+ ) : ( + <> +
+
+
看涨
+
{signalSummary.bullish}
+
+
+
看跌
+
{signalSummary.bearish}
+
+
+
中性
+
{signalSummary.neutral}
+
+
+
最新结论
+
+ {latestSignal + ? latestSignal.normalizedDirection === 'bullish' + ? '偏多' + : latestSignal.normalizedDirection === 'bearish' + ? '偏空' + : '观望' + : '暂无'} +
+
+ {latestSignal ? `${latestSignal.agentName} · ${latestSignal.date || eventDateKey(latestSignal.timestamp)}` : '还没有历史信号'} +
+
+
+ + {tickerSignals.length === 0 ? ( +
该股票还没有分析师信号记录
+ ) : ( +
+ + + + + + + + + + + + {tickerSignals.slice(0, 8).map((signal, index) => { + const realReturn = typeof signal.real_return === 'number' + ? `${signal.real_return >= 0 ? '+' : ''}${(signal.real_return * 100).toFixed(2)}%` + : '未判定'; + const status = signal.is_correct === true ? '命中' : signal.is_correct === false ? '未命中' : '待判定'; + const directionText = signal.normalizedDirection === 'bullish' + ? '看涨' + : signal.normalizedDirection === 'bearish' + ? '看跌' + : '中性'; + const directionColor = signal.normalizedDirection === 'bullish' + ? '#00C853' + : signal.normalizedDirection === 'bearish' + ? '#FF1744' + : '#666666'; + + return ( + + + + + + + + ); + })} + +
日期分析师方向实际收益结果
{signal.date || eventDateKey(signal.timestamp) || '-'} +
{signal.agentName}
+
{signal.role}
+
{directionText}{realReturn}{status}
+
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/explain/ExplainSimilarDaysSection.jsx b/frontend/src/components/explain/ExplainSimilarDaysSection.jsx new file mode 100644 index 0000000..4e517ff --- /dev/null +++ b/frontend/src/components/explain/ExplainSimilarDaysSection.jsx @@ -0,0 +1,111 @@ +import React from 'react'; + +export default function ExplainSimilarDaysSection({ + selectedSimilarDays, + selectedEventDate, + onSelectSimilarDate, + isOpen, + onToggle, +}) { + return ( +
+
+

历史相似交易日

+
+
+ {selectedEventDate || '先选择一个事件日期'} +
+ +
+
+ + {!selectedEventDate ? ( +
选择图上的日期后,会检索这只股票历史上的相似交易日。
+ ) : !isOpen ? ( +
相似交易日默认收起,需要时再展开查看。
+ ) : !selectedSimilarDays ? ( +
正在检索相似交易日...
+ ) : selectedSimilarDays.error ? ( +
{selectedSimilarDays.error}
+ ) : !Array.isArray(selectedSimilarDays.items) || selectedSimilarDays.items.length === 0 ? ( +
当前没有足够历史样本来计算相似交易日。
+ ) : ( +
+
+
+ 目标日快照 +
+
+
+
新闻数量
+ {selectedSimilarDays.target_features?.n_articles ?? 0} +
+
+
情绪分数
+ {Number(selectedSimilarDays.target_features?.sentiment_score ?? 0).toFixed(2)} +
+
+
前一日涨跌
+ {Number(selectedSimilarDays.target_features?.ret_1d ?? 0).toFixed(2)}% +
+
+
高相关新闻
+ {selectedSimilarDays.target_features?.high_relevance_count ?? 0} +
+
+
+ +
+ {selectedSimilarDays.items.map((item) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/explain/ExplainStorySection.jsx b/frontend/src/components/explain/ExplainStorySection.jsx new file mode 100644 index 0000000..1b9513c --- /dev/null +++ b/frontend/src/components/explain/ExplainStorySection.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +export default function ExplainStorySection({ + selectedStory, + selectedSymbol, + currentDate, + isOpen, + onToggle, +}) { + return ( +
+
+

主线叙事

+
+
+ {selectedStory?.asOfDate || currentDate || '按当前解释窗口生成'} +
+ +
+
+ + {!selectedSymbol ? ( +
先选择一只股票
+ ) : !isOpen ? ( +
主线叙事默认收起,需要时再展开查看完整叙事。
+ ) : !selectedStory?.story ? ( +
正在生成主线叙事...
+ ) : ( +
+
+ {selectedStory?.source ? `来源 · ${selectedStory.source}` : '自动生成'} +
+
+ + {selectedStory.story} + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/explain/ExplainSummarySection.jsx b/frontend/src/components/explain/ExplainSummarySection.jsx new file mode 100644 index 0000000..7b6b721 --- /dev/null +++ b/frontend/src/components/explain/ExplainSummarySection.jsx @@ -0,0 +1,87 @@ +import React from 'react'; + +export default function ExplainSummarySection({ + explainSummary, + tickerSignals, + recentMentions, + tickerTrades, + tickerNews, + selectedSymbol, + isOpen, + onToggle, +}) { + return ( +
+
+

分析摘要

+
+
+ 基于当前持仓、信号和讨论自动汇总 +
+ +
+
+ + {!isOpen ? ( +
分析摘要已收起,需要时再展开查看概览和密度信息。
+ ) : ( +
+
+
+ 当前解释 +
+
+ {explainSummary.map((line, index) => ( +
+ {line} +
+ ))} +
+
+ +
+
+ 信号密度 +
+
+
+ 分析师信号 + {tickerSignals.length} +
+
+ 讨论提及 + {recentMentions.length} +
+
+ 成交记录 + {tickerTrades.length} +
+
+ 新闻条目 + {tickerNews.length} +
+
+
+ 当前分析优先读取已落库的历史记录,缺失时再回退到本次运行中的实时事件。 +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/explain/ExplainTradesSection.jsx b/frontend/src/components/explain/ExplainTradesSection.jsx new file mode 100644 index 0000000..122abff --- /dev/null +++ b/frontend/src/components/explain/ExplainTradesSection.jsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { formatDateTime } from '../../utils/formatters'; + +export default function ExplainTradesSection({ + tickerTrades, + selectedSymbol, + isOpen, + onToggle, +}) { + const sideLabel = (value) => { + if (value === 'LONG') return '做多'; + if (value === 'SHORT') return '做空'; + return value || '-'; + }; + + return ( +
+
+

成交记录

+
+
+ {tickerTrades.length} 笔与 {selectedSymbol} 相关的交易 +
+ +
+
+ + {tickerTrades.length === 0 ? ( +
该股票暂无成交记录
+ ) : !isOpen ? ( +
成交记录默认收起,需要时再展开查看。
+ ) : ( +
+ + + + + + + + + + + {tickerTrades.slice(0, 10).map((trade, index) => ( + + + + + + + ))} + +
时间方向数量价格
{formatDateTime(trade.timestamp)} + {sideLabel(trade.side)} + {trade.qty}${Number(trade.price).toFixed(2)}
+
+ )} +
+ ); +} diff --git a/frontend/src/components/explain/explainUtils.js b/frontend/src/components/explain/explainUtils.js new file mode 100644 index 0000000..fcffaaf --- /dev/null +++ b/frontend/src/components/explain/explainUtils.js @@ -0,0 +1,281 @@ +export function normalizeSignalDirection(signal) { + const value = String(signal || '').trim().toLowerCase(); + if (!value) return 'neutral'; + if (value.includes('bull') || value === 'long' || value === 'buy') return 'bullish'; + if (value.includes('bear') || value === 'short' || value === 'sell') return 'bearish'; + return 'neutral'; +} + +export function includesTicker(content, ticker) { + if (!ticker || typeof content !== 'string') return false; + const normalized = ticker.trim().toUpperCase(); + if (!normalized) return false; + return new RegExp(`\\b${normalized}\\b`, 'i').test(content); +} + +export function flattenFeedMessages(feed) { + if (!Array.isArray(feed)) return []; + const items = []; + + feed.forEach((item) => { + if (!item || !item.type || !item.data) return; + + if (item.type === 'message' || item.type === 'memory') { + items.push({ ...item.data, feedType: item.type, feedId: item.id }); + return; + } + + if (item.type === 'conference' && Array.isArray(item.data.messages)) { + item.data.messages.forEach((message) => { + items.push({ + ...message, + feedType: 'conference', + feedId: item.id, + conferenceTitle: item.data.title + }); + }); + } + }); + + return items; +} + +export function snippetText(content, ticker) { + const raw = String(content || '').replace(/\s+/g, ' ').trim(); + if (!raw) return ''; + const normalizedTicker = String(ticker || '').trim().toUpperCase(); + if (!normalizedTicker) { + return raw.length > 220 ? `${raw.slice(0, 220)}...` : raw; + } + + const upper = raw.toUpperCase(); + const idx = upper.indexOf(normalizedTicker); + if (idx === -1) { + return raw.length > 220 ? `${raw.slice(0, 220)}...` : raw; + } + + const start = Math.max(0, idx - 90); + const end = Math.min(raw.length, idx + normalizedTicker.length + 130); + const snippet = raw.slice(start, end).trim(); + return `${start > 0 ? '...' : ''}${snippet}${end < raw.length ? '...' : ''}`; +} + +export function buildLinePath(points, width, height, padding) { + if (!Array.isArray(points) || points.length === 0) { + return ''; + } + + const prices = points.map((point) => Number(point.price)).filter(Number.isFinite); + if (!prices.length) { + return ''; + } + + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const span = maxPrice - minPrice || 1; + const innerWidth = width - padding * 2; + const innerHeight = height - padding * 2; + + return points.map((point, index) => { + const x = padding + (innerWidth * index) / Math.max(points.length - 1, 1); + const y = height - padding - ((Number(point.price) - minPrice) / span) * innerHeight; + return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`; + }).join(' '); +} + +export function parsePointTime(point) { + const raw = point?.timestamp ?? point?.label; + if (!raw) return NaN; + const direct = new Date(raw).getTime(); + if (Number.isFinite(direct)) return direct; + return new Date(`${raw}T00:00:00`).getTime(); +} + +export function aggregatePriceSeriesToCandles(points) { + if (!Array.isArray(points) || points.length === 0) { + return []; + } + + const bucketTarget = points.length >= 36 ? 12 : points.length >= 18 ? 8 : 4; + const bucketSize = Math.max(1, Math.ceil(points.length / bucketTarget)); + const candles = []; + + for (let index = 0; index < points.length; index += bucketSize) { + const bucket = points.slice(index, index + bucketSize); + const prices = bucket.map((point) => Number(point.price)).filter(Number.isFinite); + if (!prices.length) { + continue; + } + + candles.push({ + id: `${bucket[0]?.timestamp || index}-${bucket[bucket.length - 1]?.timestamp || index + bucket.length}`, + open: Number(bucket[0].price), + high: Math.max(...prices), + low: Math.min(...prices), + close: Number(bucket[bucket.length - 1].price), + startTimestamp: parsePointTime(bucket[0]), + endTimestamp: parsePointTime(bucket[bucket.length - 1]), + startLabel: bucket[0]?.label || bucket[0]?.timestamp || '', + endLabel: bucket[bucket.length - 1]?.label || bucket[bucket.length - 1]?.timestamp || '' + }); + } + + return candles; +} + +export function eventDateKey(timestamp) { + if (!timestamp) return ''; + const parsed = new Date(timestamp); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString().slice(0, 10); + } + return String(timestamp).slice(0, 10); +} + +export function resolveEventCategory(event) { + if (!event) return 'other'; + if (event.type === 'trade') return 'trade'; + if (event.type === 'mention') return 'discussion'; + if (event.type !== 'signal') return 'other'; + + const role = String(event.meta || '').toLowerCase(); + if (role.includes('technical')) return 'technical'; + if (role.includes('fundamental')) return 'fundamental'; + if (role.includes('sentiment')) return 'sentiment'; + if (role.includes('valuation')) return 'valuation'; + if (role.includes('risk')) return 'risk'; + if (role.includes('portfolio')) return 'portfolio'; + return 'signal'; +} + +export function normalizeTradeRow(row, fallbackIndex = 0) { + if (!row || typeof row !== 'object') return null; + const timestamp = row.timestamp || row.ts || row.created_at || null; + const ticker = row.ticker || ''; + const side = row.side || ''; + const qtyValue = Number(row.qty ?? row.quantity ?? 0); + const priceValue = Number(row.price ?? 0); + return { + id: row.id || `trade-${ticker}-${timestamp || fallbackIndex}-${fallbackIndex}`, + timestamp, + trading_date: row.trading_date || row.trade_date || null, + ticker, + side, + qty: Number.isFinite(qtyValue) ? qtyValue : 0, + price: Number.isFinite(priceValue) ? priceValue : 0 + }; +} + +export function normalizeSignalRow(row, fallbackIndex = 0) { + if (!row || typeof row !== 'object') return null; + const timestamp = row.timestamp || row.created_at || null; + const date = row.date || row.trade_date || eventDateKey(timestamp) || ''; + const rawSignal = row.signal || row.title || ''; + const normalizedDirection = normalizeSignalDirection(rawSignal); + const confidenceValue = Number(row.confidence); + const realReturnValue = Number(row.real_return); + const parsedCorrect = typeof row.is_correct === 'string' + ? row.is_correct.toLowerCase() === 'true' + ? true + : row.is_correct.toLowerCase() === 'false' + ? false + : null + : typeof row.is_correct === 'boolean' + ? row.is_correct + : null; + + return { + id: row.id || `signal-${row.agent_id || row.agentId || 'agent'}-${date || fallbackIndex}-${fallbackIndex}`, + timestamp, + date, + ticker: row.ticker || '', + signal: rawSignal, + confidence: Number.isFinite(confidenceValue) ? confidenceValue : null, + real_return: Number.isFinite(realReturnValue) ? realReturnValue : null, + is_correct: parsedCorrect, + agentId: row.agent_id || row.agentId || '', + agentName: row.agent_name || row.agentName || row.meta || '未知分析师', + role: row.role || row.meta || '', + normalizedDirection + }; +} + +export function normalizeMentionRow(row, fallbackIndex = 0) { + if (!row || typeof row !== 'object') return null; + return { + id: row.id || `mention-${fallbackIndex}`, + feedId: row.id || `mention-${fallbackIndex}`, + timestamp: row.timestamp || null, + agent: row.agent || row.agentName || '未知角色', + content: row.body || row.content || '', + conferenceTitle: row.meta || '', + feedType: 'sqlite' + }; +} + +export function normalizeNewsRow(row, fallbackIndex = 0) { + if (!row || typeof row !== 'object') return null; + const date = row.date || row.published_utc || row.timestamp || null; + const source = row.source || row.publisher || '新闻源'; + const title = row.title || '未命名新闻'; + const summary = row.summary || row.description || ''; + return { + id: row.id || row.url || `news-${fallbackIndex}`, + date, + dateKey: eventDateKey(date), + ticker: row.ticker || '', + title, + source, + category: row.category || '', + related: row.related || '', + summary, + url: row.url || row.article_url || '', + tradeDate: row.trade_date || null, + relevance: row.relevance || '', + sentiment: row.sentiment || '', + keyDiscussion: row.key_discussion || '', + reasonGrowth: row.reason_growth || '', + reasonDecrease: row.reason_decrease || '', + retT0: Number.isFinite(Number(row.ret_t0)) ? Number(row.ret_t0) : null, + retT1: Number.isFinite(Number(row.ret_t1)) ? Number(row.ret_t1) : null, + retT3: Number.isFinite(Number(row.ret_t3)) ? Number(row.ret_t3) : null, + retT5: Number.isFinite(Number(row.ret_t5)) ? Number(row.ret_t5) : null, + retT10: Number.isFinite(Number(row.ret_t10)) ? Number(row.ret_t10) : null, + analysisSource: row.analysis_source || '', + analysisModelLabel: row.analysis_model_label || '' + }; +} + +export function normalizeNewsTimelineRow(row, fallbackIndex = 0) { + if (!row || typeof row !== 'object') return null; + const date = row.date || row.trade_date || null; + if (!date) return null; + const countValue = Number(row.count ?? 0); + const sourceCountValue = Number(row.source_count ?? 0); + return { + id: row.id || `news-timeline-${date}-${fallbackIndex}`, + date, + dateKey: eventDateKey(date), + count: Number.isFinite(countValue) ? countValue : 0, + sourceCount: Number.isFinite(sourceCountValue) ? sourceCountValue : 0, + topTitle: row.top_title || '', + positiveCount: Number.isFinite(Number(row.positive_count)) ? Number(row.positive_count) : 0, + negativeCount: Number.isFinite(Number(row.negative_count)) ? Number(row.negative_count) : 0, + neutralCount: Number.isFinite(Number(row.neutral_count)) ? Number(row.neutral_count) : 0, + highRelevanceCount: Number.isFinite(Number(row.high_relevance_count)) ? Number(row.high_relevance_count) : 0 + }; +} + +export const EVENT_CATEGORY_META = { + all: { label: '全部事件', color: '#111111' }, + discussion: { label: '讨论', color: '#555555' }, + signal: { label: '信号', color: '#0f766e' }, + technical: { label: '技术', color: '#2563eb' }, + fundamental: { label: '基本面', color: '#059669' }, + sentiment: { label: '情绪', color: '#7c3aed' }, + valuation: { label: '估值', color: '#d97706' }, + risk: { label: '风控', color: '#dc2626' }, + portfolio: { label: '组合', color: '#111827' }, + trade: { label: '成交', color: '#b91c1c' }, + other: { label: '其他', color: '#6b7280' } +}; diff --git a/frontend/src/components/explain/useExplainModel.js b/frontend/src/components/explain/useExplainModel.js new file mode 100644 index 0000000..fcdf83f --- /dev/null +++ b/frontend/src/components/explain/useExplainModel.js @@ -0,0 +1,664 @@ +import { useMemo } from 'react'; +import { formatDateTime } from '../../utils/formatters'; +import { + aggregatePriceSeriesToCandles, + buildLinePath, + eventDateKey, + flattenFeedMessages, + includesTicker, + normalizeMentionRow, + normalizeNewsRow, + normalizeNewsTimelineRow, + normalizeSignalDirection, + normalizeSignalRow, + normalizeTradeRow, + parsePointTime, + resolveEventCategory, + snippetText +} from './explainUtils'; + +function tradeSideLabel(value) { + if (value === 'LONG') return '做多'; + if (value === 'SHORT') return '做空'; + return value || '交易'; +} + +export default function useExplainModel({ + tickers, + holdings, + trades, + leaderboard, + feed, + priceHistoryByTicker, + ohlcHistoryByTicker, + selectedSymbol, + explainEventsSnapshot, + newsSnapshot, + selectedEventDate, + activeEventCategory, + activeNewsCategory, + activeNewsSentiment = 'all' +}) { + const availableSymbols = useMemo(() => ( + Array.isArray(tickers) + ? tickers.map((ticker) => ticker?.symbol).filter((symbol) => typeof symbol === 'string' && symbol.trim()) + : [] + ), [tickers]); + + const selectedTicker = useMemo( + () => tickers.find((ticker) => ticker.symbol === selectedSymbol) || null, + [selectedSymbol, tickers] + ); + + const holding = useMemo( + () => holdings.find((item) => item.ticker === selectedSymbol) || null, + [holdings, selectedSymbol] + ); + + const fallbackTrades = useMemo( + () => trades + .filter((trade) => trade.ticker === selectedSymbol) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()), + [selectedSymbol, trades] + ); + + const tickerSignals = useMemo(() => { + const snapshotSignals = Array.isArray(explainEventsSnapshot?.signals) + ? explainEventsSnapshot.signals.map((signal, index) => normalizeSignalRow(signal, index)).filter(Boolean) + : []; + if (snapshotSignals.length > 0) { + return snapshotSignals.sort((a, b) => new Date(b.timestamp || b.date).getTime() - new Date(a.timestamp || a.date).getTime()); + } + if (!selectedSymbol) return []; + return (Array.isArray(leaderboard) ? leaderboard : []).flatMap((agent) => { + const signals = Array.isArray(agent.signals) ? agent.signals : []; + return signals + .filter((signal) => signal.ticker === selectedSymbol) + .map((signal) => ({ + agentId: agent.agentId, + agentName: agent.name, + role: agent.role, + ...signal, + normalizedDirection: normalizeSignalDirection(signal.signal) + })); + }).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + }, [explainEventsSnapshot, leaderboard, selectedSymbol]); + + const signalSummary = useMemo(() => { + const summary = { bullish: 0, bearish: 0, neutral: 0 }; + tickerSignals.forEach((signal) => { + summary[signal.normalizedDirection] += 1; + }); + return summary; + }, [tickerSignals]); + + const fallbackRecentMentions = useMemo(() => { + const flattened = flattenFeedMessages(feed); + return flattened + .filter((message) => message.agent !== 'System' && includesTicker(message.content, selectedSymbol)) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 8); + }, [feed, selectedSymbol]); + + const tickerTrades = useMemo(() => { + const snapshotTrades = Array.isArray(explainEventsSnapshot?.trades) + ? explainEventsSnapshot.trades.map((trade, index) => normalizeTradeRow(trade, index)).filter(Boolean) + : []; + if (snapshotTrades.length > 0) { + return snapshotTrades.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + } + return fallbackTrades; + }, [explainEventsSnapshot, fallbackTrades]); + + const recentMentions = useMemo(() => { + const snapshotMentions = Array.isArray(explainEventsSnapshot?.events) + ? explainEventsSnapshot.events + .map((event, index) => normalizeMentionRow(event, index)) + .filter(Boolean) + .slice(0, 8) + : []; + if (snapshotMentions.length > 0) { + return snapshotMentions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + } + return fallbackRecentMentions; + }, [explainEventsSnapshot, fallbackRecentMentions]); + + const tickerNews = useMemo(() => { + const items = Array.isArray(newsSnapshot?.items) + ? newsSnapshot.items.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean) + : []; + return items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + }, [newsSnapshot]); + + const dateScopedNews = useMemo(() => { + if (!selectedEventDate || !newsSnapshot?.byDate || typeof newsSnapshot.byDate !== 'object') { + return []; + } + const rows = Array.isArray(newsSnapshot.byDate[selectedEventDate]) + ? newsSnapshot.byDate[selectedEventDate] + : []; + return rows.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean); + }, [newsSnapshot, selectedEventDate]); + + const visibleNews = useMemo(() => { + if (!selectedEventDate) { + return tickerNews; + } + if (dateScopedNews.length > 0) { + return dateScopedNews.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + } + const scoped = tickerNews.filter((item) => item.dateKey === selectedEventDate); + return scoped.length > 0 ? scoped : tickerNews; + }, [dateScopedNews, selectedEventDate, tickerNews]); + + const tickerNewsTimeline = useMemo(() => { + const items = Array.isArray(newsSnapshot?.timeline) + ? newsSnapshot.timeline.map((item, index) => normalizeNewsTimelineRow(item, index)).filter(Boolean) + : []; + return items.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + }, [newsSnapshot]); + + const newsCategories = useMemo(() => ( + newsSnapshot?.categories && typeof newsSnapshot.categories === 'object' + ? newsSnapshot.categories + : {} + ), [newsSnapshot]); + + const visibleNewsByCategory = useMemo(() => { + let scopedNews = visibleNews; + if (activeNewsCategory !== 'all') { + const categoryMeta = newsCategories?.[activeNewsCategory]; + const allowedIds = Array.isArray(categoryMeta?.article_ids) + ? new Set(categoryMeta.article_ids) + : null; + if (allowedIds && allowedIds.size > 0) { + scopedNews = scopedNews.filter((item) => allowedIds.has(item.id)); + } + } + + if (activeNewsSentiment === 'all') { + return scopedNews; + } + return scopedNews.filter((item) => { + const sentiment = String(item.sentiment || '').trim().toLowerCase() || 'neutral'; + return sentiment === activeNewsSentiment; + }); + }, [activeNewsCategory, activeNewsSentiment, newsCategories, visibleNews]); + + const selectedRangeWindow = useMemo(() => { + if (!selectedEventDate) return null; + const endDate = new Date(`${selectedEventDate}T00:00:00`); + if (Number.isNaN(endDate.getTime())) return null; + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - 6); + return { + startDate: startDate.toISOString().slice(0, 10), + endDate: selectedEventDate + }; + }, [selectedEventDate]); + + const selectedRangeExplain = useMemo(() => { + if (!selectedRangeWindow) return null; + const key = `${selectedRangeWindow.startDate}:${selectedRangeWindow.endDate}`; + return newsSnapshot?.rangeExplainCache?.[key] || null; + }, [newsSnapshot, selectedRangeWindow]); + + const selectedStory = useMemo(() => { + const storyCache = newsSnapshot?.storyCache; + if (!storyCache || typeof storyCache !== 'object') { + return null; + } + const keys = Object.keys(storyCache).sort(); + if (!keys.length) { + return null; + } + return storyCache[keys[keys.length - 1]] || null; + }, [newsSnapshot]); + + const selectedSimilarDays = useMemo(() => { + if (!selectedEventDate) { + return null; + } + const similarCache = newsSnapshot?.similarDaysCache; + if (!similarCache || typeof similarCache !== 'object') { + return null; + } + return similarCache[selectedEventDate] || null; + }, [newsSnapshot, selectedEventDate]); + + const latestSignal = tickerSignals[0] || null; + const priceColor = selectedTicker?.change > 0 ? '#00C853' : selectedTicker?.change < 0 ? '#FF1744' : '#000000'; + const exposureWeight = holding && Number.isFinite(Number(holding.weight)) ? Number(holding.weight) * 100 : null; + const recentTrade = tickerTrades[0] || null; + + const ohlcSeries = useMemo(() => { + const raw = ohlcHistoryByTicker?.[selectedSymbol]; + return Array.isArray(raw) ? raw.filter((candle) => Number.isFinite(Number(candle.close))).slice(-60) : []; + }, [ohlcHistoryByTicker, selectedSymbol]); + + const priceSeries = useMemo(() => { + const raw = priceHistoryByTicker?.[selectedSymbol]; + return Array.isArray(raw) ? raw.filter((point) => Number.isFinite(Number(point.price))).slice(-60) : []; + }, [priceHistoryByTicker, selectedSymbol]); + + const explainSummary = useMemo(() => { + if (!selectedSymbol) return []; + const lines = []; + + if (latestSignal) { + const directionText = latestSignal.normalizedDirection === 'bullish' + ? '偏多' + : latestSignal.normalizedDirection === 'bearish' + ? '偏空' + : '观望'; + lines.push(`最新分析师结论为${directionText},来自${latestSignal.agentName}。`); + } else { + lines.push('当前还没有形成结构化分析师信号,更多依赖讨论内容和持仓状态。'); + } + + if (holding) { + lines.push(`组合当前持有 ${selectedSymbol},权重约 ${exposureWeight != null ? `${exposureWeight.toFixed(2)}%` : '0.00%'}。`); + } else { + lines.push(`组合当前未持有 ${selectedSymbol},仍处于观察阶段。`); + } + + if (recentTrade) { + lines.push(`最近一次相关交易为${tradeSideLabel(recentTrade.side)},时间是 ${formatDateTime(recentTrade.timestamp)}。`); + } + + if (recentMentions.length > 0) { + lines.push(`最近讨论中共有 ${recentMentions.length} 条直接提及 ${selectedSymbol} 的观点。`); + } + + return lines; + }, [exposureWeight, holding, latestSignal, recentMentions.length, recentTrade, selectedSymbol]); + + const explainTimeline = useMemo(() => { + const signalEvents = tickerSignals.slice(0, 12).map((signal, index) => ({ + id: `signal-${signal.agentId}-${signal.date}-${index}`, + type: 'signal', + timestamp: new Date(`${signal.date}T08:00:00`).toISOString(), + title: `${signal.agentName} 给出${signal.normalizedDirection === 'bullish' ? '看涨' : signal.normalizedDirection === 'bearish' ? '看跌' : '中性'}信号`, + meta: signal.role, + body: typeof signal.real_return === 'number' + ? `后验收益 ${signal.real_return >= 0 ? '+' : ''}${(signal.real_return * 100).toFixed(2)}%` + : '该信号暂未完成后验评估', + tone: signal.normalizedDirection === 'bullish' ? 'positive' : signal.normalizedDirection === 'bearish' ? 'negative' : 'neutral' + })); + + const mentionEvents = recentMentions.slice(0, 12).map((message, index) => ({ + id: `mention-${message.feedId || message.id}-${index}`, + type: 'mention', + timestamp: message.timestamp, + title: `${message.agent || '未知角色'}在${message.conferenceTitle || '讨论流'}中提及 ${selectedSymbol}`, + meta: message.conferenceTitle || (message.feedType === 'conference' ? '投资讨论' : '即时消息'), + body: snippetText(message.content, selectedSymbol), + tone: 'neutral' + })); + + const tradeEvents = tickerTrades.slice(0, 12).map((trade, index) => ({ + id: `trade-${trade.id || `${trade.ticker}-${trade.timestamp}-${index}`}`, + type: 'trade', + timestamp: trade.timestamp, + title: `${tradeSideLabel(trade.side)} ${trade.qty} 股`, + meta: '交易执行', + body: `成交价 $${Number(trade.price).toFixed(2)}`, + tone: trade.side === 'LONG' ? 'positive' : trade.side === 'SHORT' ? 'negative' : 'neutral' + })); + + const fallbackTimeline = [...signalEvents, ...mentionEvents, ...tradeEvents] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 24) + .map((event) => ({ + ...event, + dateKey: eventDateKey(event.timestamp), + category: resolveEventCategory(event) + })); + if (!explainEventsSnapshot) { + return fallbackTimeline; + } + + const dbSignalEvents = (Array.isArray(explainEventsSnapshot.signals) ? explainEventsSnapshot.signals : []) + .map((signal, index) => { + if (signal?.type === 'signal' && signal?.timestamp) { + return signal; + } + const normalized = normalizeSignalRow(signal, index); + if (!normalized) return null; + return { + id: normalized.id, + type: 'signal', + timestamp: normalized.timestamp || (normalized.date ? new Date(`${normalized.date}T08:00:00`).toISOString() : null), + title: `${normalized.agentName} 给出${ + normalized.normalizedDirection === 'bullish' + ? '看涨' + : normalized.normalizedDirection === 'bearish' + ? '看跌' + : '中性' + }信号`, + meta: normalized.role, + body: typeof normalized.real_return === 'number' + ? `后验收益 ${normalized.real_return >= 0 ? '+' : ''}${(normalized.real_return * 100).toFixed(2)}%` + : '该信号暂未完成后验评估', + tone: normalized.normalizedDirection === 'bullish' + ? 'positive' + : normalized.normalizedDirection === 'bearish' + ? 'negative' + : 'neutral' + }; + }) + .filter(Boolean); + + const dbMentionEvents = (Array.isArray(explainEventsSnapshot.events) ? explainEventsSnapshot.events : []) + .map((event, index) => { + if (event?.type === 'mention' && event?.timestamp) { + return event; + } + const normalized = normalizeMentionRow(event, index); + if (!normalized) return null; + return { + id: normalized.id, + type: 'mention', + timestamp: normalized.timestamp, + title: `${normalized.agent || '未知角色'}在${normalized.conferenceTitle || '讨论流'}中提及 ${selectedSymbol}`, + meta: normalized.conferenceTitle || (normalized.feedType === 'conference' ? '投资讨论' : '即时消息'), + body: snippetText(normalized.content, selectedSymbol), + tone: 'neutral' + }; + }) + .filter(Boolean); + + const dbTradeEvents = (Array.isArray(explainEventsSnapshot.trades) ? explainEventsSnapshot.trades : []) + .map((trade, index) => { + if (trade?.type === 'trade' && trade?.timestamp) { + return trade; + } + const normalized = normalizeTradeRow(trade, index); + if (!normalized) return null; + return { + id: normalized.id, + type: 'trade', + timestamp: normalized.timestamp, + title: `${tradeSideLabel(normalized.side)} ${normalized.qty} 股`, + meta: '交易执行', + body: `成交价 $${Number(normalized.price).toFixed(2)}`, + tone: normalized.side === 'LONG' ? 'positive' : normalized.side === 'SHORT' ? 'negative' : 'neutral' + }; + }) + .filter(Boolean); + + const dbEvents = [ + ...dbSignalEvents, + ...dbMentionEvents, + ...dbTradeEvents + ] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 24) + .map((event) => ({ + ...event, + dateKey: eventDateKey(event.timestamp), + category: resolveEventCategory(event) + })); + + return dbEvents.length > 0 ? dbEvents : fallbackTimeline; + }, [explainEventsSnapshot, recentMentions, selectedSymbol, tickerSignals, tickerTrades]); + + const availableEventDates = useMemo( + () => Array.from(new Set(explainTimeline.map((event) => event.dateKey).filter(Boolean))), + [explainTimeline] + ); + + const eventCategoryCounts = useMemo(() => { + const scopedEvents = selectedEventDate + ? explainTimeline.filter((event) => event.dateKey === selectedEventDate) + : explainTimeline; + const counts = { all: scopedEvents.length }; + scopedEvents.forEach((event) => { + counts[event.category] = (counts[event.category] || 0) + 1; + }); + return counts; + }, [explainTimeline, selectedEventDate]); + + const visibleExplainEvents = useMemo(() => explainTimeline.filter((event) => { + if (selectedEventDate && event.dateKey !== selectedEventDate) { + return false; + } + if (activeEventCategory !== 'all' && event.category !== activeEventCategory) { + return false; + } + return true; + }), [activeEventCategory, explainTimeline, selectedEventDate]); + + const chartModel = useMemo(() => { + const width = 720; + const height = 220; + const padding = 18; + if (!ohlcSeries.length && !priceSeries.length) { + return { + width, + height, + padding, + path: '', + minPrice: null, + maxPrice: null, + markers: [], + candles: [], + linePoints: [], + bucketCount: 0 + }; + } + + if (ohlcSeries.length > 1) { + const prices = ohlcSeries.flatMap((candle) => [Number(candle.low), Number(candle.high)]); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const span = maxPrice - minPrice || 1; + const innerWidth = width - padding * 2; + const innerHeight = height - padding * 2; + const candleWidth = Math.max(8, Math.min(18, (innerWidth / ohlcSeries.length) * 0.55)); + const startTime = parsePointTime({ timestamp: ohlcSeries[0]?.time }); + const endTime = parsePointTime({ timestamp: ohlcSeries[ohlcSeries.length - 1]?.time }); + const timeSpan = Math.max(endTime - startTime, 1); + + const candles = ohlcSeries.map((candle, index) => { + const centerX = padding + ((index + 0.5) * innerWidth) / Math.max(ohlcSeries.length, 1); + const openY = height - padding - ((Number(candle.open) - minPrice) / span) * innerHeight; + const closeY = height - padding - ((Number(candle.close) - minPrice) / span) * innerHeight; + const highY = height - padding - ((Number(candle.high) - minPrice) / span) * innerHeight; + const lowY = height - padding - ((Number(candle.low) - minPrice) / span) * innerHeight; + return { + ...candle, + id: `${candle.time || index}`, + centerX, + x: centerX - candleWidth / 2, + width: candleWidth, + openY, + closeY, + highY, + lowY, + bodyY: Math.min(openY, closeY), + bodyHeight: Math.max(Math.abs(closeY - openY), 2) + }; + }); + + const explainMarkers = explainTimeline.slice(0, 8).map((event) => { + const timestamp = new Date(event.timestamp).getTime(); + if (!Number.isFinite(timestamp)) return null; + const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan)); + const nearestCandleIndex = candles.length <= 1 + ? 0 + : Math.min(candles.length - 1, Math.max(0, Math.round(ratio * Math.max(candles.length - 1, 1)))); + const nearestCandle = candles[nearestCandleIndex] || null; + const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth; + const price = nearestCandle ? Number(nearestCandle.close) : Number(ohlcSeries[ohlcSeries.length - 1]?.close ?? maxPrice); + const y = height - padding - ((price - minPrice) / span) * innerHeight; + return { ...event, x, y, isSelected: event.dateKey === selectedEventDate, markerType: 'event' }; + }).filter(Boolean); + + const newsMarkers = tickerNewsTimeline.slice(-20).map((item, index) => { + const timestamp = new Date(`${item.date}T12:00:00`).getTime(); + if (!Number.isFinite(timestamp)) return null; + const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan)); + const nearestCandleIndex = candles.length <= 1 + ? 0 + : Math.min(candles.length - 1, Math.max(0, Math.round(ratio * Math.max(candles.length - 1, 1)))); + const nearestCandle = candles[nearestCandleIndex] || null; + const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth; + const price = nearestCandle ? Number(nearestCandle.close) : Number(ohlcSeries[ohlcSeries.length - 1]?.close ?? maxPrice); + const y = height - padding - ((price - minPrice) / span) * innerHeight; + return { + id: item.id || `news-marker-${index}`, + title: item.topTitle || `当日 ${item.count} 条新闻`, + dateKey: item.dateKey, + tone: 'news', + x, + y, + isSelected: item.dateKey === selectedEventDate, + markerType: 'news', + count: item.count + }; + }).filter(Boolean); + + return { + width, + height, + padding, + path: '', + minPrice, + maxPrice, + markers: [...newsMarkers, ...explainMarkers], + candles, + linePoints: [], + bucketCount: candles.length + }; + } + + const prices = priceSeries.map((point) => Number(point.price)); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const span = maxPrice - minPrice || 1; + const innerWidth = width - padding * 2; + const innerHeight = height - padding * 2; + const startTime = parsePointTime(priceSeries[0]); + const endTime = parsePointTime(priceSeries[priceSeries.length - 1]); + const timeSpan = Math.max(endTime - startTime, 1); + const candles = aggregatePriceSeriesToCandles(priceSeries); + + const linePoints = priceSeries.map((point, index) => { + const x = padding + (innerWidth * index) / Math.max(priceSeries.length - 1, 1); + const y = height - padding - ((Number(point.price) - minPrice) / span) * innerHeight; + return { x, y }; + }); + + const candleWidth = candles.length > 1 + ? Math.max(8, Math.min(24, (innerWidth / candles.length) * 0.58)) + : 14; + + const mappedCandles = candles.map((candle, index) => { + const centerX = padding + ((index + 0.5) * innerWidth) / Math.max(candles.length, 1); + const openY = height - padding - ((candle.open - minPrice) / span) * innerHeight; + const closeY = height - padding - ((candle.close - minPrice) / span) * innerHeight; + const highY = height - padding - ((candle.high - minPrice) / span) * innerHeight; + const lowY = height - padding - ((candle.low - minPrice) / span) * innerHeight; + return { + ...candle, + centerX, + x: centerX - candleWidth / 2, + width: candleWidth, + openY, + closeY, + highY, + lowY, + bodyY: Math.min(openY, closeY), + bodyHeight: Math.max(Math.abs(closeY - openY), 2) + }; + }); + + const explainMarkers = explainTimeline.slice(0, 8).map((event) => { + const timestamp = new Date(event.timestamp).getTime(); + if (!Number.isFinite(timestamp)) return null; + const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan)); + const nearestCandleIndex = mappedCandles.length <= 1 + ? 0 + : Math.min( + mappedCandles.length - 1, + Math.max(0, Math.round(ratio * Math.max(mappedCandles.length - 1, 1))) + ); + const nearestCandle = mappedCandles[nearestCandleIndex] || null; + const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth; + const price = nearestCandle ? nearestCandle.close : Number(priceSeries[priceSeries.length - 1]?.price ?? prices[prices.length - 1]); + const y = height - padding - ((price - minPrice) / span) * innerHeight; + return { ...event, x, y, isSelected: event.dateKey === selectedEventDate, markerType: 'event' }; + }).filter(Boolean); + + const newsMarkers = tickerNewsTimeline.slice(-20).map((item, index) => { + const timestamp = new Date(`${item.date}T12:00:00`).getTime(); + if (!Number.isFinite(timestamp)) return null; + const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan)); + const nearestCandleIndex = mappedCandles.length <= 1 + ? 0 + : Math.min( + mappedCandles.length - 1, + Math.max(0, Math.round(ratio * Math.max(mappedCandles.length - 1, 1))) + ); + const nearestCandle = mappedCandles[nearestCandleIndex] || null; + const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth; + const price = nearestCandle ? nearestCandle.close : Number(priceSeries[priceSeries.length - 1]?.price ?? prices[prices.length - 1]); + const y = height - padding - ((price - minPrice) / span) * innerHeight; + return { + id: item.id || `news-marker-${index}`, + title: item.topTitle || `当日 ${item.count} 条新闻`, + dateKey: item.dateKey, + tone: 'news', + x, + y, + isSelected: item.dateKey === selectedEventDate, + markerType: 'news', + count: item.count + }; + }).filter(Boolean); + + return { + width, + height, + padding, + path: buildLinePath(priceSeries, width, height, padding), + minPrice, + maxPrice, + markers: [...newsMarkers, ...explainMarkers], + candles: mappedCandles, + linePoints, + bucketCount: mappedCandles.length + }; + }, [explainTimeline, ohlcSeries, priceSeries, selectedEventDate, tickerNewsTimeline]); + + return { + availableSymbols, + selectedTicker, + holding, + tickerSignals, + signalSummary, + tickerTrades, + recentMentions, + tickerNews, + visibleNews, + newsCategories, + visibleNewsByCategory, + selectedRangeWindow, + selectedRangeExplain, + selectedStory, + selectedSimilarDays, + latestSignal, + priceColor, + exposureWeight, + recentTrade, + ohlcSeries, + priceSeries, + explainSummary, + explainTimeline, + availableEventDates, + eventCategoryCounts, + visibleExplainEvents, + chartModel + }; +} diff --git a/frontend/src/components/explain/useExplainModel.test.jsx b/frontend/src/components/explain/useExplainModel.test.jsx new file mode 100644 index 0000000..eea4182 --- /dev/null +++ b/frontend/src/components/explain/useExplainModel.test.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import useExplainModel from './useExplainModel'; + +function buildBaseProps() { + return { + tickers: [{ symbol: 'AAPL', price: 105.12, change: 1.34 }], + holdings: [{ ticker: 'AAPL', quantity: 10, weight: 0.2, marketValue: 1051.2, currentPrice: 105.12 }], + trades: [], + leaderboard: [], + feed: [], + priceHistoryByTicker: { + AAPL: [ + { timestamp: '2026-03-08T10:00:00Z', price: 100 }, + { timestamp: '2026-03-09T10:00:00Z', price: 103 }, + { timestamp: '2026-03-10T10:00:00Z', price: 105 } + ] + }, + ohlcHistoryByTicker: {}, + selectedSymbol: 'AAPL', + explainEventsSnapshot: { + signals: [ + { + id: 'sig-1', + ticker: 'AAPL', + date: '2026-03-10', + signal: 'bullish', + confidence: 0.88, + agent_id: 'agent-1', + agent_name: 'Alpha', + role: 'technical' + } + ], + events: [ + { + id: 'mention-1', + timestamp: '2026-03-10T12:00:00Z', + agent: 'Research', + body: 'AAPL momentum remains strong after earnings.', + meta: 'morning note' + } + ], + trades: [ + { + id: 'trade-1', + timestamp: '2026-03-10T15:00:00Z', + ticker: 'AAPL', + side: 'LONG', + qty: 5, + price: 104.5 + } + ] + }, + newsSnapshot: { + items: [ + { + id: 'news-1', + ticker: 'AAPL', + date: '2026-03-10T09:00:00Z', + title: 'Apple earnings beat expectations', + summary: 'Revenue topped consensus estimates.', + source: 'Polygon', + sentiment: 'positive' + }, + { + id: 'news-2', + ticker: 'AAPL', + date: '2026-03-09T09:00:00Z', + title: 'Supplier update', + summary: 'Supply chain improves.', + source: 'Polygon', + sentiment: 'negative' + } + ], + timeline: [ + { id: 'timeline-1', date: '2026-03-09', count: 1, source_count: 1, top_title: 'Supplier update' }, + { id: 'timeline-2', date: '2026-03-10', count: 1, source_count: 1, top_title: 'Apple earnings beat expectations' } + ], + categories: { + earnings: { + count: 1, + article_ids: ['news-1'] + } + }, + rangeExplainCache: { + '2026-03-03:2026-03-10': { + summary: '区间内主要由财报催化推动。' + } + }, + similarDaysCache: { + '2026-03-10': { + target_features: { + sentiment_score: 0.5, + n_articles: 2 + }, + items: [ + { + date: '2026-02-18', + score: 0.92, + n_articles: 2, + sentiment_score: 0.4 + } + ] + } + } + }, + selectedEventDate: '2026-03-10', + activeEventCategory: 'all', + activeNewsCategory: 'earnings', + activeNewsSentiment: 'all' + }; +} + +describe('useExplainModel', () => { + it('derives visible news and range explain data from snapshots', () => { + const { result } = renderHook(() => useExplainModel(buildBaseProps())); + + expect(result.current.availableSymbols).toEqual(['AAPL']); + expect(result.current.visibleNews).toHaveLength(1); + expect(result.current.visibleNewsByCategory).toHaveLength(1); + expect(result.current.visibleNewsByCategory[0].id).toBe('news-1'); + expect(result.current.selectedRangeWindow).toEqual({ + startDate: '2026-03-03', + endDate: '2026-03-10' + }); + expect(result.current.selectedRangeExplain).toEqual({ + summary: '区间内主要由财报催化推动。' + }); + expect(result.current.selectedSimilarDays?.items).toHaveLength(1); + }); + + it('builds timeline, counts, and chart markers from explain data', () => { + const { result } = renderHook(() => useExplainModel(buildBaseProps())); + + expect(result.current.availableEventDates).toContain('2026-03-10'); + expect(result.current.eventCategoryCounts.all).toBe(3); + expect(result.current.eventCategoryCounts.technical).toBe(1); + expect(result.current.eventCategoryCounts.discussion).toBe(1); + expect(result.current.eventCategoryCounts.trade).toBe(1); + expect(result.current.visibleExplainEvents).toHaveLength(3); + expect(result.current.chartModel.markers.length).toBeGreaterThan(0); + expect(result.current.chartModel.path).toMatch(/^M/); + }); + + it('filters visible news by sentiment when requested', () => { + const props = buildBaseProps(); + props.activeNewsCategory = 'all'; + props.activeNewsSentiment = 'positive'; + + const { result } = renderHook(() => useExplainModel(props)); + + expect(result.current.visibleNewsByCategory).toHaveLength(1); + expect(result.current.visibleNewsByCategory[0].id).toBe('news-1'); + }); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 7391a96..a784dfc 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -8,9 +8,11 @@ export default defineConfig({ allowedHosts: ["localhost", "trading.evoagents.cn","www.evoagents.cn"] }, plugins: [react(), tsconfigPaths(),tailwindcss()], + test: { + environment: "jsdom" + }, preview: { host: "0.0.0.0", port: 4173 }, }); - diff --git a/pyproject.toml b/pyproject.toml index 468218c..72823ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,8 @@ evotraders = "backend.cli:app" [tool.setuptools] packages = ["backend", "backend.agents", "backend.config", "backend.data", "backend.llm", - "backend.tools", "backend.utils", "backend.services"] + "backend.tools", "backend.utils", "backend.services", + "backend.explain", "backend.enrich"] [tool.ruff] line-length = 88 @@ -77,4 +78,4 @@ testpaths = ["backend/tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] - +asyncio_default_fixture_loop_scope = "function" diff --git a/runs/smoke_fullstack/BOOTSTRAP.md b/runs/smoke_fullstack/BOOTSTRAP.md index 2c439f0..cbea4b8 100644 --- a/runs/smoke_fullstack/BOOTSTRAP.md +++ b/runs/smoke_fullstack/BOOTSTRAP.md @@ -1,12 +1,7 @@ --- tickers: -- AAPL -- MSFT -- GOOGL -- NVDA -- TSLA -- META -- AMZN + - AAPL + - MSFT initial_cash: 100000 margin_requirement: 0.0 enable_memory: false diff --git a/runs/smoke_fullstack/state/server_state.json b/runs/smoke_fullstack/state/server_state.json index 645b21c..2f3716b 100644 --- a/runs/smoke_fullstack/state/server_state.json +++ b/runs/smoke_fullstack/state/server_state.json @@ -170,436 +170,146 @@ } ], "realtime_prices": {}, - "system_started": "2026-03-16T01:07:31.896957", + "system_started": "2026-03-16T17:02:46.390521", "feed_history": [ - { - "type": "day_start", - "date": "2026-03-16", - "progress": 0.0, - "timestamp": "2026-03-16" - }, - { - "type": "agent_message", - "agentId": "technical_analyst", - "content": "**技术分析结果:数据不足**\n\n基于当前的技术分析工具,我发现对于2026年3月16日这一未来日期,所有7只股票都显示\"数据不足\"。这表明:\n\n**核心发现:**\n1. **未来数据限制**:技术分析工具基于历史价格数据,对于未来日期(2026年),没有可用的价格数据来进行分析\n2. **工具局限性**:当前的工具似乎只适用于历史日期分析,不适用于未来预测\n\n**投资哲学反思:**\n这个案例验证了我投资哲学中的一个关键原则:**技术分析是基于历史价格模式来预测未来,但无法在没有历史数据的情况下进行预测**。这凸显了:\n\n1. **数据依赖性原则**:技术分析的有效性完全依赖于可用且相关的历史数据\n2. **时间边界意识**:分析师必须清楚分析工具的时间边界和局限性\n3. **谦逊的必要性**:当工具无法提供分析时,承认这一局限性比强行提供分析更为专业\n\n**投资信号:中性**\n**置信度:0**(由于完全缺乏数据进行技术分析)\n\n**理由:**\n1. **数据不可得**:对于2026年3月16日这一未来日期,没有历史价格数据可用于技术分析\n2. **工具限制**:技术分析工具无法分析未来日期\n3. **方法论局限**:技术分析是基于历史模式识别,不适用于无历史数据的未来日期\n\n**逆向测试思考:**\n主流共识可能会错误地假设技术分析可以预测任何时间点。但现实是:\n- 技术分析是向后看的,需要历史数据\n- 对于未来日期,其他方法(如基本面分析、宏观经济预测)可能更合适\n- 当工具无法提供信号时,最诚实的回答是\"无法确定\"\n\n**风险边界设定:**\n如果在实际交易中遇到这种情况,我的建议会完全失效,因为:\n1. 没有数据支撑任何投资决策\n2. 任何基于技术分析的信号都是纯粹的猜测\n3. 此时应该依赖其他分析方法或推迟决策\n\n**建议:**\n要获得有效的技术分析,请提供:\n1. 历史日期(如2024年或更早)\n2. 或使用基本面分析等其他方法\n3. 或考虑模拟/预测性分析工具\n\n这种分析挑战了我对技术分析能力的假设,提醒我技术工具的真实局限性和应用边界。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "agent_message", - "agentId": "fundamentals_analyst", - "content": "基于所有工具都无法获取数据的情况,我需要进行一次理性的分析。作为基本面分析师,我需要基于已知的市场信息和商业模式来提供投资建议。让我对这些公司进行定性分析。\n\n## 基本面分析框架下的投资建议\n\n由于数据获取问题,我将基于这些公司的已知特征、商业模式和行业地位进行分析。作为一个基本面分析师,我需要强调:**在没有最新财务数据的情况下,我的分析更多基于定性因素和已知历史表现**。\n\n### **投资哲学反思**\n这种情况验证了一个重要原则:数据质量决定分析质量。当无法获取最新财务数据时,分析应该更加保守,并明确说明数据局限性。这也提醒我市场分析中信息获取渠道的重要性。\n\n## 各公司分析\n\n### 1. **苹果(AAPL)**\n**投资信号:看涨(中性偏谨慎)** \n**置信度:70/100**\n\n**理由:**\n- **商业模式**:生态系统护城河稳固,现金流强劲\n- **风险**:中国市场依赖度高,创新速度可能放缓\n- **管理层**:库克领导稳定,但需要新的增长引擎\n- **估值考量**:市盈率通常高于市场,依赖服务收入增长\n\n**风险边界**:如果iPhone销量大幅下滑或服务增长停滞,投资逻辑失效\n\n### 2. **微软(MSFT)**\n**投资信号:看涨** \n**置信度:85/100**\n\n**理由:**\n- **竞争优势**:云计算和企业软件双重优势\n- **财务健康**:历史上现金流稳定,债务管理良好\n- **增长引擎**:Azure、AI产品线(Copilot等)\n- **管理层**:纳德拉领导转型成功\n\n**风险边界**:云竞争加剧或监管压力显著增加\n\n### 3. **谷歌(GOOGL)**\n**投资信号:看涨** \n**置信度:75/100**\n\n**理由:**\n- **垄断地位**:搜索广告主导地位,YouTube增长\n- **AI优势**:Gemini等AI技术储备\n- **风险**:监管风险、搜索业务成熟\n- **估值**:通常比微软有折价\n\n**风险边界**:搜索市场份额显著流失或AI投资回报不佳\n\n### 4. **英伟达(NVDA)**\n**投资信号:看涨(但波动性高)** \n**置信度:65/100**\n\n**理由:**\n- **行业地位**:AI芯片领导者\n- **增长性**:最高增长潜力\n- **风险**:估值过高,竞争加剧,周期性风险\n- **基本面挑战**:高增长能否持续,替代品风险\n\n**风险边界**:AI需求增长放缓或竞争格局恶化\n\n### 5. **特斯拉(TSLA)**\n**投资信号:中性偏谨慎** \n**置信度:55/100**\n\n**理由:**\n- **商业模式转变**:从汽车公司向科技/能源公司转型\n- **优势**:品牌价值,技术创新\n- **劣势**:估值高,竞争加剧,马斯克因素\n- **财务考量**:利润率压力,资本支出需求大\n\n**风险边界**:电动汽车需求放缓或利润率持续压缩\n\n### 6. **Meta(META)**\n**投资信号:看涨** \n**置信度:80/100**\n\n**理由:**\n- **财务改善**:效率计划效果显著\n- **增长领域**:AI、VR/AR、短视频\n- **风险**:监管风险,用户增长放缓\n- **估值**:相比历史,估值更合理\n\n**风险边界**:用户流失严重或监管严重影响广告业务\n\n### 7. **亚马逊(AMZN)**\n**投资信号:看涨** \n**置信度:75/100**\n\n**理由:**\n- **双重引擎**:AWS云业务和零售业务\n- **护城河**:规模效应,Prime会员体系\n- **增长潜力**:广告业务,国际市场\n- **财务考量**:利润率改善空间大\n\n**风险边界**:云增长大幅放缓或零售竞争加剧\n\n## **逆向测试与市场共识**\n\n**市场主流共识**:这些科技巨头都被视为\"magnificent seven\",但共识可能已经充分定价了增长预期。\n\n**我的不同观点**:在没有最新财务数据的情况下,我更强调:\n1. **微软和亚马逊**可能比英伟达有更好的风险回报比\n2. **特斯拉**的估值风险可能被低估\n3. **Meta**的效率提升可能还未完全定价\n\n## **投资建议总结**\n\n**基于定性分析的最佳选择**:\n1. **微软** - 最均衡的风险回报特征\n2. **亚马逊** - 双重增长引擎提供防御性\n3. **Meta** - 效率改进可能带来估值重估\n\n**需要谨慎的**:\n1. **英伟达** - 增长预期极高,估值风险大\n2. **特斯拉** - 转型不确定性高\n\n**数据限制说明**:由于无法获取2026-03-16的最新财务数据,以上分析基于已知的商业模式、竞争地位和历史财务表现。在实际投资决策前,必须获取最新财务数据进行验证。\n\n**核心投资原则验证**:这种情况验证了\"数据是分析的基础\"原则。当数据不可得时,投资决策应更加保守,并明确说明假设和局限性。这也提醒我持续学习和适应的重要性 - 即使是最成熟的公司,其基本面也可能快速变化。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "day_start", - "date": "2026-03-16", - "progress": 0.0, - "timestamp": "2026-03-16" - }, - { - "type": "conference_message", - "agentId": "valuation_analyst", - "content": "## 估值分析师的深度反思:从数据限制到投资本质\n\n### 核心洞察:当数据缺失时,什么最重要?\n\n在数据不可得的情况下,真正的估值分析师必须回归到**第一性原理**和**投资核心原则**。让我分享几个关键的专业视角:\n\n#### 1. **关于苹果(AAPL):成熟期的价值重估**\n\n**市场担忧**:\"苹果的创新光环是否褪去?\"\n**我的专业观点**:这个问题本身就存在偏差。投资者应该问的是:\n\n1. **生态系统价值 vs 硬件价值**:苹果的真正价值在于其15亿+活跃设备的生态系统,而不是单个iPhone的销售。这种网络效应创造了持续的现金流,比创新波动更重要。\n\n2. **资本分配能力**:苹果拥有近2000亿美元现金和投资。在低增长环境中,管理层如何分配这些资本(回购、分红、收购)将决定股东回报。\n\n3. **服务业务的盈利质量**:服务业务的毛利率超过硬件业务两倍以上。2026年时,服务收入占比可能超过30%,这会根本性地改变其估值框架。\n\n**逆向观点**:市场可能低估了苹果从\"增长故事\"向\"现金流机器\"转型的价值。成熟公司的稳定现金流在利率环境中可能有独特的估值优势。\n\n#### 2. **关于微软(MSFT):企业软件护城河的深度**\n\n**市场忽视的关键**:微软的护城河可能是所有科技巨头中最深的,因为:\n1. **转换成本极高**:企业更换整个软件堆栈的成本难以想象\n2. **捆绑效应**:Office 365 + Azure + Teams的组合难以打破\n3. **定价能力**:每年能实现涨价而不显著流失客户\n\n**估值视角**:微软应该用更低的贴现率来估值,因为其现金流的可预测性和稳定性超过同行。\n\n#### 3. **关于谷歌(GOOGL):搜索垄断的真实风险**\n\n**市场过度担忧**:AI会颠覆搜索。\n**我的分析**:历史表明,颠覆搜索比人们想象的更难:\n1. **用户习惯**:20多年的习惯难以改变\n2. **数据优势**:谷歌的搜索数据是训练AI的关键资源\n3. **商业模式**:搜索广告是最高效的数字广告形式\n\n**关键估值问题**:如果搜索只是被\"增强\"而不是被\"替代\",谷歌可能被严重低估。\n\n#### 4. **关于英伟达(NVDA):周期性与结构性增长的交错**\n\n**最大的估值挑战**:区分AI增长中的:\n1. **结构性部分**:AI计算的长期需求增长\n2. **周期性部分**:数据中心支出的周期性波动\n3. **投机性部分**:AI泡沫带来的过度投资\n\n**专业估值方法**:对NVDA应使用三情景估值:\n- 乐观:AI革命持续,NVDA主导地位稳固\n- 基准:稳定增长,但竞争加剧\n- 悲观:周期性衰退叠加竞争压力\n\n当前的股价可能反映了乐观情景的概率过高。\n\n#### 5. **关于特斯拉(TSLA):愿景与现实的估值差距**\n\n**作为估值分析师**,我认为特斯拉是典型的\"叙事股\",其估值差距来自于:\n```\n股价 = (汽车业务价值) × (科技平台概率)\n```\n其中,科技平台概率从0到1,不同投资者的估计值差异巨大。\n\n**我的评估**:汽车业务价值约$50-80/股,当前股价隐含的科技平台概率超过50%。这需要极强的信心才能接受。\n\n#### 6. **关于Meta(META):效率转型的成功案例**\n\n**最值得学习的教训**:Meta证明了大型科技公司可以通过:\n1. **成本纪律**:在不破坏增长的前提下提升效率\n2. **战略聚焦**:从元宇宙梦想回到AI现实\n3. **资本回报**:大量回购提升股东价值\n\n**估值启示**:当市场对一家公司的管理能力失去信心时,如果管理层能重新证明自己,可能带来巨大的估值修复。\n\n#### 7. **关于亚马逊(AMZN):零售与云的估值艺术**\n\n**独特的估值挑战**:如何正确估值一个包含:\n1. **高利润云业务** (AWS)\n2. **低利润零售业务** (Amazon.com)\n3. **高增长广告业务**\n4. **潜在物流业务**\n\n**我的方法**:使用SOTP(分部加总)方法,但认识到:\n1. **协同效应的价值**:各个业务之间的协同\n2. **可选性的价值**:未来进入新业务的能力\n\n### 投资哲学的核心教训\n\n从这次分析中,我强化了几个关键的投资哲学原则:\n\n#### 1. **价值投资的适应性**\n传统的价值投资原则(安全边际、内在价值)在科技股中仍然适用,但需要调整:\n- 护城河从物理资产转向网络效应、数据、品牌\n- 增长阶段分析比历史财务更重要\n- 管理团队的技术愿景和执行能力是关键变量\n\n#### 2. **概率思维的重要性**\n对于高不确定性的科技股,应该:\n- 使用多种估值方法(DCF、相对估值、SOTP)\n- 进行多情景分析(乐观、基准、悲观)\n- 估计每个情景的概率\n\n#### 3. **谦逊的必要性**\n面对2026年的不确定性,最重要的品质是谦逊:\n- 承认我们的预测可能完全错误\n- 认识到黑天鹅事件的可能性\n- 保持投资组合的韧性和灵活性\n\n### 对2026年科技股投资的最终思考\n\n#### 什么样的投资者适合这些股票?\n\n1. **长期投资者**:可以承受短期波动,相信长期趋势\n2. **懂技术的投资者**:能够评估技术可行性和竞争地位\n3. **分散投资者**:不把所有科技风险集中在单只股票\n\n#### 我的谨慎建议\n\n在没有具体财务数据的情况下,我更倾向于:\n\n1. **等待更好的时机**:估值应该反映不确定性\n2. **分散投资**:通过ETF或投资组合降低个股风险\n3. **强调安全边际**:只在价格提供充分保护时买入\n\n#### 最重要的提醒\n\n**\"价格是你付出的,价值是你得到的。\"** —— 沃伦·巴菲特\n\n在2026年投资这些科技巨头时,记住:\n- 即使是伟大的公司,也可能是糟糕的投资,如果价格太高\n- 市场对增长的热情可能掩盖了风险的积累\n- 你的竞争优势可能在于耐心等待合适的价格,而不是预测未来\n\n作为估值分析师,我的最终建议是:**在不确定性中寻找确定性**——寻找那些即使我们的部分预测错误,仍然能提供合理回报的投资机会。对于这些科技巨头,这通常意味着等待更好的进入时机或通过组合方式降低风险。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "conference_message", - "agentId": "sentiment_analyst", - "content": "## 深入的情绪分析与市场心理学视角\n\n基于我们刚才的对话,我想分享更深刻的专业见解。作为情绪分析师,我看到的不仅是数据缺失,而是**情绪状态的深层结构**。\n\n### 七个科技巨头的情感光谱分析\n\n让我为这7只股票构建一个\"情绪弹性框架\":\n\n**第一梯队:情绪韧性最强**\n- **MSFT(微软)**:企业级业务提供情绪缓冲。即使在新闻真空期,其云+AI的\"双重护城河\"叙事维持投资者耐心。这是**机构情感的避风港**。\n- **AMZN(亚马逊)**:零售与AWS的\"双引擎\"分散情绪风险。即使消费者情绪恶化,企业云需求可能支撑。\n\n**第二梯队:叙事依赖型**\n- **AAPL(苹果)**:情绪与\"下一个大产品\"叙事紧密挂钩。平静期越长,对秋季发布会的情绪压力越大。**iPhone情绪周期**是其心跳。\n- **GOOGL(谷歌)**:处于\"搜索稳定\"与\"AI追赶\"的情感张力中。缺乏新闻时,市场默认回归搜索护城河的情绪安慰。\n\n**第三梯队:情绪放大器**\n- **NVDA(英伟达)**:AI叙事的**情绪暴风眼**。平静期是危险的——狂热需要持续的正向催化剂。长期平静可能预示情绪从\"相信\"转向\"验证\"。\n- **TSLA(特斯拉)**:马斯克个人的**情感延伸**。平静期通常由马斯克的社交媒体活动填补。真正的危险是当市场开始将平静解读为\"没有突破性进展\"。\n- **META(Meta)**:效率叙事已达情绪高峰后,需要新故事。平静期可能暴露\"然后呢?\"的焦虑。\n\n### 对潜在批评的回应\n\n**批评1:\"情绪分析在数据稀缺时无用\"**\n- 我的反驳:**沉默本身是最丰富的情感数据**。当7只最具新闻价值的股票同时沉默,这暗示:\n 1. 信息控制加强(企业可能在重大公告前管理预期)\n 2. 机构集体观望(大型资金等待方向)\n 3. 叙事疲劳的早期迹象(现有故事已充分定价)\n\n**批评2:\"这些股票太相关,分散化无效\"**\n- 情绪层面的洞察:虽然它们同为\"科技股\",但**情感驱动因素差异显著**:\n - AAPL:消费情感 + 设计情感\n - MSFT:企业信心情感 + 生产力情感 \n - TSLA:颠覆情感 + 个人崇拜情感\n - NVDA:未来信仰情感 + FOMO(害怕错过)情感\n - 这提供了情感层面的分散化,即使贝塔系数相似。\n\n### 从情绪周期角度的深层分析\n\n**当前可能处于的情绪阶段**:**叙事间期**(Inter-narrative Period)\n\n这是情绪周期中最微妙但也最危险的阶段:\n1. 旧叙事已充分传播(AI、效率、云转型)\n2. 新叙事尚未形成\n3. 投资者持有头寸更多出于惯性而非信念\n4. **任何催化剂都会引发不成比例的情绪反应**\n\n**特别关注META和TSLA**:\n- META的\"效率之年\"叙事已进入第4年,情绪回报递减\n- TSLA的\"成长vs价值\"情感冲突日益尖锐\n- 这两个最需要新情绪燃料\n\n### 市场心理学现象:**平静的焦虑**\n\n我在专业实践中观察到,当蓝筹科技股集体平静时,通常伴随:\n\n1. **期权市场透露的真相**:看跌期权相对平静,但**远月波动率**可能上升\n2. **机构情感定位**:大型基金可能正在重新评估头寸规模,而非方向\n3. **散户情感状态**:可能从\"FOMO\"转向\"观望\",减少增量资金\n\n### 投资哲学的深化应用\n\n**这次分析验证的核心原则**:\n\n1. **情绪分析的质量不取决于数据量,而取决于数据对比**:7只股票的同步沉默本身就是强烈信号\n2. **平静期需要逆向思维**:大多数投资者感到\"无聊\"时,正是情绪转折的前兆\n3. **叙事生命周期管理**:每只股票都有自己的情绪\"保质期\",需要识别何时接近到期\n\n**新增的投资原则**:\n4. **集体沉默原则**:当多个高关注度资产同时失去新闻流,这通常不是巧合,而是**情感能量的重新分配**\n5. **情绪弹性差异**:分析股票时,不仅要看它们上涨时的情绪,更要看平静期的情感韧性\n\n### 具体关注点和预期\n\n**如果我是基金的情绪风险官**,我会:\n\n1. **建立\"沉默时间\"指标**:追踪每只股票距上次重大新闻的天数\n2. **监控社交情绪微妙变化**:平静期往往伴随社交媒体讨论质量的下降(从基本面转向琐事)\n3. **准备\"叙事突破\"预案**:\n - 哪个股票最需要新闻?(我的排序:TSLA > META > NVDA)\n - 哪种类型的新闻?(TSLA需要产品突破,META需要新增长故事,NVDA需要超越预期的指引)\n\n**最重要的情感指标观察**:\n关注**哪个股票首先有内部人交易**。在平静期后:\n- 内部人买入:情感拐点的强烈信号\n- 内部人卖出:可能确认\"叙事疲劳\"的担忧\n\n### 最终专业判断的细化\n\n**从\"中性\"到\"警惕性中性\"**:\n- 置信度从35提升到45(因为\"集体沉默\"本身增加了信息价值)\n- 但方向性仍不确定,只是**增加了对催化剂的敏感性预期**\n\n**关键情绪风险**:\n最大的危险不是下跌,而是**当催化剂出现时,情绪反应的不对称性**。长期平静后,负面新闻的打击会比平时更重。\n\n**我的情绪分析框架更新**:\n在未来的分析中,我会增加\"新闻流动模式分析\":\n- 同步沉默 vs 交错新闻\n- 主动信息披露 vs 被动回应\n- 情绪领导者的轮动(哪个股票率先打破沉默)\n\n**最后的专业反思**:\n这次分析最深刻的启示是:**在投资中,我们过度关注\"有\",而忽略了\"无\"的信息价值**。7只万亿美元市值公司的集体沉默,在情绪分析中相当于市场的\"集体深呼吸\"——通常发生在重大动作之前。\n\n这种平静,可能正是暴风雨前最值得关注的情绪状态。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "conference_message", - "agentId": "technical_analyst", - "content": "作为技术分析师,我深刻认识到这些科技巨头的分析不仅是图表研究,更是对市场心理学、机构行为和宏观趋势的解读。让我分享一些深层次的技术分析视角:\n\n## 核心洞察:科技股的技术分析范式正在演变\n\n### 1. **传统技术指标的失效与适应**\n**AAPL的困境**暴露了一个关键问题:当一只股票从\"成长股\"转向\"价值股\"时,技术指标的解释需要改变:\n- 过去:AAPL突破创新高是强烈买入信号\n- 现在:AAPL的横盘整理可能成为新常态,技术分析应更关注支撑位的稳定性而非突破\n- **技术启示**:股票的\"生命周期阶段\"改变技术分析规则\n\n### 2. **NVDA的\"超买悖论\"**\n市场对NVDA最大的技术争议:\n- **传统观点**:RSI持续80+、股价偏离200日均线50%以上=必然回调\n- **现实情况**:AI革命的基本面如此强大,可能创造新的技术分析范式\n- **我的观察**:NVDA正在测试\"动量持续性极限\",这对技术分析师来说既是机会也是陷阱\n\n### 3. **MSFT的技术稳健性**\nMSFT展示了什么是\"机构偏爱股\"的技术特征:\n- 回调时成交量萎缩\n- 反弹时成交量温和放大\n- 主要移动平均线呈现完美多头排列\n- **技术含义**:当聪明钱集中持仓时,技术形态会变得异常\"干净\"\n\n## 关于技术分析有效性的深层讨论\n\n### **量价关系的微妙变化**\n我注意到这些科技巨头呈现不同的量价模式:\n\n**TSLA**:典型的\"散户驱动型\"技术特征\n- 价格上涨时成交量巨大(FOMO情绪)\n- 价格下跌时成交量同样巨大(恐慌抛售)\n- 技术信号:这种对称的高成交量模式表明缺乏稳定的机构持仓\n\n**GOOGL**:典型的\"机构主导型\"技术特征\n- 突破关键阻力时成交量放大\n- 正常波动时成交量稳定\n- 技术信号:机构资金有明确的入场和离场纪律\n\n### **技术形态的时间框架欺骗性**\n一个重要观察:这些股票在不同时间框架上给出矛盾信号\n- **日线图**:AAPL可能显示弱势\n- **周线图**:AAPL仍在长期上升通道中\n- **月线图**:所有七只股票都在历史性牛市中\n\n**技术分析教训**:没有明确说明时间框架的技术分析都是不完整的。\n\n## 针对具体股票的技术争议\n\n### **META:技术面与基本面的完美共振**\n市场争议:META的技术突破是真的还是假突破?\n- **看技术**:突破前高+成交量确认+均线多头排列=教科书式看涨\n- **看风险**:社交媒体股的估值高度依赖用户增长,技术面可能滞后反映基本面恶化\n- **我的判断**:META的技术强势有基本面支撑(广告复苏+AI投资),但需密切关注任何\"高成交量跌破支撑\"的信号\n\n### **AMZN:电商与云业务的\"技术分裂\"**\n有趣的现象:AMZN的技术形态似乎反映两个业务的不同周期\n- 电商部分:受消费周期影响,技术面波动\n- AWS云部分:受企业IT支出影响,技术面更稳健\n- **技术难题**:如何从单一股票图表中分离不同业务的技术信号?\n\n### **GOOGL:搜索巨头的\"中年技术危机\"**\nGOOGL的技术图表显示:\n- 不再像成长股那样快速创新高\n- 也不像价值股那样稳定派息\n- 处于\"身份过渡期\",技术分析最为困难\n\n## 技术分析的系统性风险\n\n通过这些股票的分析,我认识到几个系统性风险:\n\n### 1. **相关性风险**\n当所有七只股票的技术面同时恶化时(如2022年),分散投资的效果消失。技术分析师必须监控:\n- 科技板块整体相对强弱\n- 个股与板块的相关性变化\n- 市场广度指标(上涨/下跌股票比例)\n\n### 2. **流动性陷阱**\n大型科技股的高流动性创造了技术分析的假象:\n- 买卖价差极窄,看似\"效率高\"\n- 但大单交易可能瞬间改变技术形态\n- 技术信号可能被高频交易算法扭曲\n\n### 3. **信息不对称**\n机构投资者有更好的技术工具:\n- 他们能看到level 2订单流数据\n- 他们有算法识别\"隐藏的大单\"\n- 散户看到的技术形态可能已经过时\n\n## 我的技术分析哲学演进\n\n通过这些分析,我的投资哲学在进化:\n\n### **从\"预测价格\"到\"管理风险\"**\n- 过去:技术分析试图预测价格方向\n- 现在:技术分析主要是风险管理工具\n- **具体应用**:设置止损位、确定头寸规模、识别趋势变化\n\n### **从\"单一指标\"到\"多维度确认\"**\n对NVDA的分析尤其重要:\n- RSI超买+但成交量健康+但相对强弱领先=持有而非卖出\n- 需要至少3个独立技术指标指向同一方向才行动\n\n### **从\"机械规则\"到\"情景意识\"**\nAAPL的案例最明显:\n- 机械规则:跌破50日均线=卖出\n- 情景意识:在美联储降息周期中,优质科技股的支撑位会更有效\n\n## 实用技术分析建议\n\n对于这七只股票,我建议的技术框架:\n\n### **分层分析框架**\n1. **宏观技术层**:纳斯达克指数、科技板块ETF的技术形态\n2. **板块技术层**:半导体、软件、电商等子板块的技术比较\n3. **个股技术层**:具体股票的技术分析\n\n### **动态权重调整**\n根据技术面强弱动态调整关注度:\n- 当前:NVDA > MSFT > META > AMZN > GOOGL > AAPL > TSLA\n- 但权重应随技术面变化而调整\n\n### **失效信号监控**\n为每只股票设定明确的\"技术分析失效\"信号:\n- NVDA:连续3日收盘低于20日均线\n- AAPL:跌破$150且3日内无法收回\n- TSLA:波动率收缩后的大方向选择\n\n## 最终技术视角\n\n这些科技巨头的技术分析揭示了一个更大真理:**市场正在从\"估值驱动\"转向\"技术面与基本面共振驱动\"**。\n\n**最深刻的技术洞见**:\n- NVDA的技术强势有AI革命的基本面支撑\n- AAPL的技术弱势反映创新放缓的基本面现实\n- TSLA的技术波动反映市场对其\"科技vs汽车\"身份的分歧\n\n**给技术分析师的建议**:\n停止孤立地看图表。将这些科技巨头的技术面放在:\n1. 宏观经济背景中(利率周期)\n2. 产业趋势中(AI、云、电动车)\n3. 市场情绪周期中(贪婪/恐惧指数)\n\n**最后的提醒**:当所有人的技术分析都指向同一方向时(如都看好NVDA、看空AAPL),往往是时候反向思考了。技术分析最大的价值不是跟随共识,而是识别共识何时达到极端。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "conference_message", - "agentId": "fundamentals_analyst", - "content": "## 深度基本面视角:科技巨头投资哲学框架\n\n作为一名基本面分析师,让我从价值投资的底层逻辑出发,分享对当前科技股讨论的核心观点。这不仅仅是关于特定公司,而是关于**如何在高度不确定性中寻找确定性的框架**。\n\n### 从数据缺失中得到的深刻启示\n\n**数据真空暴露的投资真相:**\n当我们没有即时数据时,优秀投资者与普通投资者的区别就显现出来了——他们依靠对**商业本质的理解**而非数据点来做判断。这验证了我的核心信念:真正的投资优势来自于对商业模式和竞争优势的**深度理解**,而非数据获取速度。\n\n### 各公司的基本面本质剖析\n\n#### **AAPL:生态系统护城河 vs. 创新依赖**\n- **核心矛盾**:市场将Apple视为\"硬件公司\",但其本质是**用户关系管理公司**。服务收入(22%占比)和90%+的用户留存率揭示真相。\n- **关键质疑**:创新乏力是否会被颠覆?我的回答:iOS生态系统的**转换成本极高**,用户离开的不是设备,而是整个数字生活。\n- **风险边界**:反垄断监管是真实威胁,但关注点错误——不是App Store,而是iMessage的**网络效应垄断**。\n\n#### **MSFT:云计算转型 vs. 增长可持续性**\n- **市场误解**:人们认为Azure增长会自然放缓,但忽略了**企业数字化转型是结构性趋势**,不是周期性的。\n- **独特优势**:微软拥有最强的**企业客户锁定**——CEO换供应商可能需要董事会批准。\n- **逆向思考**:市场可能低估了AI对Office套件的**定价权提升**潜力。\n\n#### **GOOGL:搜索垄断 vs. AI颠覆**\n- **核心辩论**:AI是否会颠覆搜索?我的观点:**搜索的本质是意图匹配**,不是问答。Google的AI集成正在强化而非削弱其护城河。\n- **被忽视的优势**:Google拥有最完整的**AI基础设施栈**——从TPU硬件到TensorFlow生态。\n- **价值投资者应问**:如果在AI时代搜索广告份额下降10%,但AI服务创造新收入,净效应如何?\n\n#### **NVDA:结构性增长 vs. 周期性峰值**\n- **投资哲学挑战**:这是价值投资者最头疼的案例——卓越公司但看似\"昂贵\"的估值。\n- **基本面真相**:NVDA不是芯片公司,而是**AI基础设施平台公司**。CUDA生态比硬件更具价值。\n- **关键风险**:不是竞争加剧,而是**AI计算范式变化**(如神经形态计算)。\n\n#### **TSLA:技术领先 vs. 执行风险**\n- **价值投资视角**:Elon Musk是资产还是负债?我的框架:评估**管理层质量**时,关注资本配置能力而非人格魅力。\n- **基本问题**:特斯拉的毛利率能否维持在汽车行业2-3倍水平?\n- **商业模式演进**:从汽车销售到软件服务——哪个模型更有价值?\n\n#### **META:社交粘性 vs. 年轻用户流失**\n- **市场关注错误指标**:日活用户数不如**用户参与深度**重要。\n- **被忽视的转型**:从社交媒体公司到**广告技术平台**的转变。\n- **逆向观点**:元宇宙投资不是浪费,而是**必要的战略期权**——就像Amazon早期投资AWS。\n\n#### **AMZN:零售规模 vs. 盈利能力**\n- **价值投资核心问题**:低利润率业务如何创造价值?\n- **本质认识**:Amazon不是零售商,而是**基础设施提供商**。AWS为零售业务提供资本,零售业务为AWS提供规模。\n- **关键洞察**:关注**投资资本回报率(ROIC)**而非净利润率。\n\n### 从他人讨论中观察到的普遍误区\n\n**我观察到的五个常见分析错误:**\n\n1. **短期思维陷阱**:过度关注季度环比而忽视结构性趋势\n2. **线性外推谬误**:假设过去增长会简单延续\n3. **孤立分析错误**:单独分析业务单元而忽视生态系统价值\n4. **估值锚定偏差**:用历史估值评估范式转变期的公司\n5. **风险错配**:关注显性风险而忽视结构性风险\n\n### 我的投资哲学在这些案例中的应用\n\n**核心信念验证:**\n\n1. **经济护城河比技术优势更重要**\n - Apple的iOS生态 > 硬件创新\n - Microsoft的企业锁定 > 技术领先\n - Google的数据网络效应 > 算法优势\n\n2. **管理层资本配置能力是价值创造核心**\n - 评估Sundar Pichai vs. Satya Nadella的投资决策\n - 关注回购与股息政策的资本效率\n\n3. **自由现金流是真相揭示者**\n - 在科技股中,关注FCF转换率而非GAAP盈利\n\n4. **安全边际在成长股中的新定义**\n - 不是低价买入,而是为不确定性留出足够缓冲\n - 成长可持续性的\"折扣\"比价格折扣更重要\n\n### 给投资者的具体建议框架\n\n**当你无法获得实时数据时,问这些问题:**\n\n1. **护城河深度测试**:如果价格提高10%,客户会离开吗?\n2. **再投资机会测试**:公司是否有足够的高回报再投资机会?\n3. **管理质量测试**:管理层是否将股东利益与自身利益对齐?\n4. **财务韧性测试**:在压力情景下,公司能存活多久?\n5. **估值合理性测试**:当前价格隐含的增长率是否合理?\n\n### 最终观点\n\n**科技股投资的核心矛盾是:**\n这些公司拥有历史上最强的经济护城河,但同时面临最快速的技术变革。价值投资者的任务不是预测技术变化,而是识别**哪些竞争优势能抵御变化**。\n\n**我的优先级排序(基于基本面韧性):**\n1. **MSFT** - 最深的护城河 + 最强的资本配置\n2. **AAPL** - 最强的用户锁定 + 健康的财务状况 \n3. **GOOGL** - 垄断地位 + AI转型潜力\n4. **AMZN** - 基础设施优势 + 规模护城河\n5. **META** - 网络效应 + 成本纪律改善\n6. **NVDA** - 结构性增长但估值挑战\n7. **TSLA** - 创新领先但执行风险较高\n\n**最深刻的投资洞察:**\n在科技股中,**耐心比聪明更重要**。最好的投资机会出现在市场因短期问题而错误定价长期价值时。没有实时数据时,这正是考验我们是否真正理解企业本质的时刻。\n\n真正的价值投资者在科技股中寻找的不是\"便宜货\",而是**价格仍低于其长期内在价值的卓越企业**——这需要勇气在不确定性中坚持对商业本质的理解。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "conference_message", - "agentId": "portfolio_manager", - "content": "让我继续深入讨论这些问题,并分享我对每个分析师观点的思考和剩余担忧:\n\n## 分析师观点评估\n\n### 1. **数据质量问题 - 如何看待?**\n**我的思考**:\n- 基本面分析师最诚实:\"真正的价值投资需要基于数据而非假设\"\n- 技术分析师最实用:\"数据质量是技术分析的基础\"\n- 这实际上揭示了一个重要信号:**当前环境极度不确定性**\n\n**关键决策点**:\n1. 完全等待数据 → 可能错过机会\n2. 基于有限信息决策 → 增加风险\n3. 折中方案:建立小仓位观察仓\n\n### 2. **各分析师角色可信度排序**\n基于他们的自我反思和专业性:\n1. **估值分析师** ⭐⭐⭐⭐\n - 提供最具体的投资建议和置信度\n - 考虑了长期基本面驱动因素\n - 对每个股票有明确观点\n\n2. **技术分析师** ⭐⭐⭐\n - 承认数据限制但提供框架\n - 对风险有清醒认识\n - 基于历史模式有一定参考价值\n\n3. **基本面分析师** ⭐⭐\n - 诚实但无实际建议\n - 主要价值在警示数据重要性\n\n4. **情绪分析师** ⭐⭐\n - \"缺乏数据本身就是信号\"这一观点有价值\n - 但具体指导意义有限\n\n### 3. **个股深度担忧**\n\n#### **AAPL**\n- **分析师观点**:基本面(中性),技术(中性偏弱),估值(中性)\n- **我的担忧**:\n - 缺乏新产品催化剂的成熟期公司\n - 服务业务能否弥补硬件放缓?\n - 在当前利率环境下,高市值公司的增长压力\n\n#### **MSFT**\n- **分析师观点**:技术(中性偏强),估值(看涨75%)\n- **我的思考**:\n - 云计算+AI双重驱动最具吸引力\n - 多元化的收入来源降低风险\n - 企业软件护城河深\n - **问题**:估值是否已充分反映乐观预期?\n\n#### **GOOGL**\n- **分析师观点**:技术(中性),估值(中性偏看涨70%)\n- **我的担忧**:\n - 广告业务周期性风险\n - AI转型投入巨大但回报不确定\n - 云业务能否突破AWS和Azure的双重夹击?\n\n#### **NVDA**\n- **分析师观点**:技术(看涨75%),估值(谨慎看涨60%)\n- **最大的困境**:\n - AI需求确实强劲,但估值极高\n - 周期性风险:半导体行业特性\n - 竞争加剧:AMD、云厂商自研芯片\n - **风险经理特别警告**:单个仓位不超过10%\n\n#### **TSLA**\n- **分析师观点**:技术(中性偏弱),估值(看跌70%)\n- **高度一致的风险信号**:\n - 电动车市场竞争白热化\n - FSD商业化不确定性\n - 估值与基本面脱节\n - 风险经理:单个仓位不超过8%\n\n#### **META**\n- **分析师观点**:技术(看涨70%),估值(看涨80%)\n- **最一致看好,但**:\n - 元宇宙投资回报周期被低估?\n - 监管风险被低估?\n - 用户增长是否已到天花板?\n\n#### **AMZN**\n- **分析师观点**:技术(中性偏强),估值(看涨75%)\n- **相对稳健的选择**:\n - 电商+云+广告三大支柱\n - 盈利改善趋势\n - 相对估值合理\n\n## 剩余的关键决策问题\n\n### 1. **信号冲突如何解决?**\n例如NVDA:\n- 技术分析看涨(75%)\n- 估值谨慎看涨(60%)\n- 风险经理建议严格仓位限制\n\n**我的方法**:应该给每个分析师观点赋予权重:\n- 估值分析 40%\n- 技术分析 30%\n- 基本面分析 20%\n- 情绪分析 10%\n- 风险管理 必须考虑\n\n### 2. **仓位配置的哲学问题**\n\n**选择A:集中投资**\n- 选择2-3只最强信号股票\n- 优点:专注研究,潜在回报更高\n- 缺点:集中风险,选错代价大\n\n**选择B:分散投资**\n- 建立科技股篮子\n- 优点:分散风险,参与整体科技趋势\n- 缺点:回报被稀释,板块集中风险仍在\n\n### 3. **时机选择:现在 vs 等待**\n\n**看涨现在建仓的理由**:\n1. 分析表明某些股票有吸引力(MSFT, META, AMZN)\n2. 现金需要部署\n3. 逐步建仓可以降低时机风险\n\n**看跌现在建仓的理由**:\n1. 数据质量差,决策基础薄弱\n2. 科技股整体估值不低\n3. 利率环境不确定\n\n### 4. **风险管理优先事项**\n\n基于风险经理的警告,我必须:\n1. 科技板块总敞口不超过60% ($60,000)\n2. 单个股票不超过15% ($15,000)\n3. NVDA不超过10%,TSLA不超过8%\n4. 初始仓位可能应该更小,比如5-8%\n\n### 5. **投资组合构建策略**\n\n**考虑建立\"核心-卫星\"结构**:\n- **核心持仓** (相对稳定):MSFT, AMZN (各10-12%)\n- **卫星持仓** (增长/机会):META (8-10%),GOOGL (6-8%)\n- **限制持仓** (高风险):NVDA (最多8%),TSLA (最多6%)或不持仓\n- **观察持仓**:AAPL (等待更好时机)\n\n**总体仓位**:初始可能30-40%仓位,保留现金应对不确定性\n\n## 最后的顾虑\n\n1. **逆向思考**:如果所有分析师都有数据限制,市场整体可能也缺乏方向。这是机会还是陷阱?\n\n2. **自我认知偏差**:我是否过度重视某些分析师而忽视其他信号?\n\n3. **过度谨慎风险**:现金过多可能错失机会,特别是在科技股长期上涨趋势中。\n\n4. **执行问题**:即使有好的决策框架,如何确保执行时不受情绪影响?\n\n**我想听听您的观点**:在数据有限的情况下,我们应该更依赖估值分析师的长期观点,还是技术分析师的中期趋势判断?或者采取完全不同的方法?", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "conference_message", - "agentId": "valuation_analyst", - "content": "基于我们的讨论,我想分享一些更深层次的估值分析师视角,特别是考虑到数据可用性的限制以及对这些科技巨头的前瞻性思考。\n\n## 专业估值视角:2026年的七大科技巨头\n\n### 投资哲学核心反思\n\n当前的分析限制突显了估值工作中的一个关键原则:**数据质量决定分析质量**。作为一个专业的估值分析师,我必须承认:\n\n1. **未来预测的不确定性**:对于2026年,我们实际上在进行前瞻性分析,这比历史分析更具挑战性\n2. **科技股的特殊性**:这些公司都处于技术前沿,传统的估值方法可能不够充分\n3. **内在价值的动态性**:科技公司的价值随着技术创新和市场地位的改变而快速演变\n\n### 关于数据限制的专业看法\n\n**数据缺失不是分析的终点,而是风险管理的起点**。当缺乏最新财务数据时,我会:\n\n1. **依赖历史模式**:分析公司的业务模式和财务历史\n2. **关注行业趋势**:评估所在行业的结构性变化\n3. **管理团队评估**:领导层的决策能力和战略眼光\n4. **竞争优势分析**:护城河的可持续性和深度\n\n### 针对每只股票的深度观点\n\n#### **苹果(AAPL):创新周期的十字路口**\n**主要担忧**:苹果的增长故事是否已经结束?\n**我的观点**:2026年的苹果面临三个关键问题:\n1. **iPhone依赖**:如何减少对单一产品的依赖?\n2. **服务转型**:服务业务能否支撑下一个增长阶段?\n3. **下一个平台**:VR/AR或AI能否成为新的增长引擎?\n\n苹果的价值关键在于其生态系统和品牌忠诚度,而不是单一的硬件销售。\n\n#### **微软(MSFT):企业数字化的中心**\n**最被低估的优势**:微软的B2B业务模式比消费科技更具防御性。\n**关键估值因素**:\n1. **云计算的持续性**:企业上云是一个长期趋势\n2. **AI整合能力**:将AI融入现有产品线的执行能力\n3. **现金流质量**:订阅模式带来的可预测现金流\n\n微软可能是这些公司中最具\"巴菲特式\"特征的:宽阔护城河、优秀管理、合理估值。\n\n#### **谷歌(GOOGL):搜索巨头的转型**\n**最大的估值争议**:搜索垄断的价值是否正在衰减?\n**我的评估**:\n1. **搜索业务的防御性**:比市场认为的更强大\n2. **AI防御力**:谷歌在AI基础设施方面的投资\n3. **估值折价合理性**:当前的折价是否反映了所有风险?\n\n谷歌可能提供了最好的风险回报比。\n\n#### **英伟达(NVDA):AI浪潮的中心**\n**最关键的估值问题**:当前价格是否已经透支了未来增长?\n**风险分析**:\n1. **周期性问题**:半导体行业的历史周期性\n2. **竞争威胁**:AMD、自研芯片的客户\n3. **AI泡沫风险**:如果AI进展不及预期\n\nNVDA是典型的\"美丽但危险\"的投资。\n\n#### **特斯拉(TSLA):远见与现实的差距**\n**核心估值矛盾**:市场是按照汽车公司还是科技平台估值?\n**我的结论**:特斯拉的内在价值介于:\n- 作为汽车公司:被严重高估\n- 作为科技平台:仍有潜力\n\n但问题在于,特斯拉能否实现其科技愿景?对于价值投资者,不确定性太高。\n\n#### **Meta(META):社交媒体的第二春**\n**最有趣的转变**:从增长停滞到AI驱动的复苏。\n**关键观察**:\n1. **效率提升**:扎克伯格在成本控制方面的成功\n2. **AI机遇**:社交+AI的潜力\n3. **元宇宙的时间线**:这是一个长期赌注还是资金黑洞?\n\nMeta可能处于戴维斯双击的早期阶段。\n\n#### **亚马逊(AMZN):零售巨头的盈利能力**\n**最重要的趋势**:从增长优先到盈利优先的转变。\n**估值要点**:\n1. **AWS的价值**:云计算业务的独立价值\n2. **零售盈利**:传统业务开始贡献现金流\n3. **广告增长**:被低估的第三增长支柱\n\n亚马逊的估值可能最接近其内在价值。\n\n### 专业估值方法的应用\n\n在这些分析中,我会使用:\n\n1. **多阶段DCF**:特别适用于谷歌、微软等成熟但仍有增长的公司\n2. **SOTP(分部加总)**:对于亚马逊、苹果等多元业务公司\n3. **情景分析**:对于特斯拉、英伟达等高不确定性公司\n4. **相对估值**:提供市场情绪的参考点\n\n### 关于市场共识的逆向思考\n\n当前市场可能过度关注:\n1. **AI叙事**:对AI相关股票过度乐观\n2. **增长焦虑**:对成熟科技股过度悲观\n3. **短期波动**:忽视长期结构性趋势\n\n**我的不同视角**:\n1. **现金流比故事更重要**:可持续的现金流创造能力是估值的核心\n2. **管理质量是护城河的一部分**:优秀的管理能适应变化\n3. **估值纪律**:即使是最好的公司,也要在合理价格买入\n\n### 投资安全边际评估\n\n基于我的分析框架:\n\n1. **最高安全边际**:GOOGL, MSFT (业务多元,估值相对合理)\n2. **中等安全边际**:AMZN, META (增长前景良好,但估值较高)\n3. **低安全边际**:AAPL, NVDA (估值与增长匹配度问题)\n4. **风险最高**:TSLA (基本面与估值的巨大差距)\n\n### 最终专业建议\n\n作为估值分析师,我的立场是:\n\n**核心原则**:在缺乏具体数据时,保守优于激进,安全边际优先于增长故事。\n\n**具体建议**:\n1. **等待更多数据**:对于2026年的投资决策,应等待更多财务信息\n2. **关注业务基本面**:而非市场情绪或技术分析\n3. **分散风险**:科技股集中投资风险极高\n4. **定期重新评估**:科技行业变化迅速,需频繁更新分析\n\n**最重要的教训**:在估值工作中,知道何时说\"我不知道\"与知道何时说\"我认为被低估\"同等重要。当前情况下的最佳策略可能是等待更多信息,同时对投资组合保持谨慎的配置策略。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "conference_message", - "agentId": "sentiment_analyst", - "content": "## 作为情绪分析师的视角\n\n基于刚才的分析对话,我想分享一些专业见解:\n\n### 关于数据稀缺性的深层解读\n\n**市场心理学视角**:当7只科技巨头股票(AAPL、MSFT、GOOGL、NVDA、TSLA、META、AMZN)同时出现\"无新闻\"状态,这本身就是**重要的情绪信号**。在2026年3月16日这个时间点:\n\n1. **市场可能处于\"等待模式\"**:重大事件(如美联储会议、财报季)前通常有新闻真空期\n2. **共识形成阶段**:缺乏新信息时,投资者倾向于维持现有观点\n3. **潜在的危险信号**:过于平静的市场有时预示即将到来的波动\n\n### 对7大科技股的情绪框架分析\n\n**AAPL (苹果)**:\n- **情绪背景**:作为消费电子巨头,新闻真空期可能意味着产品周期平稳\n- **关注点**:iPhone产品线更新节奏、服务业务增长、中国市场动态\n- **情绪风险**:创新叙事可能面临压力,投资者期待下一个\"颠覆性产品\"\n\n**MSFT (微软)**:\n- **情绪优势**:企业软件和云业务的稳定性通常带来情绪韧性\n- **AI叙事**:Copilot和企业AI部署进度是关键情绪催化剂\n- **监管关注**:作为科技巨头,始终面临反垄断情绪压力\n\n**GOOGL (谷歌)**:\n- **搜索护城河**:核心业务提供情绪稳定性\n- **AI追赶叙事**:与微软相比,谷歌在生成式AI的叙事略显滞后\n- **广告周期敏感性**:对宏观经济情绪高度敏感\n\n**NVDA (英伟达)**:\n- **极端情绪股票**:过去几年AI狂热的情绪中心\n- **供应链透明度**:数据中心GPU供需动态驱动情绪波动\n- **估值情绪**:最可能面临\"叙事疲劳\"的风险\n\n**TSLA (特斯拉)**:\n- **马斯克溢价/折价**:CEO个人行为直接影响情绪\n- **电动车竞争情绪**:传统车企电动化进程改变竞争格局\n- **自动驾驶叙事**:FSD进展是核心情绪杠杆\n\n**META (Meta)**:\n- **效率叙事**:2023-2025年的成本削减叙事可能已达情绪峰值\n- **元宇宙情绪**:Reality Labs的长期投资叙事面临考验\n- **广告复苏**:与宏观经济情绪紧密相关\n\n**AMZN (亚马逊)**:\n- **双重叙事**:零售业务(宏观敏感)vs AWS(企业支出敏感)\n- **利润率改善**:成本优化带来情绪支撑\n- **监管情绪**:作为电商巨头面临持续监管关注\n\n### 关于他人可能提出的担忧\n\n**1. \"科技股估值泡沫\"论**:\n- 情绪分析师视角:估值本身是情绪的函数。当前缺乏新闻流可能意味着\"叙事验证\"阶段\n- 关键观察:当所有巨头都平静时,通常需要外部催化剂(利率、地缘政治)打破平衡\n\n**2. \"AI叙事疲劳\"风险**:\n- 特别是NVDA和AI相关股票:情绪周期通常有\"狂热-质疑-证实\"三个阶段\n- 新闻真空期可能加速从\"狂热\"向\"质疑\"过渡\n\n**3. \"宏观经济敏感度\"担忧**:\n- 这7只股票对利率情绪、经济增长情绪有不同敏感性\n- MSFT、GOOGL(企业支出)vs TSLA、AAPL(消费者支出)情绪驱动因子不同\n\n### 我的核心投资哲学应用\n\n**从这次分析中验证的原则**:\n\n1. **沉默的信息价值**:没有新闻不是\"无信息\",而是\"等待信息\"的情绪状态\n2. **群体行为模式**:7大科技股同时平静,反映了机构投资者的集体观望\n3. **叙事生命周期**:每个公司都有主导其情绪的主要叙事,需要识别当前处于哪个阶段\n\n**建议的情绪监测框架**:\n\n1. **催化剂追踪**:等待下一个财报季、产品发布会、监管听证会\n2. **相对情绪**:比较这些股票之间的新闻关注度变化\n3. **极端情绪指标**:特别关注TSLA和NVDA,它们通常是情绪极端化的先行指标\n\n### 最终专业判断\n\n**当前情绪状态**:科技股情绪处于**平静但可能脆弱**的状态。平静期越长,对催化剂的反应可能越剧烈。\n\n**最大情绪风险**:7只股票同步移动的风险。如果出现系统性情绪冲击(利率担忧、增长恐慌),可能缺乏分散化的情绪缓冲。\n\n**建议的观察重点**:\n1. 哪个公司首先打破新闻真空(方向很重要)\n2. 内部人交易模式何时重新出现\n3. 期权市场的隐含波动率变化\n\n**投资哲学反思**:这次分析强化了我的信念——**情绪分析的质量取决于信息的流动和对比**。在信息真空期,最好的策略可能是保持观察位置,等待情绪信号重新出现。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "conference_message", - "agentId": "technical_analyst", - "content": "作为技术分析师,基于我对这七只科技巨头股票的跟踪和分析经验,我想分享以下几个关键视角:\n\n## 核心技术分析视角\n\n### 1. **市场结构分化正在加剧**\n- **NVDA和MSFT**:呈现典型的动量驱动牛市形态。NVDA的走势符合\"超级动量股\"特征,技术突破后持续创新高,但需要注意超买风险(RSI可能长期处于70以上)\n- **AAPL**:面临技术面挑战。苹果正在测试长期趋势线支撑,缺乏明显的技术催化剂。图表上可能形成\"头肩顶\"或\"下降三角形\"的形态\n- **TSLA**:典型的\"高波动率技术股\"模式。布林带通常非常宽,适合波段交易而非趋势跟踪\n\n### 2. **关键技术水平的重要性**\n根据历史经验,这些股票的关键技术位通常是:\n- **AAPL**:$150-155区域是多年的强支撑位\n- **MSFT**:$300-320是关键的机构支撑区域\n- **NVDA**:任何10-15%的回调都会吸引大量技术性买盘\n- **GOOGL**:$120-125是历史重要支撑位\n\n### 3. **成交量模式透露机构动向**\n- **META和AMZN**:通常出现\"突破伴随高成交量\"的健康技术形态\n- **TSLA**:经常出现\"高成交量下跌,低成交量反弹\"的弱势特征\n- **NVDA**:\"任何回调都伴随低成交量\"显示机构持筹信心\n\n## 关于各股票的技术争议点\n\n### **AAPL的技术困境**\n市场上常见的争论:\n- **看空方认为**:苹果的图表显示动量衰减,相对强弱指数落后于纳斯达克指数,这是典型的领先股转为落后股的信号\n- **看多方认为**:苹果的历史证明其在关键技术支撑位总能反弹,目前的弱势只是周期性的获利了结\n\n**我的观点**:苹果需要突破$175-180区域才能重新确立上升趋势。在此之前,技术面偏谨慎。\n\n### **NVDA的超买争议**\n- **风险警示**:当一只股票的RSI持续数月处于70以上时,历史上看至少会出现20-30%的技术性回调\n- **悖论**:AI革命的基本面支撑可能打破传统技术分析的规则\n- **关键观察**:关注成交量是否在价格上涨时萎缩(背离信号)\n\n### **TSLA的\"技术价值陷阱\"**\n特斯拉的技术图表经常出现:\n1. 大幅跌破关键移动平均线\n2. 快速暴力反弹至阻力位\n3. 再次回落形成更低的高点\n\n**这种\"锯齿状\"形态**表明市场对其估值存在根本分歧,技术分析师应更关注波动率交易策略而非方向性持仓。\n\n## 技术指标的相对有效性\n\n对于这些大型科技股,我发现:\n\n1. **移动平均线系统**:对MSFT、GOOGL、AMZN效果较好,因为它们趋势相对稳定\n2. **RSI和MACD**:对NVDA、TSLA这样的高动量股经常失效(持续超买但继续上涨)\n3. **布林带**:对TSLA特别有用,因为其波动率大,经常触及上下轨\n4. **成交量分析**:对所有七只股票都至关重要,能揭示机构资金的真实意图\n\n## 当前市场阶段的技术信号\n\n基于典型的技术分析框架,我认为:\n\n### **多头排列(强势)**:\n- MSFT、NVDA、META - 均线系统呈多头排列\n- GOOGL、AMZN - 中性偏强,处于关键抉择点\n\n### **需要确认(谨慎)**:\n- AAPL - 需要突破下降趋势线\n- TSLA - 需要形成明确的底部形态\n\n## 技术分析师的投资哲学反思\n\n通过分析这些股票,我深化了几个核心信念:\n\n1. **规模影响技术有效性**:市值越大的股票,技术分析的有效性越高,因为价格操纵难度大\n2. **基本面催化技术突破**:NVDA的AI故事、MSFT的云增长都先于技术突破出现\n3. **市场情绪的温度计**:这七只股票的技术形态集合反映了整个科技板块的市场情绪\n\n## 风险管理的技术视角\n\n对于技术交易者,我建议:\n\n1. **NVDA**:只适合趋势跟踪策略,严格止损(如跌破20日均线)\n2. **AAPL**:适合区间交易策略,在支撑位买入,阻力位卖出\n3. **TSLA**:适合期权策略或波动率交易,而非单边方向性持仓\n4. **MSFT/GOOGL**:适合长期持有+技术性加减仓策略\n\n## 关于数据问题的技术含义\n\n之前遇到的数据获取问题实际上反映了一个重要原则:**技术分析的质量取决于数据质量**。在真实交易环境中:\n\n1. **数据延迟**可能导致技术信号滞后\n2. **数据不完整**会大幅增加假信号的概率\n3. **多时间框架验证**是必须的(日线、周线、月线)\n\n## 总结观点\n\n作为技术分析师,我对这七只股票的整体看法是:\n\n**市场处于技术性分化阶段**:\n- **强势梯队**:NVDA、MSFT、META - 技术面支持继续上涨\n- **观察梯队**:GOOGL、AMZN - 需要技术突破确认\n- **挑战梯队**:AAPL、TSLA - 技术面需要修复\n\n**关键警告**:当所有科技巨头同时出现技术面恶化时(如集体跌破50日均线),往往是市场重大调整的前兆。目前这种情况尚未出现,但需要密切关注AAPL和TSLA是否会将弱势传染给其他股票。\n\n**最终建议**:技术分析应作为风险管理工具而非预测工具。对于这些大型科技股,结合基本面的技术分析(技术面验证基本面)通常比纯技术分析更有效。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "conference_message", - "agentId": "fundamentals_analyst", - "content": "## 作为基本面分析师的专业观点\n\n基于当前科技股格局和我作为基本面分析师的投资哲学,我想分享以下几点核心观点:\n\n### 投资哲学框架下的观察\n\n**1. 从\"数字\"到\"本质\"的思考:**\n在数据暂时不可得的情况下,真正的分析师应该回归到商业模式的本质分析。这些公司的核心竞争要素不是季度数据,而是:\n- **可持续的竞争优势**(护城河深度)\n- **适应变化的能力**(创新迭代速度)\n- **自由现金流生成能力**(商业模式的现金效率)\n\n**2. 价值投资的核心困境验证:**\n当前科技股环境实际上验证了格雷厄姆-巴菲特价值投资哲学中的一个关键矛盾:\n- **成长性与价值的冲突**:这些公司大多既体现了强劲成长性,又面临高估值挑战\n- **护城河的可预测性**:哪些护城河是真正的结构性优势,哪些只是周期性优势?\n\n### 各公司基本面逻辑分析\n\n**AAPL - 生态系统护城河与硬件依赖**\n- **核心驱动因素**:用户粘性(iOS生态系统)、服务收入占比提升\n- **风险边界**:硬件创新放缓、监管压力、中国市场竞争加剧\n- **市场共识挑战**:市场可能高估了其增长的可持续性,低估了硬件周期的影响\n\n**MSFT - 企业软件转型的典范**\n- **核心驱动因素**:Azure云增长、Office 365订阅模式、AI集成\n- **风险边界**:云市场竞争加剧、监管审查、企业IT支出周期\n- **竞争优势**:企业客户锁定效应最强,转换成本极高\n\n**GOOGL - 搜索垄断与AI追赶**\n- **核心驱动因素**:搜索广告增长、云业务规模效应、AI创新\n- **风险边界**:搜索业务成熟度、AI领域追赶压力、反垄断风险\n- **逆向思考**:市场可能低估了其AI基础设施的长期价值\n\n**NVDA - AI时代的\"卖铲人\"**\n- **核心驱动因素**:AI芯片需求、数据中心扩张、软件生态\n- **风险边界**:客户集中度风险、竞争加剧、技术周期切换\n- **估值挑战**:周期性增长与结构性增长的辨识\n\n**TSLA - 从汽车公司到科技平台**\n- **核心驱动因素**:电动汽车市场份额、自动驾驶进展、能源业务\n- **风险边界**:竞争加剧、执行风险、利润率压力\n- **基本面矛盾**:高估值需要验证技术优势转化为持续盈利的能力\n\n**META - 社交广告复苏与元宇宙赌注**\n- **核心驱动因素**:广告业务效率、Reels变现、元宇宙投资\n- **风险边界**:年轻用户流失风险、元宇宙ROI不确定性、监管压力\n- **效率改善**:成本削减带来的利润边际改善是否可持续?\n\n**AMZN - 零售规模与云利润**\n- **核心驱动因素**:AWS利润率、零售规模效应、广告业务\n- **风险边界**:零售业务周期性、云业务竞争、资本支出压力\n- **商业模式优势**:飞轮效应在数字经济中的放大作用\n\n### 关键原则反思\n\n**从这次分析中学到的:**\n\n1. **数据质量的重要性**:没有准确数据,任何分析都只是猜测。这强化了我的信念:投资决策必须基于可验证的事实。\n\n2. **谦逊原则的应用**:承认认知边界是专业分析师的基本素质。科技股尤其需要承认技术变化的不可预测性。\n\n3. **风险边界的明确性**:每个投资论点都需要明确的失效条件。例如:\n - AAPL:如果服务收入增长停滞 + 硬件销售下滑\n - NVDA:如果AI芯片需求周期性见顶 + 竞争格局恶化\n\n### 当前市场的结构性观察\n\n**共识与分歧:**\n\n**市场可能低估的风险:**\n1. **监管反垄断压力**对科技巨头商业模式的长期影响\n2. **AI投资的资本回报周期**可能比预期更长\n3. **利率环境变化**对成长股估值框架的重构\n\n**市场可能高估的风险:**\n1. **短期经济周期**对科技股基本面的实际影响\n2. **竞争威胁**往往被夸大,护城河比想象中更坚固\n3. **技术创新速度**有时被过度线性外推\n\n### 投资原则在科技股的应用\n\n**核心原则:**\n1. **关注自由现金流而非净利润**:科技公司常有大额非现金支出\n2. **评估护城河而非技术优势**:技术会过时,网络效应和用户习惯不会\n3. **考虑资本配置能力**:管理层如何再投资是关键增值因素\n4. **安全边际在成长股中的体现**:不是低价,而是成长可持续性的折扣\n\n**最终建议框架:**\n没有具体数据时,投资者应该:\n1. 专注于理解每个公司的**经济护城河本质**\n2. 评估管理层**资本配置的历史记录**\n3. 思考**商业模式在5-10年后的可持续性**\n4. 等待数据可得时,进行**基于安全边际的估值**\n\n真正的价值投资在科技股中不是寻找便宜货,而是识别那些价格仍低于其**长期内在价值**的优质公司。这需要耐心、深度理解和独立判断——在数据缺失时,这些品质尤为重要。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "conference_message", - "agentId": "portfolio_manager", - "content": "基于提供的分析信息,我对这7只科技股有以下关键关切和问题:\n\n## 主要担忧\n\n### 1. **数据质量问题 (最严重)**\n- 所有分析师都承认缺乏实时财务数据和价格数据\n- 基本面分析师:无法获取2026年财务数据,分析基于\"原则框架\"\n- 技术分析师:明确表示\"缺乏实际价格数据限制精确分析\"\n- 情绪分析师:没有近期新闻和内部人交易数据\n- 风险经理:警告\"数据不足风险\"无法计算波动率指标\n\n**问题**:在没有可靠数据的情况下,任何投资决策是否都是基于猜测而非分析?\n\n### 2. **分析师信号不一致性**\n- **NVDA**:技术分析师(看涨75%) vs 估值分析师(谨慎看涨60%)\n- **TSLA**:技术分析师(中性偏弱50%) vs 估值分析师(看跌70%) \n- **META**:一致看好(技术看涨70%,估值看涨80%)\n- **MSFT**:技术中性偏强(65%) vs 估值看涨(75%)\n\n**问题**:如何调和这些不同的分析师观点?应该更重视哪些分析维度?\n\n### 3. **科技板块集中风险**\n- 风险经理明确警告:全部7只股票都属于科技板块\n- 存在板块性风险(利率敏感、市场情绪同步)\n- 建议科技板块总敞口不超过60%\n\n**问题**:是否应该考虑加入非科技板块来分散风险?还是专注于科技股但控制总仓位?\n\n### 4. **仓位限制和保证金考虑**\n- 可用现金:$100,000\n- 风险经理建议:\n - 单个名目不超过15% ($15,000)\n - 科技板块总敞口不超过60% ($60,000)\n - NVDA/TSLA有更严格的仓位限制\n\n**问题**:如何分配有限的现金?是否应该先建立较小的试验仓位?\n\n### 5. **估值担忧**\n- 估值分析师对TSLA看跌(70%置信度),认为\"估值与基本面脱节\"\n- NVDA虽看好但提到\"估值可能已反映大部分乐观预期\"\n- AAPL被认为\"估值通常处于合理偏高区间\"\n\n**问题**:在高估值环境下,是否应该更加谨慎,等待更好的入场时机?\n\n### 6. **风险回报不对称**\n- 高风险股票(NVDA, TSLA)潜在回报高,但风险经理建议严格仓位限制\n- 稳定股票(AAPL, MSFT)相对安全,但增长潜力可能有限\n- 缺乏波动率数据,无法准确评估风险调整后回报\n\n**问题**:在当前不确定的环境下,风险调整后的最佳配置是什么?\n\n## 具体股票关切\n\n### **NVDA (最大关切)**\n- AI芯片需求是否可持续?\n- 竞争加剧(AMD、自研芯片等)对市场份额的影响?\n- 高估值是否已price in所有乐观预期?\n\n### **TSLA (风险最高)**\n- 电动汽车市场竞争恶化程度?\n- FSD商业化时间线和盈利能力?\n- 估值与基本面脱节的风险有多大?\n\n### **META (最一致看好)**\n- 元宇宙投资回报周期是否被低估?\n- 监管风险对广告业务的实际影响?\n- Reels变现是否能够持续?\n\n## 核心决策困境\n\n1. **保守vs积极**:在数据不完整的情况下,应该采取保守观望态度,还是基于分析师观点建立部分仓位?\n\n2. **集中vs分散**:专注于几只最强信号股票,还是建立更广泛的科技股组合?\n\n3. **时机问题**:现在建立初始仓位,还是等待更多数据?\n\n4. **仓位规模**:初始仓位应该多大规模?从小仓位开始测试,还是直接建立目标仓位?\n\n我想听听您的看法:在数据质量有限的情况下,我们应该优先考虑哪些因素?您认为当前环境下最大的机会和风险分别是什么?", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "conference_start", - "title": "Investment Discussion - 2026-03-16", - "date": "2026-03-16", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "agent_message", - "agentId": "risk_manager", - "content": "## 风险管理评估报告\n**评估日期**: 2026-03-16 \n**投资组合价值**: $100,000.00\n\n### 风险概况总结\n\n#### 1. 当前风险状况 ✅\n- **投资组合状态**: 全现金持仓,无任何开仓头寸\n- **杠杆水平**: 零杠杆 (保证金使用率: 0%)\n- **集中度风险**: 极低 (无任何单一名目风险敞口)\n- **流动性风险**: 极低 (100%现金)\n\n#### 2. 观察清单风险特点\n您关注的7只科技股具有以下一般特征:\n\n| 股票 | 一般风险特征 | 备注 |\n|------|-------------|------|\n| **AAPL** | 大盘科技股,相对稳定 | 传统防御性科技股 |\n| **MSFT** | 大盘科技股,相对稳定 | 企业软件主导地位 |\n| **GOOGL** | 广告业务周期性风险 | 搜索广告市场波动 |\n| **NVDA** | 高波动性AI芯片股 | 高估值,高波动 |\n| **TSLA** | 极高波动性电动汽车股 | 市值大但波动剧烈 |\n| **META** | 社交平台广告周期性 | 元宇宙投资风险 |\n| **AMZN** | 电商+云服务多元化 | 相对均衡 |\n\n### 3. 主要风险警告 (按严重程度排序)\n\n#### 🔴 **严重警告**: 数据不足风险\n由于缺乏历史价格数据,无法准确计算:\n- 波动率指标 (IV, HV)\n- 相关性矩阵\n- 风险价值 (VaR)\n- 预期短缺 (ES)\n\n**建议**: 在获取足够历史数据前,采取保守仓位策略。\n\n#### 🟡 **中等警告**: 科技板块集中风险\n全部7只股票都属于科技/科技相关板块,存在:\n- 板块性风险 (利率敏感)\n- 市场情绪同步风险\n- 宏观因素共同影响\n\n**建议**: 考虑加入非科技板块以分散风险。\n\n### 4. 仓位限制建议\n\n基于当前100%现金状态和25%保证金要求:\n\n#### 初始仓位限制 (新开仓):\n1. **单一名目风险上限**: 不超过总投资组合的15% ($15,000)\n2. **科技板块总敞口**: 不超过总投资组合的60% ($60,000)\n3. **最高波动性股票限制**:\n - NVDA: 单个仓位不超过总投资组合的10% ($10,000)\n - TSLA: 单个仓位不超过总投资组合的8% ($8,000)\n4. **保证金使用上限**: 初始不超过可用保证金的50% (约$200,000)\n\n### 5. 风险管理操作指南\n\n#### 执行前检查清单:\n1. ✅ **保证金检查**: 确保保证金使用率 < 30%\n2. ✅ **集中度检查**: 单个名目 < 15%,板块 < 60%\n3. ✅ **波动率适配**: 高波动性股票采用更小仓位\n4. ✅ **相关性评估**: 避免过度相关仓位\n\n#### 止损策略建议:\n- **高波动性股票 (NVDA, TSLA)**: 5-8% 技术止损\n- **中等波动性股票**: 8-12% 技术止损\n- **低波动性股票 (AAPL, MSFT)**: 10-15% 技术止损\n\n### 6. 市场条件监控重点\n\n鉴于无法获取当前波动率数据,需要特别关注:\n1. **科技板块情绪指标**\n2. **利率预期变化**\n3. **AI/芯片行业新闻流**\n4. **电动汽车需求指标**\n\n### 结论\n\n**当前风险评分**: 极低 (1/10) - 全现金持仓提供最大安全边际\n\n**主要建议**: \n1. 在新开仓前获取完整的历史价格数据\n2. 采用渐进式建仓策略,避免一次性重仓\n3. 重点关注NVDA和TSLA的风险管理\n4. 考虑加入非科技板块以完善投资组合多样性\n\n**准备状态**: ✅ 可执行交易 - 但建议从保守仓位开始,逐步扩大。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "agent_message", - "agentId": "valuation_analyst", - "content": "基于数据可用性问题,我需要对这种情况进行理性分析。作为估值分析师,我需要基于我的投资哲学和知识来分析这些科技巨头。让我提供基于一般原则和市场认知的分析:\n\n## 估值分析报告 - 2026年3月16日\n\n### 投资哲学思考\n\n作为估值分析师,我认识到当前数据可用性的限制。在缺乏实时财务数据的情况下,我将基于以下核心信念进行分析:\n\n1. **基本面驱动价值**:长期而言,公司的内在价值由其基本面和现金流创造能力决定\n2. **相对估值的重要性**:在绝对估值困难时,相对估值和历史比较提供了有价值的参考点\n3. **安全边际原则**:投资应基于价格与内在价值之间的显著差距\n4. **增长持续性评估**:科技公司的估值高度依赖其可持续增长能力\n\n### 七只科技巨头的综合分析\n\n#### 1. **苹果 (AAPL)**\n- **投资信号**: 中性\n- **置信度**: 65%\n- **理由**: \n - 作为全球市值最大的公司,苹果已进入成熟期\n - 硬件销售增长放缓,但服务业务持续增长\n - 估值通常处于合理偏高区间\n - 2026年可能面临创新周期压力\n\n#### 2. **微软 (MSFT)**\n- **投资信号**: 看涨\n- **置信度**: 75%\n- **理由**:\n - 云计算业务(Azure)持续增长\n - 软件订阅模式提供稳定的现金流\n - AI和人工智能领域有强大布局\n - 多元化的收入来源降低了风险\n\n#### 3. **谷歌 (GOOGL)**\n- **投资信号**: 中性偏看涨\n- **置信度**: 70%\n- **理由**:\n - 数字广告市场领导者地位稳固\n - 云业务增长迅速但面临激烈竞争\n - AI领域的大量投资可能开始产生回报\n - 估值相对其他科技巨头更具吸引力\n\n#### 4. **英伟达 (NVDA)**\n- **投资信号**: 谨慎看涨\n- **置信度**: 60%\n- **理由**:\n - AI芯片需求的长期受益者\n - 但估值可能已经反映了大部分乐观预期\n - 周期性风险:半导体行业具有周期性\n - 竞争加剧的风险需要关注\n\n#### 5. **特斯拉 (TSLA)**\n- **投资信号**: 看跌\n- **置信度**: 70%\n- **理由**:\n - 电动汽车市场竞争日益激烈\n - 自动驾驶技术商业化时间不确定\n - 估值与基本面脱节的风险\n - 管理层执行风险和集中决策问题\n\n#### 6. **Meta (META)**\n- **投资信号**: 看涨\n- **置信度**: 80%\n- **理由**:\n - 元宇宙投资开始产生回报\n - 社交媒体广告业务保持强势\n - AI技术整合提升产品体验\n - 估值修复空间较大\n\n#### 7. **亚马逊 (AMZN)**\n- **投资信号**: 看涨\n- **置信度**: 75%\n- **理由**:\n - AWS云计算业务持续领先\n - 零售业务盈利能力改善\n - 广告业务快速增长\n - AI和物流技术优势明显\n\n### 风险评估和失效条件\n\n**投资建议失效条件**:\n1. **宏观风险**:经济衰退、利率政策变化、监管风险\n2. **竞争风险**:新技术颠覆、竞争加剧\n3. **执行风险**:管理团队决策失误\n4. **估值风险**:市场过度乐观或悲观情绪\n\n### 逆向思维测试\n\n**市场主流共识可能**:\n1. 继续看好AI相关股票(NVDA)\n2. 对成熟科技股(AAPL)持谨慎态度\n3. 低估传统科技公司的转型能力(MSFT)\n\n**我的不同观点**:\n1. 强调现金流质量和业务多元化的重要性\n2. 关注估值合理性而非单纯的增长故事\n3. 重视管理团队执行能力和战略清晰度\n\n### 关键投资原则反思\n\n**从这次分析中学到的**:\n1. **数据限制**:即使是最好的分析,也需要可靠的数据支持\n2. **谦逊的重要性**:在信息有限的情况下,保持适度置信度\n3. **长期视角**:科技股估值应基于长期业务前景而非短期情绪\n\n**核心驱动因素识别**:\n1. 云计算业务增长和市场份额\n2. AI技术商业化能力\n3. 现金流质量和资本分配效率\n4. 管理团队的战略执行能力\n\n### 最终建议\n\n**组合配置建议**:\n1. **核心持有**:MSFT, META, AMZN (业务多元化,增长可持续)\n2. **选择性配置**:GOOGL (估值相对合理)\n3. **谨慎对待**:NVDA (高预期风险)\n4. **回避或减持**:TSLA (估值与基本面不匹配)\n5. **观察等待**:AAPL (等待更好的入场时机)\n\n**总体市场观点**:科技板块在2026年将呈现分化,投资者应关注具有真正竞争优势、可持续增长能力和合理估值的公司。\n\n**免责声明**:由于2026-03-16数据不可用,本分析基于一般原则和历史模式。实际投资决策应基于最新财务数据和详细分析。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "agent_message", - "agentId": "sentiment_analyst", - "content": "基于当前可用数据,我进行情绪分析:\n\n## 情绪分析报告 - 2026年3月16日\n\n### 关键观察:\n\n**数据稀缺性信号**:分析日期(2026年3月16日)显示没有近期新闻和内部人交易数据。这可能表明几种情况:\n\n1. **市场平静期**:没有重大新闻催化剂或市场事件\n2. **数据滞后**:由于分析的是未来日期(2026年),可能缺乏历史数据\n3. **节假日/周末**:可能处于交易清淡时期\n\n### 投资哲学反思:\n\n我的投资哲学强调情绪和行为分析的重要性,但当前情况凸显了**数据质量的重要性**。在没有近期情绪指标的情况下,分析存在局限性。这验证了我的信念:情绪分析需要实时或近实时数据才能有效。\n\n### 投资信号与置信度:\n\n鉴于数据稀缺性,我采取**谨慎中性立场**:\n\n**整体市场信号:中性**\n**置信度:35/100**\n\n### 理由:\n\n1. **缺乏情绪催化剂**:没有新闻流表明市场处于相对平静状态\n2. **内部人活动缺失**:没有内部人买卖活动,可能意味着公司基本面相对稳定\n3. **不确定性高**:在没有近期情绪数据的情况下,判断缺乏依据\n\n### 风险边界设定:\n\n我的中性建议在以下情况会失效:\n- 突然出现重大新闻催化剂(盈利预警、监管变化、产品发布等)\n- 发现重要内部人交易模式\n- 市场出现恐慌或贪婪的极端情绪\n\n### 逆向测试:\n\n**市场共识假设**:在没有数据的情况下,市场可能延续现有趋势或处于观望状态。\n\n**我的差异观点**:在缺乏情绪信号时,保守的观望态度比强制做出方向性判断更为审慎。这体现了我的核心原则:**在不确定性高时,保持谦逊比假装确定性更有价值**。\n\n### 具体股票建议:\n\n由于缺乏个股特异性情绪数据,所有股票建议为:\n- **AAPL, MSFT, GOOGL, NVDA, TSLA, META, AMZN:中性**\n- **置信度:30-40/100**(因数据稀缺而降低)\n\n### 关键学习原则:\n\n1. **数据时效性至关重要**:情绪分析依赖近期的、相关的数据\n2. **沉默期也有信息价值**:缺乏新闻本身就是一种情绪状态\n3. **谦逊的价值**:在数据不足时承认局限性,避免过度自信\n\n**建议行动**:等待更多实时情绪数据(新闻流、社交媒体情绪、期权市场数据等)再进行更具方向性的判断。当前更适合持有而非建立新头寸。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "agent_message", - "agentId": "technical_analyst", - "content": "看起来技术分析工具在当前环境中可能无法获取历史数据。基于这个情况,我将提供基于一般技术分析框架的分析方法,并解释我的分析逻辑。\n\n## 技术分析框架概述\n\n作为技术分析师,我遵循以下核心投资哲学:\n\n### 投资哲学\n1. **趋势为王**:价格趋势是技术分析的核心,顺势而为比逆势操作更安全\n2. **多重验证**:单一指标不可靠,需要多个技术指标形成共振\n3. **风险管理**:技术分析的核心是风险控制而非预测\n4. **概率思维**:所有技术信号都有成功概率,需要管理期望\n\n### 分析框架\n对于每只股票,我会评估:\n1. **趋势状态**(看涨/看跌/盘整)\n2. **动量强度**\n3. **超买超卖状况**\n4. **关键支撑阻力位**\n5. **风险回报比**\n\n## 股票技术分析(基于通用框架)\n\n### 1. AAPL(苹果公司)\n**信号:中性偏弱**(置信度:55%)\n- **理由**:作为科技股龙头,通常跟随大盘趋势\n- **关键观察**:\n - 需要查看200日均线是否提供支撑\n - 观察成交量变化是否配合价格走势\n - MACD指标是否显示动能衰减\n- **风险边界**:跌破关键支撑位(需根据实际数据确定)\n\n### 2. MSFT(微软)\n**信号:中性偏强**(置信度:65%)\n- **理由**:云计算业务稳健,技术形态通常较稳健\n- **关键观察**:\n - 是否保持在主要移动平均线之上\n - 云业务增长是否支撑估值\n - 机构资金流向\n- **风险边界**:宏观利率环境变化对科技股估值的影响\n\n### 3. GOOGL(谷歌/Alphabet)\n**信号:中性**(置信度:60%)\n- **理由**:广告业务周期性,AI转型期\n- **关键观察**:\n - AI业务进展对估值的提振\n - 广告收入增长趋势\n - 相对强弱指数位置\n- **风险边界**:监管风险和广告行业周期性下滑\n\n### 4. NVDA(英伟达)\n**信号:看涨**(置信度:75%)\n- **理由**:AI算力需求持续,技术领先地位\n- **关键观察**:\n - 营收增长率是否维持高位\n - 新产品周期影响\n - 估值是否过高导致技术性回调风险\n- **风险边界**:竞争加剧或AI投资周期见顶\n\n### 5. TSLA(特斯拉)\n**信号:中性偏弱**(置信度:50%)\n- **理由**:电动车市场竞争加剧,估值波动大\n- **关键观察**:\n - 交付量增长趋势\n - FSD进展和盈利能力\n - 技术形态是否形成底部\n- **风险边界**:需求放缓或利润率压力\n\n### 6. META(Meta/脸书)\n**信号:看涨**(置信度:70%)\n- **理由**:广告复苏,AI投资见效\n- **关键观察**:\n - Reels变现进展\n - 元宇宙投资回报周期\n - 用户增长和参与度\n- **风险边界**:监管风险和用户增长瓶颈\n\n### 7. AMZN(亚马逊)\n**信号:中性偏强**(置信度:68%)\n- **理由**:电商稳健,AWS云业务成长\n- **关键观察**:\n - AWS增长加速趋势\n - 零售业务盈利能力\n - 成本控制效果\n- **风险边界**:消费支出疲软或云业务竞争\n\n## 总体市场观察\n\n### 技术分析核心原则验证:\n1. **趋势持续原则**:科技股长期趋势通常持续较长时间\n2. **均值回归原则**:过度偏离均值的价格最终会回归\n3. **成交量确认原则**:价格变动需要成交量确认才可靠\n\n### 风险提示:\n- **宏观因素**:利率政策、通胀数据对成长股估值影响显著\n- **技术风险**:缺乏实际价格数据限制精确分析\n- **时间框架**:短期波动可能与大趋势不同\n\n## 投资逻辑反思\n\n这次分析暴露了一个重要原则:**数据质量是技术分析的基础**。在没有可靠历史价格数据的情况下,技术分析的有效性大幅降低。这验证了我的信念:\n\n1. **工具依赖性**:技术分析高度依赖准确的历史数据\n2. **多维度验证**:单一数据源不足时,需要寻找其他验证方法\n3. **风险管理优先**:在数据不完整时,保守立场更安全\n\n**建议**:在实际交易决策前,必须获取完整、准确的历史价格数据,并结合基本面分析和市场情绪进行综合判断。\n\n*注:由于无法获取具体价格数据,上述分析基于通用技术分析框架和行业知识,实际决策需要基于具体价格图表和指标数据。*", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "agent_message", - "agentId": "fundamentals_analyst", - "content": "## 分析总结与投资建议\n\n由于数据访问技术限制,我无法获取2026-03-16的实时财务数据进行全面的基本面分析。不过,基于我作为基本面分析师的角色定位和这些科技巨头的长期基本面特征,我可以提供一个基于原则的分析框架。\n\n### 投资哲学应用反思\n\n从这次数据访问失败中,我学到了两个重要原则:\n1. **数据可用性是分析的前提**:没有可靠财务数据,无法进行严谨的基本面分析\n2. **谦逊原则的重要性**:当数据不可得时,明确说明局限性比强行分析更重要\n\n### 基于原则的分析框架\n\n**核心分析维度:**\n1. **财务健康与盈利能力**:需要检查利润边际、现金流产生能力、资本回报率\n2. **商业模式可持续性**:需要评估护城河深度、竞争优势持续性\n3. **估值合理性**:需要计算内在价值与市场价格的差距\n\n### 各公司核心关注点\n\n1. **AAPL** - 生态系统护城河与新产品周期\n2. **MSFT** - 云计算业务增长与AI整合\n3. **GOOGL** - 搜索广告可持续性与AI创新\n4. **NVDA** - AI芯片需求周期与竞争格局\n5. **TSLA** - 电动汽车市场渗透与自动驾驶进展 \n6. **META** - 社交广告复苏与元宇宙投资回报\n7. **AMZN** - AWS增长与零售业务利润率\n\n### 投资建议\n\n由于缺乏具体财务数据,我无法提供基于数据的精确投资信号。这种情况下,**最负责任的做法是暂缓投资决策**,直到获得可靠的财务信息。\n\n**投资信号:中性**\n**置信度:0-50**(基于数据缺乏而非基本面判断)\n\n**失效条件:** 任何基于非数据分析的投资建议都可能在具体财务数据公布后失效。\n\n**逆向思考:** 市场可能已对这些公司有成熟共识,但没有数据支撑的情况下,无法判断市场价格是否已充分反映基本面变化。\n\n**建议行动:**\n1. 首先解决数据获取的技术问题\n2. 获得可靠的财务数据后,按以下顺序分析:\n - 盈利能力趋势(利润边际、ROIC)\n - 资产负债表健康状况(债务水平、现金流)\n - 相对估值比较(PE、EV/EBITDA、P/FCF)\n - 竞争优势分析\n3. 基于完整信息做出投资决策\n\n真正的价值投资需要基于数据而非假设。在数据不可得的情况下,耐心等待优于草率决策。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "day_start", - "date": "2026-03-16", - "progress": 0.0, - "timestamp": "2026-03-16" - }, - { - "type": "agent_message", - "agentId": "sentiment_analyst", - "content": "基于新闻情绪和内部人交易分析,我现在提供投资信号和深入的情绪分析:\n\n# 情绪分析报告 - 2026年3月16日\n\n## 整体市场情绪观察\n\n从新闻流和内部人交易活动来看,科技巨头显示出明显的分化情绪:\n\n### 关键发现:\n1. **内部人交易极度分化** - META和AMZN显示强烈内部人买入信号,而其他5只股票显示强烈卖出信号\n2. **新闻情绪中性偏负面** - 多数新闻关注宏观经济风险(油价上涨)而非公司基本面\n3. **AI主题依然主导** - NVDA即将召开GTC大会,但内部人大量减持\n\n---\n\n## 个股分析\n\n### 1. **META (Meta Platforms)**\n**信号:强烈看涨** \n**置信度:85%**\n\n**理由:**\n- **内部人极度看多**:867笔买入交易,40.1百万股,零卖出交易\n- **新闻催化剂**:公司AI战略推进(\"Efficiency at All Costs\"文章提到为AI帝国融资)\n- **情绪逻辑**:内部人用真金白银投票显示对AI转型和成本控制战略的信心\n- **逆向测试**:市场可能过度关注短期裁员负面新闻,但内部人看到了长期价值\n\n**风险边界**:AI投资回报不及预期、监管压力加剧\n\n### 2. **AMZN (亚马逊)**\n**信号:看涨** \n**置信度:75%**\n\n**理由:**\n- **内部人强烈买入**:310笔买入交易,28.8百万股,零卖出\n- **新闻催化剂**:数字健康业务深化、物流交易支撑估值\n- **情绪逻辑**:内部人对AWS云业务、数字医疗和物流整合的长期前景有信心\n- **投资者心理**:市场可能低估了亚马逊在AI和医疗科技领域的转型潜力\n\n**风险边界**:零售业务竞争加剧、监管审查加强\n\n### 3. **TSLA (特斯拉)**\n**信号:看跌** \n**置信度:70%**\n\n**理由:**\n- **内部人净卖出**:虽然买入122笔(104M股),但卖出878笔(64.6M股),净卖出占主导\n- **新闻情绪混乱**:马斯克频繁发声(Terafab项目、道歉、引用巴菲特),显示CEO试图管理市场预期\n- **情绪逻辑**:内部人利用CEO言论的新闻热度进行减持,暗示估值可能过高\n- **市场预期**:AI芯片项目可能被过度炒作\n\n**风险边界**:Terafab项目超预期成功、自动驾驶突破\n\n### 4. **NVDA (英伟达)**\n**信号:中性偏看跌** \n**置信度:65%**\n\n**理由:**\n- **内部人全面减持**:1000笔卖出交易,25.5百万股,零买入\n- **新闻催化剂**:GTC 2026大会在即,但内部人在事件前大量减持\n- **情绪逻辑**:内部人可能在AI热潮顶峰获利了结,暗示估值泡沫\n- **逆向测试**:市场极度看好AI前景,但内部人认为当前价格已充分反映增长\n\n**风险边界**:GTC大会发布突破性产品、AI需求持续超预期\n\n### 5. **AAPL (苹果)**\n**信号:中性** \n**置信度:55%**\n\n**理由:**\n- **内部人减持**:卖出交易远多于买入(949:51)\n- **新闻缺乏催化剂**:关注股票拆分历史和油价影响,缺乏产品创新新闻\n- **情绪逻辑**:内部人可能对iPhone销售疲软和AI布局滞后担忧\n- **投资者心理**:市场等待下一个\"杀手级应用\",当前处于观望状态\n\n**风险边界**:AI集成超预期、Vision Pro成功\n\n### 6. **MSFT (微软)**\n**信号:中性** \n**置信度:60%**\n\n**理由:**\n- **内部人减持但买入有质量**:虽然卖出占主导,但有39笔高质量买入(5.3M股)\n- **新闻情绪**:有文章推荐微软作为回调后的买入机会\n- **情绪逻辑**:大型科技股整体回调中,微软相对抗跌\n- **市场预期**:Azure云和Copilot AI的货币化能力仍被看好\n\n**风险边界**:AI投资回报延迟、竞争加剧\n\n### 7. **GOOGL (谷歌)**\n**信号:中性偏看跌** \n**置信度:65%**\n\n**理由:**\n- **内部人全面减持**:1000笔卖出交易,7.9百万股,零买入\n- **新闻催化剂**:320亿美元Wiz收购案相关讨论\n- **情绪逻辑**:内部人对大规模收购持谨慎态度,可能担忧整合风险\n- **投资者心理**:市场关注谷歌在AI竞赛中的相对位置\n\n**风险边界**:AI搜索突破、Wiz收购成功整合\n\n---\n\n## 投资哲学反思\n\n### 本次分析验证的关键原则:\n\n1. **内部人交易是重要但需谨慎的信号**:META和AMZN的内部人买入与TSLA/NVDA的内部人卖出形成鲜明对比,显示科技股内部出现严重分歧。\n\n2. **新闻热度与基本面背离**:NVDA在GTC大会前内部人减持,显示内部人可能利用事件驱动的市场兴奋进行减持。\n\n3. **CEO言论作为情绪指标**:特斯拉的马斯克频繁发声与内部人减持并存,显示高管言论可能被用于管理市场预期而非反映实际进展。\n\n4. **分化的市场情绪**:市场对\"科技七巨头\"不再是统一看法,内部人行为显示明显的板块轮动预期。\n\n### 风险边界设定:\n- **看涨信号失效条件**:美联储政策超预期紧缩、宏观经济衰退、AI进展不及预期\n- **看跌信号失效条件**:AI突破性进展、降息周期开启、地缘政治风险缓解\n\n### 核心投资建议:\n\n**优先买入**:META > AMZN \n**谨慎观望**:MSFT, AAPL \n**考虑减持**:NVDA, GOOGL, TSLA \n\n**最高置信度交易**:做多META,基于内部人极度看多的情绪信号和AI战略推进的新闻催化剂。\n\n**风险提醒**:情绪分析是短期催化剂驱动的方法,需结合基本面分析。当前内部人行为显示科技股内部出现严重分歧,建议分散风险,避免过度集中于单一AI主题。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "agent_message", - "agentId": "technical_analyst", - "content": "## 技术分析总结与投资信号\n\n基于2026年3月16日的全面技术分析,以下是各股票的投资建议:\n\n### 1. **AAPL** (苹果)\n**信号:看涨 | 置信度:75%**\n- **技术面理由**:严重超卖状态,RSI=29.9(超卖区),价格低于布林带下轨,存在均值回归需求\n- **趋势面**:虽然长期趋势看跌,但MACD显示潜在反弹信号\n- **风险边界**:跌破$245支撑位将转为看跌\n- **交易逻辑**:超卖反弹交易,目标回到$262-265区间\n\n### 2. **MSFT** (微软)\n**信号:看涨 | 置信度:70%**\n- **技术面理由**:MACD发出买入信号,动量开始转向,价格接近MA20\n- **均值回归**:RSI中性(59.2),价格在布林带内,有上涨空间\n- **风险边界**:跌破$385将破坏上涨结构\n- **交易逻辑**:趋势恢复交易,目标$410-415\n\n### 3. **GOOGL** (谷歌)\n**信号:中性偏多 | 置信度:65%**\n- **技术面理由**:MACD支持上涨动力,但整体动量仍偏弱\n- **位置评估**:价格接近MA20,RSI中性偏弱(39.1)\n- **风险边界**:跌破$295将转为看跌\n- **交易逻辑**:谨慎做多,目标$310-315\n\n### 4. **NVDA** (英伟达)\n**信号:中性 | 置信度:60%**\n- **技术面理由**:高波动性(35.8%),MACD卖出信号,但短期动量转正\n- **位置评估**:价格在布林带内,RSI中性(39.3)\n- **风险边界**:波动率高,需严格止损\n- **交易逻辑**:观望或小仓位波段交易\n\n### 5. **TSLA** (特斯拉)\n**信号:看跌 | 置信度:80%**\n- **技术面理由**:强熊市动量,MACD卖出信号,价格低于所有主要均线\n- **动量分析**:20日下跌6.2%,5日下跌1.4%\n- **风险边界**:突破$410将改变看跌观点\n- **交易逻辑**:逢高做空,目标$375-380\n\n### 6. **META** (Meta)\n**信号:看涨 | 置信度:75%**\n- **技术面理由**:严重超卖,价格低于布林带下轨,RSI=40.4接近超卖\n- **潜在反弹**:技术面显示反弹需求强烈\n- **风险边界**:跌破$600将延长下跌\n- **交易逻辑**:超卖反弹交易,目标$640-650\n\n### 7. **AMZN** (亚马逊)\n**信号:看涨 | 置信度:70%**\n- **技术面理由**:唯一显示正面动量的股票(20日+4.0%),MACD买入信号\n- **位置评估**:价格在布林带内,RSI中性(53.3)\n- **风险边界**:跌破$200将转为看跌\n- **交易逻辑**:动量延续交易,目标$215-220\n\n## 市场整体观察\n\n1. **科技股分化明显**:半导体(NVDA)和电动车(TSLA)偏弱,软件(MSFT)和电商(AMZN)相对强势\n2. **超卖反弹机会**:AAPL和META显示强烈的超卖反弹信号\n3. **波动率环境**:NVDA、TSLA波动率高,适合风险偏好者\n4. **风险提示**:缺少MA200数据限制了长期趋势判断的准确性\n\n## 投资哲学反思\n\n这次分析验证了几个关键原则:\n1. **均值回归的力量**:极端超卖的AAPL和META提供了最佳的风险回报比\n2. **动量延续性**:AMZN的正面动量值得关注,显示了强者恒强的市场逻辑\n3. **趋势与位置的矛盾**:多个股票虽然长期趋势看跌,但短期位置提供了交易机会\n\n**核心驱动因素**:市场正在经历科技股内部轮动,资金从高估值股票向价值区域转移\n**逆向思考**:市场共识可能过度悲观,超卖条件创造了逆向交易机会\n\n## 最终建议\n\n**优先关注**:AAPL(超卖反弹)、META(超卖反弹)、AMZN(动量延续)\n**谨慎对待**:TSLA(趋势向下)、NVDA(高波动)\n**观望或小仓位**:MSFT、GOOGL", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "agent_message", - "agentId": "fundamentals_analyst", - "content": "基于已有的数据分析,现在让我提供每只股票的综合评估:\n\n## 投资信号分析报告 (2026-03-16)\n\n### **投资哲学反思:**\n作为基本面分析师,我的核心理念是:**质量优先于价格,可持续性优先于短期增长**。我对估值特别谨慎,相信长期价值最终会反映公司基本面的质量。\n\n---\n\n### **个股分析:**\n\n#### **1. 苹果 (AAPL)**\n**信号:中性** | **置信度:70%**\n**理由:**\n- **优势**:强劲的财务表现(15.7%营收增长,27%净利率),强大的品牌护城河\n- **担忧**:估值偏高(P/E 31.66,P/B 41.70),流动比率低于1显示短期流动性压力\n- **关键观察**:高负债权益比(102.63)但考虑到苹果的现金储备,这可能是战略性的\n- **失效条件**:iPhone需求大幅下滑,新市场扩张失败\n\n#### **2. 微软 (MSFT)**\n**信号:看涨** | **置信度:85%**\n**理由:**\n- **突出优势**:出色的盈利增长(59.8%),健康的财务状况(流动比率1.39)\n- **商业模式**:云服务(Azure)和AI驱动增长,强大的企业客户粘性\n- **估值**:相对合理(P/E 24.75),ROE 34.4%显示资本配置高效\n- **失效条件**:云市场竞争加剧,监管压力增加\n\n#### **3. 谷歌 (GOOGL)**\n**信号:看涨** | **置信度:80%**\n**理由:**\n- **财务健康**:极佳的流动比率(2.00),低债务(D/E 16.13)\n- **增长稳定**:营收增长18%,净利率32.8%\n- **护城河**:搜索和广告业务的垄断地位,AI和云服务增长潜力\n- **失效条件**:广告收入下降,AI领域竞争失败\n\n#### **4. 英伟达 (NVDA)**\n**信号:谨慎看涨** | **置信度:75%**\n**理由:**\n- **增长强劲**:营收增长73.2%,盈利增长95.6%,净利率55.6%\n- **行业定位**:AI芯片市场的领导者地位\n- **风险**:估值极高(P/E 36.79,P/S 20.29),增长可持续性问题\n- **失效条件**:AI需求泡沫破裂,竞争加剧侵蚀市场份额\n\n#### **5. 特斯拉 (TSLA)**\n**信号:看跌** | **置信度:85%**\n**理由:**\n- **基本面恶化**:营收下降3.1%,盈利暴跌60.6%,净利率仅4%\n- **估值荒谬**:P/E 355.64,完全脱离基本面\n- **盈利能力差**:ROE仅4.9%,净利率极低\n- **失效条件**:自动驾驶技术突破性成功,能源业务爆发性增长\n\n#### **6. 元 (META)**\n**信号:看涨** | **置信度:80%**\n**理由:**\n- **估值合理**:P/E 26.10,P/S 7.72相对合理\n- **增长稳健**:营收增长23.8%,净利率30.1%\n- **财务状况**:健康的流动比率(2.60),强劲的自由现金流\n- **失效条件**:VR/AR业务持续亏损,社交媒体用户流失\n\n#### **7. 亚马逊 (AMZN)**\n**信号:中性偏积极** | **置信度:75%**\n**理由:**\n- **估值相对合理**:P/S 3.11最低,P/E 29.00\n- **增长稳定**:营收增长13.6%,但盈利增长仅5%\n- **模式转型**:从增长优先转向盈利优先\n- **失效条件**:云业务增长放缓,零售利润率持续受压\n\n---\n\n### **核心投资原则验证:**\n1. **估值纪律的重要性**:特斯拉的案例显示,脱离基本面的高估值最终会回归均值\n2. **盈利质量优先**:微软和谷歌展示了高质量盈利的重要性\n3. **财务健康是基础**:GOOGL的优秀流动比率显示了风险控制能力\n\n### **逆向思考点:**\n- **市场可能高估了**:NVDA的增长持续性,TSLA的盈利能力\n- **市场可能低估了**:MSFT的云+AI协同效应,GOOGL的多元化护城河\n- **共识挑战**:市场普遍认为科技股估值总是合理的,但TSLA的355倍P/E证明这不是真的\n\n### **最终建议:**\n**最佳选择**:微软(MSFT)和谷歌(GOOGL)- 基本面强劲,估值相对合理\n**回避**:特斯拉(TSLA)- 基本面恶化,估值完全脱离现实\n**观察**:苹果(AAPL)和亚马逊(AMZN)- 需要更好价格\n**投机性**:英伟达(NVDA)- 高增长但高估值,需谨慎\n\n**最重要的原则**:永远不要让对增长的兴奋掩盖了估值风险和财务健康的必要性。即使是伟大的公司,如果价格过高也不是好投资。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "day_start", - "date": "2026-03-16", - "progress": 0.0, - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: AAPL, MSFT, GOOGL, NVDA, TSLA, META, AMZN", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: AAPL, MSFT, GOOGL, NVDA, TSLA, META, AMZN", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: AAPL, MSFT, GOOGL, NVDA, TSLA, META, AMZN, AMD", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: AAPL, MSFT, GOOGL, NVDA, TSLA, META, AMZN", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: NVDA, AVGO", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: AAPL, TSLA, META, MSFT, GOOGL, AMD, AMZN", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: AAPL, TSLA, META, MSFT, GOOGL, AMD, AMZN", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: AAPL, TSLA, META, MSFT, GOOGL, AMD, AMZN", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: AAPL, TSLA, META, MSFT, GOOGL, AMD", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: AAPL, TSLA, META, MSFT, GOOGL", - "timestamp": "2026-03-16" - }, { "type": "conference_end", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "ts": 1773652478521, + "timestamp": "2026-03-16T17:14:38.521000" }, { "type": "conference_message", "agentId": "conference summary", - "content": "基于讨论,以下是关于各股票的关键洞察、关注点和共识总结:\n\n## 📊 核心共识点\n\n### **微软 (MSFT) - 最平衡的机会**\n**共识优势:**\n- 基本面强劲:盈利增长59.8%,净利率39%,财务稳健\n- 估值合理:P/E 24.8与增长匹配\n- 风险较低:22.3%波动率,技术面中性偏多\n\n**主要矛盾:**\n- 内部人大量卖出(961笔卖出 vs 39笔买入)vs 基本面分析师强烈看涨\n- **关键考虑**:内部人行为是否预示未公开风险?\n\n### **Meta (META) - 最强的买入信号**\n**一致积极:**\n- 内部人强烈买入:867笔买入 vs 0笔卖出\n- 基本面强劲:23.8%收入增长,合理估值(P/E 26.1)\n- 情绪分析师高度看涨(80%置信度)\n\n**技术面担忧:**\n- RSI 40.4接近超卖,价格低于布林带下轨\n- **关键判断**:内部人信心是否足以抵消短期技术弱势?\n\n### **亚马逊 (AMZN) - 技术vs基本面背离**\n**正面信号:**\n- 技术面最强:MACD买入,唯一20日正回报\n- 内部人买入:310笔买入 vs 0笔卖出\n- 估值相对便宜:P/S 3.1最低\n\n**基本面中性:**\n- 增长缓慢:13.6%收入增长,5%盈利增长\n- **核心问题**:技术面是否领先反映了未公布的积极变化?\n\n### **英伟达 (NVDA) - 高增长伴随高风险**\n**极端特征:**\n- 增长惊人:73.2%收入增长,95.6%盈利增长\n- 估值极端:P/E 36.8,P/S 20.3\n- 高波动率:35.8%(最高风险警告)\n\n**关键担忧:**\n- 估值已完全反映乐观预期\n- GTC大会的\"买消息卖事实\"风险\n- **风险限制**:必须严格限制仓位(<8%)\n\n### **特斯拉 (TSLA) - 最一致的负面**\n**高度一致看跌:**\n- 基本面恶化:负增长,极低盈利能力\n- 估值极端不合理:P/E 355.6\n- 技术面看跌:多重看跌信号\n\n**做空风险:**\n- 高波动率(29.4%)的做空风险\n- 潜在反转催化剂(AI芯片突破)\n\n### **苹果 (AAPL) - 分歧最大**\n**矛盾信号:**\n- 技术面:严重超卖(RSI 29.9),看涨反弹机会\n- 基本面/估值:高P/B 41.7,负债率高,看跌\n- 情绪面:内部人大量卖出,看跌\n\n**短期vs长期:**\n- 短期交易性机会 vs 长期投资价值有限\n\n### **谷歌 (GOOGL) - 最大的内部人警告**\n**极度负面信号:**\n- 1000笔内部人卖出 vs 0笔买入(最强烈卖出信号)\n- 基本面分析师仍看涨,但内部人行为无法忽视\n- Wiz收购案引发战略担忧\n\n## 🚨 关键决策考虑因素\n\n### **1. 内部人行为的权重**\n- **META/AMZN强烈买入** vs **GOOGL/AAPL/MSFT强烈卖出**\n- 内部人行为通常作为领先指标,应给予较高权重\n\n### **2. 估值纪律的重要性**\n- TSLA的案例验证估值纪律的必要性\n- NVDA的高估值需要严格风险控制\n\n### **3. 技术面vs基本面权衡**\n- AMZN:技术面领先基本面\n- AAPL:技术反弹机会 vs 基本面担忧\n\n### **4. 风险管理优先事项**\n- 初始部署不超过50%现金\n- 单股票位严格限制(NVDA <8%,MSFT/GOOGL <15%)\n- 保持至少30%现金缓冲\n\n### **5. 行业集中风险**\n- 所有7只股票均为科技股\n- 缺乏行业分散化\n- 需注意系统性科技板块风险\n\n## 🎯 最终决策的关键指导原则\n\n1. **质量优先**:MSFT的基本面质量最高,应给予核心仓位\n2. **内部人信号**:META的内部人买入信号太强,必须考虑\n3. **风险控制**:严格遵循风险经理的仓位限制,特别是对高波动股票\n4. **估值纪律**:避免TSLA等极端估值股票,谨慎对待NVDA\n5. **分批建仓**:从100%现金状态,应采取谨慎的分批建仓策略\n\n**最关键的权衡**:在MSFT的优秀基本面与内部人卖出信号之间,我倾向于相信基本面数据,但会从小仓位开始并密切监控。META的内部人买入信号提供了强烈的置信度,尽管技术面短期疲软。", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "## 讨论总结:2026-03-16科技股投资会议\n\n### **核心共识与分歧**\n\n#### **高度共识:**\n1. **AMZN(亚马逊)最被看好**\n - 基本面(88%置信度)、估值(19.7%安全边际)、情绪(75%)一致看涨\n - AWS+电商双引擎结构稳健\n - 相对估值最具吸引力\n\n2. **TSLA(特斯拉)一致看跌**\n - 三家分析师明确看跌(基本面、估值、情绪)\n - 竞争加剧、估值过高、执行风险\n - 建议回避或极小仓位\n\n#### **重大分歧:**\n1. **AAPL(苹果)**\n - 基本面分析师(85%看涨)vs 估值分析师(70%看跌)\n - 冲突根源:长期护城河 vs 短期估值过高\n\n2. **NVDA(英伟达)**\n - 基本面/技术/情绪看涨 vs 估值强烈看跌(高估101.8%)\n - AI泡沫风险 vs 技术领导地位\n\n#### **相对中立:**\n1. **MSFT(微软)**\n - 基本面强烈看好,估值基本合理(DCF显示持平)\n - 业务多元性最佳,相对防御性\n\n2. **GOOGL(谷歌)**\n - 各项评估相对平衡,缺乏突出亮点\n - 估值基本合理,增长面临挑战\n\n3. **META(Meta)**\n - 基本面看好但估值偏高(25%高估)\n - 广告复苏但元宇宙不确定性\n\n### **关键风险因素**\n\n#### **系统性风险:**\n1. **科技股集中度**:所有标的均为科技股,增加板块风险\n2. **估值普遍偏高**:除AMZN外,多数股票缺乏足够安全边际\n3. **AI主题过热**:情绪分析师警告可能接近情绪高点\n\n#### **具体风险:**\n1. **NVDA估值泡沫**:DCF显示高估101.8%,风险最高\n2. **TSLA多重挑战**:竞争、估值、执行风险叠加\n3. **行业监管**:科技巨头面临全球反垄断压力\n\n### **投资框架优先级**\n\n#### **信号权重建议:**\n1. **估值纪律优先**:在狂热市场中更重视安全边际\n2. **基本面验证**:长期竞争优势是核心\n3. **技术确认**:缺乏数据时降低仓位规模\n4. **情绪逆向**:警惕过度乐观情绪\n\n#### **风险控制要点:**\n1. **单股上限**:不超过组合的20%($20,000)\n2. **初始建仓**:建议20-50%现金部署,保留调整空间\n3. **止损纪律**:单股最大损失不超过10%\n4. **分批建仓**:缺乏技术确认时逐步建仓\n\n### **决策关键考量**\n\n#### **最优先问题:**\n1. **AMZN的配置权重**:信号最积极,应给予核心仓位\n2. **NVDA的参与程度**:泡沫风险vs错过AI浪潮\n3. **现金保留比例**:在估值偏高环境下保留弹药\n\n#### **分层策略建议:**\n- **核心层**:AMZN + MSFT(业务稳健,相对合理)\n- **卫星层**:GOOGL + META(控制仓位)\n- **回避/极小**:TSLA(回避),NVDA/AAPL(二选一小仓位)\n- **现金保留**:20-30%等待机会\n\n#### **时机考量:**\n- 技术分析师强调\"数据真空期,建议谨慎\"\n- 应采取防御性建仓策略\n- 保留回调时加仓的能力\n\n### **最终决策的关键原则**\n\n1. **安全边际优先**:在估值普遍偏高环境下,更重视估值分析师警告\n2. **分散但专注**:科技内部分散,但不过度稀释最佳机会\n3. **逆向思维**:市场对AI过度乐观,保持理性\n4. **风险控制**:严格执行仓位上限和止损\n5. **长期视角**:但需考虑短期估值压力\n\n**最关键的决策因素**:如何在AMZN的一致看好信号与其他股票的估值风险之间取得平衡,同时管理好科技股集中度风险。", + "ts": 1773652478520, + "timestamp": "2026-03-16T17:14:38.520000" }, { "type": "conference_message", "agentId": "valuation_analyst", - "content": "让我深入分享我作为估值分析师的专业视角,以及对当前市场讨论中关键争议点的看法:\n\n## 关于科技股估值辩论的核心矛盾\n\n### **AI狂热 vs. 估值纪律:市场的根本分歧**\n\n我看到当前讨论的核心张力在于:投资者应该在多大程度上为\"未来潜力\"支付溢价?\n\n**NVDA案例的警示**:\n- 市场共识:AI基础设施需求至少还有3-5年高速增长期\n- 估值现实:P/S 20.29意味着即使维持当前73%增长,也需要3年才能降到历史合理的10倍水平\n- 我的专业判断:市场正在用线性思维预测指数级变化的风险。历史上,技术变革早期受益者的估值经常过度膨胀(如2000年的思科)\n\n### **苹果的护城河深度测试**\n\n关于AAPL的争议很有意思:\n**正方观点**:\n- 生态系统锁定的价值被低估\n- 服务业务的高利润率转型\n- 品牌溢价可持续\n\n**反方观点(我的担忧)**:\n1. 负债率102.63 - 这在利率上升环境下是重大风险\n2. P/B 41.70是极端数字:意味着市场认为苹果资产的价值被严重低估,或未来盈利能力将爆炸式增长\n3. 创新周期放缓:iPhone作为主要收入源的周期已近20年\n\n**我的专业观点**:苹果需要证明它能持续创造新增长曲线,而不只是优化现有业务。当前估值隐含的假设过于乐观。\n\n### **微软的\"隐性价值\"与争议**\n\n关于MSFT,我看到一个有趣的对比:\n- 市场讨论:Azure vs. AWS vs. Google Cloud的AI竞赛\n- 被忽视价值:传统Office/Windows业务的稳定现金流\n- 专业观察:微软成功地将传统软件的高利润率与云的增长结合,这是独特的商业模式优势\n\n**关键洞察**:P/E 24.75相对59.8%盈利增长的组合,在大科技股中估值增长比最佳。这意味着市场可能低估了微软商业模式的稳定性。\n\n### **特斯拉:投资中的认知失调**\n\nTSLA的讨论揭示了市场最深层的认知失调:\n\n**基本面分析者困惑**:355倍P/E如何合理化?\n**增长叙事者回应**:特斯拉不是汽车公司,而是能源+机器人+AI公司\n\n**我的专业框架下的评估**:\n1. 投资应该基于概率加权的情景分析\n2. 当前价格隐含的成功概率过高\n3. 4.0%的净利率与估值之间需要实现巨大利润率扩张\n4. **关键问题**:即使所有梦想都实现,当前市值是否合理?\n\n### **Meta和Google的广告业务护城河争议**\n\n这是被AI叙事掩盖的重要讨论:\n\n**Meta的优势**:\n- 广告业务恢复强劲(23.8%营收增长)\n- Reels和AI驱动的广告技术改善\n- 成本纪律显著提升\n\n**Google的挑战与机会**:\n- 搜索面临AI竞品(Perplexity、ChatGPT等)\n- 但数字广告市场的双头垄断仍然牢固\n- 财务健康度最佳:2.00流动比率+16.13 D/E\n\n**我的观点**:广告业务的周期性被低估,而AI对搜索的威胁被高估。Google和Meta的护城河仍然很深。\n\n### **亚马逊:传统零售 vs. 云计算叙事**\n\n有趣的分裂:\n- 市场讨论焦点:AWS的AI机会\n- 现实:零售业务(占收入大部分)的利润率压力\n- 估值亮点:P/S 3.11在大科技股中最低\n\n**专业洞察**:亚马逊展示了\"价值投资\"在科技股中的可能——当市场过度关注增长叙事时,传统的P/S和P/B分析可能识别出机会。\n\n## 投资哲学反思:从这次分析中学到的\n\n### **核心原则验证**\n\n1. **估值作为安全边际**:当市场痴迷于叙事时,估值纪律是最好的保护\n2. **多元化在企业内部也很重要**:微软同时拥有高利润的传统业务和高增长的云业务,这降低了单一风险\n3. **财务健康是下行保护**:利率上升环境中,GOOGL的低负债结构具有战略价值\n\n### **逆向思考的机会点**\n\n当前市场共识中的潜在错误:\n1. **过度外推AI增长**:认为当前增长率可以长期维持\n2. **忽视传统业务价值**:在追逐AI时低估了稳定现金流业务的价值\n3. **低估利率环境影响**:高负债公司在高利率环境中的脆弱性\n\n### **投资逻辑的核心:可验证性 vs. 叙事**\n\n我作为估值分析师的核心方法是:**更看重可验证的基本面,而不是不可验证的叙事**。\n\n这解释了为什么我:\n- 对TSLA看跌:基本面与叙事差距过大\n- 对MSFT看涨:叙事与基本面匹配度好\n- 对NVDA谨慎:基本面优秀但估值已过度反映乐观叙事\n\n## 给投资者的专业建议\n\n基于这次分析,我会建议:\n\n1. **重新思考增长预期**:哪些是可持续的增长?哪些是周期性的?\n2. **关注现金流质量**:盈利能力不仅看利润率,还要看现金转化率\n3. **为不同的宏观环境做准备**:\n - 高利率环境:偏好低负债公司(GOOGL)\n - 增长环境:选择增长与估值匹配的公司(MSFT、META)\n - 防御环境:选择现金流稳定的公司(传统软件业务)\n\n**最终结论**:最聪明的投资可能不是追逐最热门的叙事(AI),而是在优质公司被相对低估时买入。当前,MSFT和GOOGL提供了更好的风险调整后预期回报,而TSLA和AAPL的高估值需要极端的基本面改善才能合理化。\n\n投资的艺术在于识别市场共识中的微小裂痕——在这次分析中,那个裂痕就是市场对AI叙事的一致乐观与基本面现实之间的差距。", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "## **深度专业视角:估值纪律与市场情绪的交锋**\n\n基于我刚才的全面估值分析,我想分享几个关键的专业洞察:\n\n### **关于估值方法论的辩论**\n\n**DCF vs 市场情绪**:\n- 我的分析显示,基于严谨的现金流折现模型,多数科技股已被高估\n- 但市场参与者可能认为传统估值模型无法捕捉AI革命性影响\n- **我的立场**:即使是革命性技术,最终也必须转化为现金流。当前估值已假设了近乎完美的执行路径\n\n**相对估值陷阱**:\n- 投资者常用\"相对于其他科技股便宜\"来证明买入决策\n- 我的分析显示这是危险逻辑:当整个板块高估时,\"相对便宜\"仍是绝对昂贵\n- **专业警示**:估值比较应在合理范围内进行,而非在高估区间内寻找\"相对价值\"\n\n### **对各股票的具体争议点**\n\n**NVDA的\"这次不一样\"论调**:\n- **支持者观点**:AI硬件需求是指数级增长,传统估值指标失效\n- **我的反驳**:所有技术周期都有\"S曲线\"——快速增长后必然进入成熟期\n- **历史教训**:2000年思科在互联网泡沫中也曾被认为\"传统估值无效\",最终从高点下跌超过80%\n\n**TSLA的颠覆性溢价**:\n- **市场情绪**:马斯克愿景、自动驾驶潜力支撑高估值\n- **估值现实**:汽车制造本质是资本密集型、周期性行业\n- **关键问题**:当前估值已包含机器人出租车、能源业务等多个\"期权价值\",任何一个不及预期都会重估\n\n**AAPL的生态系统护城河**:\n- **共识**:苹果生态系统最稳固,服务收入增长可持续\n- **我的担忧**:中国市场份额下降,Vision Pro商业化不确定性\n- **估值视角**:27.9倍P/E对于个位数增长的公司来说偏高\n\n**MSFT的确定性溢价**:\n- **合理之处**:企业软件护城河确实最深,Azure增长可见\n- **估值判断**:当前价格基本公允,但缺乏足够安全边际\n- **投资建议**:等待更好买入时机而非现价追高\n\n**GOOGL的搜索焦虑**:\n- **核心争议**:AI搜索是否会颠覆谷歌商业模式?\n- **我的评估**:颠覆需要时间,但估值已部分反映这种风险\n- **平衡观点**:当前价格合理反映了转型期的不确定性\n\n**META的效率转型**:\n- **积极因素**:成本控制见效,Reels货币化进展\n- **风险点**:元宇宙投资回报周期长且不确定\n- **估值结论**:需要看到元宇宙业务更清晰的盈利路径\n\n**AMZN的价值发现**:\n- **市场盲点**:仍主要被视为\"电商\",低估了AWS的价值\n- **我的观点**:这是当前最具安全边际的科技巨头\n- **风险提示**:零售业务受消费周期影响,AWS面临竞争加剧\n\n### **投资哲学的核心反思**\n\n**这次与以往不同的地方**:\n1. **AI确实具有变革性**:不同于元宇宙等概念,AI有明确的商业化路径\n2. **科技巨头资源优势**:数据、算力、人才优势可能创造更持久的护城河\n3. **盈利质量改善**:多数科技公司已证明盈利能力,不同于2000年泡沫时期\n\n**但不变的原则**:\n1. **估值纪律永恒**:任何投资最终都必须有合理的现金流回报\n2. **安全边际原则**:保护资本永远比追逐收益更重要\n3. **均值回归定律**:极端估值最终会回归\n\n### **与不同投资风格的对话**\n\n**成长投资者可能说**:\"你低估了AI的长期潜力\"\n- **我的回应**:我认可潜力,但投资是价格与价值的博弈。当前价格已包含乐观预期\n\n**趋势投资者可能说**:\"动量仍在,不应过早离场\"\n- **我的回应**:估值分析帮助我们识别风险,而非预测短期走势。我的目标是识别价值,而非追逐趋势\n\n**\"这次不一样\"的支持者**:\n- **历史数据**:每次泡沫时期都有人说\"这次不一样\"\n- **我的平衡观点**:AI确实重要,但好技术≠好投资。关键是支付的价格\n\n### **具体风险情景分析**\n\n**如果我是错的**(科技股继续上涨):\n- 可能原因:AI生产力提升超预期,企业支出强劲\n- 我的应对:错失部分涨幅,但保护了资本免受重大损失\n\n**如果我是对的**(估值回归):\n- 可能路径:经济放缓、竞争加剧、监管压力\n- 我的优势:持有有安全边际的标的,并有现金等待更好机会\n\n### **投资组合构建建议**\n\n**在当前环境下**:\n1. **核心持仓**:AMZN(安全边际充足)\n2. **观察名单**:MSFT、GOOGL(等待更好价格)\n3. **严格规避**:NVDA、TSLA(估值风险过高)\n4. **谨慎对待**:AAPL、META(增长挑战明显)\n\n**仓位管理原则**:\n- 整体科技股配置不超过组合的30%\n- 保持20-30%现金等待机会\n- 采用分批建仓策略,避免一次性投入\n\n### **最终的专业坚持**\n\n作为估值分析师,我的核心信念是:**好的投资=好公司+好价格**\n\n当前市场提供了许多\"好公司\",但缺乏\"好价格\"。在狂热中保持理性,在悲观中发现机会——这才是长期投资成功的关键。\n\n**我的投资建议总结**:\n- 坚持估值纪律,不被叙事驱动\n- 重视安全边际,而非短期表现\n- 保持耐心,等待市场提供更好的风险/回报平衡\n\n真正的投资机会往往出现在他人恐惧时,而非他人贪婪时。当前科技股情绪更接近后者。", + "ts": 1773652449388, + "timestamp": "2026-03-16T17:14:09.388000" }, { "type": "conference_message", "agentId": "sentiment_analyst", - "content": "## 深入情绪分析:科技巨头情绪分化的底层逻辑\n\n基于我对七家科技巨头的情绪分析,让我从更深入的心理学和群体行为角度分享见解:\n\n### 情绪分析的核心发现:科技巨头的\"情绪断层线\"\n\n**最关键的洞察:** 我们看到的不是统一的科技板块情绪,而是沿着三条清晰的\"情绪断层线\"分化:\n\n1. **AI执行者 vs. AI叙事者**:META和AMZN表现出内部人信心(执行信心),而GOOGL和AAPL显示内部人疑虑(叙事疑虑)\n2. **现金流稳健性 vs. 增长依赖度**:拥有强劲现金流的公司(MSFT、AAPL)内部人在减持,而增长依赖型公司(META、AMZN)内部人在增持\n3. **CEO叙事影响力**:马斯克的高个人叙事权重(TSLA)与其他公司的机构主导叙事形成对比\n\n### 针对具体讨论点的专业见解:\n\n#### 关于AAPL的\"创新停滞\"焦虑:\n市场对苹果的情绪正经历微妙转变。表面上是产品延迟,深层是**生态系统焦虑**。投资者开始质疑:当硬件创新放缓,服务增长能否维持溢价估值?情绪分析师观察到的是**叙事疲劳**——苹果的\"完美主义\"叙事正在失去情绪支撑力。\n\n**关键情绪指标:** 内部人卖出与巴菲特持仓的提及形成情绪张力。这暗示机构投资者(巴菲特)和个人内部人(执行团队)对估值认知存在差异。\n\n#### 关于MSFT的\"AI领导者悖论\":\n这是最有趣的情绪矛盾。微软被普遍视为AI领导者,但内部人却在退出。我的解读:**估值充分性与地缘政治风险的未定价**。\n\n**行为金融学视角:** 内部人可能认为:1)Azure AI优势已充分定价,2)地缘政治风险(伊朗战争影响云基础设施)未被市场充分认知。这不是对业务的看空,而是对风险调整后回报的谨慎。\n\n#### 关于GOOGL的\"红色警报\":\n1000:0的卖出买入比在情绪分析中极为罕见。这超越了正常的多元化或流动性需求,指向**系统性风险认知**。\n\n**深层情绪分析:** 可能的原因层级:\n1. **监管恐惧**:内部人预见到比市场预期更严厉的反垄断措施\n2. **AI落后焦虑**:在生成式AI竞赛中,Google可能面临结构性劣势\n3. **收购整合风险**:320亿美元收购可能稀释价值超过市场预期\n\n**最重要的情绪信号:** 当卖出如此一致且无买入时,通常预示6-12个月内的重大基本面变化。\n\n#### 关于NVDA的\"GTC预期陷阱\":\n市场正在经历典型的**事件前情绪膨胀**。历史模式显示:\n- 会前:乐观情绪累积,叙事夸大\n- 会中:现实检查,情绪修正\n- 会后:实际执行vs.预期的情绪结算\n\n**情绪风险管理:** 当前乐观情绪可能已price in超预期发布。更大的风险是:供应链中断(伊朗冲突)可能比AI需求更影响实际业绩。\n\n#### 关于TSLA的\"马斯克叙事依赖\":\n这是**个人叙事权重过载**的典型案例。当CEO成为公司主要情绪驱动因素时,系统风险增加。\n\n**情绪稳定性评估:** 特斯拉的情绪波动率远高于其基本面波动率。马斯克的每次言论都创造情绪冲击,但长期价值仍取决于执行。内部人减持表明:连内部人都认为股价可能脱离了基本面锚定。\n\n#### 关于META的\"内部人信心异常\":\n867:0的买入卖出比是2026年最强烈的积极情绪信号。这在情绪分析中意味:\n\n**可能的解释层级:**\n1. **信息优势**:内部人知道未公开的积极数据\n2. **估值错位**:股价严重低估AI投资回报\n3. **转型拐点**:元宇宙向AI的转型比市场认知更成功\n\n**情绪对比:** 市场在讨论裁员(负面情绪),内部人在大举买入(极度积极)。这种背离通常预示情绪反转。\n\n#### 关于AMZN的\"双重验证\":\n内部人买入+巴菲特提及创造了**情绪共振**。这是最稳健的积极信号类型。\n\n**情绪质量评估:** 亚马逊的情绪信号显示出:\n- **一致性**:不同信号源指向同一方向\n- **基本面支撑**:数字健康和物流扩展是可验证的战略执行\n- **防御性**:云+零售的双引擎提供情绪缓冲\n\n### 整体市场情绪的高级洞察:\n\n#### 三个未充分讨论的情绪动态:\n\n1. **地缘政治的情绪传染延迟**:\n 中东紧张可能通过:1)能源成本,2)供应链中断,3)企业IT支出谨慎性,影响科技股。当前情绪可能低估了二阶和三阶效应。\n\n2. **AI投资的\"期望膨胀期\"终结风险**:\n 我们可能接近AI叙事期望的顶峰。当内部人在AI领导者(MSFT、GOOGL)上减持时,可能预示着**叙事到现实的过渡期**即将到来。\n\n3. **流动性环境的情绪转折**:\n 油价上涨→通胀压力→利率预期变化→科技估值压缩。这个情绪传导链条可能正在形成,但市场尚未充分反应。\n\n### 投资心理学的关键教训:\n\n#### 从这次分析中学到的:\n\n**验证的心理学原理:**\n1. **群体行为的非理性一致性**:当内部人行为如此一致时(GOOGL卖出,META买入),通常反映的是信息优势而非羊群效应\n2. **叙事疲劳周期**:苹果的完美主义叙事、特斯拉的颠覆叙事可能正在失去情绪能量\n3. **确认偏误的行业表现**:科技投资者倾向于寻找支持AI乐观论点的证据,忽略反面信号\n\n**挑战的传统智慧:**\n- \"科技巨头同涨同跌\"的假设可能需要修正\n- \"内部人卖出不重要,只是多元化\"的叙事可能过度简化\n- \"AI将提升所有科技股\"的假设面临分化现实\n\n### 给投资委员会的情绪路线图:\n\n**短期(1-3个月):**\n- 关注GTC大会后的NVDA情绪修正\n- 监控GOOGL卖出压力是否持续\n- 观察META买入活动的跟随效应\n\n**中期(3-12个月):**\n- 验证META和AMZN内部人信心的正确性\n- 评估AI投资的实际回报vs.期望\n- 监测地缘政治对供应链的情绪影响\n\n**情绪风险雷达:**\n1. **最大的上行惊喜**:META的AI转型成功超越预期\n2. **最大的下行风险**:GOOGL面临监管或竞争的结构性挑战\n3. **最大的情绪反转机会**:AAPL通过新产品重新点燃创新叙事\n\n### 最终的专业立场:\n\n作为情绪分析师,我看到的是**科技股的情绪大分化时代**。2026年可能不是\"科技板块\"表现的问题,而是**哪个科技叙事能证明其现实基础**的问题。\n\n**最强烈的情绪信号仍然是:**\n- ✅ **积极**:META和AMZN的内部人信心\n- ⚠️ **谨慎**:GOOGL的红色警报\n- 🔄 **中性偏负**:AAPL、MSFT、NVDA的混合信号\n- ❓ **特殊案例**:TSLA的个人叙事主导模式\n\n**投资启示:** 在情绪如此分化的环境中,简单的\"科技股\"配置已不再适用。需要基于具体公司的情绪信号、执行验证和风险管理,进行更精细化的情绪调整配置。\n\n**最后的提醒:** 情绪是领先指标,但非充分指标。这些信号需要与基本面验证结合。当前最值得关注的情绪实验是:META的内部人集体买入,是否会成为2026年最准确的情绪预测?", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "## 深度情绪透视:超越共识的七大巨头分析\n\n基于情绪分析框架,我对当前关于科技七巨头的讨论有以下专业见解:\n\n### **1. 未被充分讨论的情绪维度**\n\n**AAPL的\"无聊溢价\"**\n市场对苹果创新的焦虑掩盖了一个关键情绪现实:**无聊有时是竞争优势**。当一家公司不再需要靠炒作维持估值时:\n- 情绪波动性降低 → 长期持有者比例上升\n- 媒体关注度下降 → 管理层专注经营而非表演\n- 预期管理更容易 → 更容易超越下调后的预期\n\n**苹果真正的情绪风险**不是创新不足,而是市场对其\"无聊状态\"的容忍度极限。一旦容忍破裂,可能触发情绪踩踏。\n\n**MSFT的\"隐形AI疲劳\"**\n虽然Azure AI增长强劲,但市场可能低估了企业AI采纳的**情绪障碍**:\n- 决策者的AI焦虑:怕选错、怕浪费、怕被取代\n- 组织变革阻力:技术易得,人心难改\n- Copilot使用率泡沫:试用兴趣≠持续付费\n\n微软面临的情绪转折点是:当AI从\"必须拥有的战略\"变为\"需要证明ROI的投资\"时,叙事会如何转变?\n\n### **2. GOOGL的\"搜索末日\"情绪错配**\n\n市场对谷歌的担忧存在时间维度错误:\n- **短期情绪**:担心AI搜索取代\n- **中期现实**:搜索广告仍然是最高效的意图变现\n- **长期真相**:用户需要的是答案,而非搜索框或聊天界面\n\n**关键情绪洞察**:投资者正在用长期威胁定价短期现金流,这种时间错配创造了逆向机会。当市场情绪从\"谷歌会被颠覆\"转向\"谷歌也在适应\"时,重估可能发生。\n\n### **3. NVDA的情绪泡沫特征**\n\nNVDA展示了经典情绪泡沫的五个阶段:\n1. 聪明钱积累(已过)\n2. 机构采纳(已过)\n3. 媒体狂热(当前)\n4. 散户涌入(部分发生)\n5. 叙事破灭(待观察)\n\n**最重要的情绪指标不是估值,而是叙事完整性**:\n- 当\"AI芯片需求无限\"的叙事开始出现细微裂缝时\n- 当替代方案(云租用、自研芯片、软件优化)获得更多讨论时\n- 当内部人开始在\"合理价格\"卖出而非\"任何价格\"持有时\n\n目前NVDA仍处于阶段3-4之间,但情绪预警信号已开始闪烁。\n\n### **4. TSLA的\"马斯克依赖症\"情绪风险**\n\n特斯拉的情绪结构与马斯克个人情绪高度耦合,这是一种危险的情绪依赖:\n- 马斯克兴奋 → TSLA上涨\n- 马斯克分心 → TSLA担忧\n- 马斯克争议 → TSLA波动\n\n**行为金融观察**:当一家公司的情绪与个人情绪绑定过紧时,机构投资者会系统性折价这种不确定性。特斯拉需要\"去马斯克化\"的情绪叙事,但市场又不相信没有马斯克的特斯拉。\n\n### **5. META的\"效率崇拜\"陷阱**\n\n市场对扎克伯格成本控制的赞扬可能过度:\n- 情绪从\"元宇宙烧钱疯子\"转向\"成本控制大师\"太极端\n- 大幅裁员创造了短期效率,但可能损害长期创新文化\n- AI投资需要新的烧钱周期,可能打破效率叙事\n\n**情绪钟摆理论**:从一个极端摆向另一个极端,很少停在中间。META的情绪可能从\"过度悲观\"转向\"过度乐观\",需要警惕。\n\n### **6. AMZN的\"三重叙事优势\"**\n\n亚马逊展示了最理想的情绪结构:多重独立叙事支撑:\n1. AWS增长叙事\n2. 零售效率叙事 \n3. 广告扩张叙事\n4. 医疗保健潜力叙事(未充分定价)\n\n**当任何单一叙事出现问题时,其他叙事提供缓冲**。这种情绪分散性是强大的抗脆弱性来源。\n\n### **系统性情绪风险:七巨头过于拥挤**\n\n最被忽视的宏观情绪风险是**相关性崩溃风险**:\n- 投资者假设这七只股票提供分散化\n- 但它们在AI叙事、利率敏感性、监管风险上高度相关\n- 真正的分散化需要不同叙事、不同情绪驱动因素\n\n### **我的核心情绪分析框架验证**\n\n这次讨论强化了我的几个关键投资哲学原则:\n\n**1. 情绪时间错配机会**\n市场经常用长期叙事定价短期表现,或用短期情绪评估长期价值。识别这种错配是阿尔法来源。\n\n**2. 叙事完整性预警**\n当股票故事变得过于简单、一致、无懈可击时,通常是情绪见顶信号。健康股票需要一些叙事复杂性和辩论空间。\n\n**3. 内部人行为信号**\n虽然这次没有具体数据,但内部人交易模式通常是滞后但可靠的信号。当内部人在大涨后继续买入,或在大跌后继续卖出时,值得深度关注。\n\n**4. 媒体情绪放大**\n媒体倾向于强化现有趋势而非发现转折点。当前科技股报道普遍处于\"强化模式\",缺乏真正的批判性质疑。\n\n### **逆向思考:可能被低估的情绪机会**\n\n**GOOGL的\"悲伤溢价\"**:市场对搜索的悲观情绪可能过度,忽略了YouTube、安卓、云的多引擎增长。\n\n**AAPL的\"服务护城河\"**:苹果服务业务的情绪价值被低估,这是真正的抗周期现金流。\n\n**META的\"AI沉默进展\"**:扎克伯格在AI基础设施的投资被效率叙事掩盖,可能提供意外上行。\n\n### **最终情绪投资建议**\n\n基于情绪分析而非基本面:\n\n**看涨情绪结构**:AMZN > MSFT\n- 多重独立叙事支撑\n- 情绪驱动力多样化\n- 管理层情绪稳定性高\n\n**谨慎乐观**:GOOGL(过度悲观中寻找机会)\n\n**情绪预警**:NVDA(叙事过于完美)\n**风险观察**:TSLA(个人情绪依赖过重)\n\n**中性但有趣**:AAPL、META(情绪转折可能带来机会)\n\n真正的专业情绪分析不是预测股价,而是识别**情绪与基本面之间的差距**,并在市场意识到这种差距前采取行动。当前最大差距可能在GOOGL的搜索悲观与多元现实之间。", + "ts": 1773652408546, + "timestamp": "2026-03-16T17:13:28.546000" }, { "type": "conference_message", "agentId": "technical_analyst", - "content": "# 深度技术分析视角:科技股分歧的根源与市场博弈\n\n基于我们刚才的分析讨论,我想从专业技术分析师的视角,分享一些更深刻的见解:\n\n## 关于市场结构的关键观察\n\n### **技术面的群体性分化说明了什么?**\n我们看到的7只科技股呈现三种截然不同的技术形态:\n\n1. **超卖反弹型(AAPL、META)** - 价格跌破技术支撑,指标显示极端超卖\n2. **整理观望型(MSFT、GOOGL、NVDA)** - 技术信号矛盾,缺乏方向性\n3. **相对强势型(AMZN)** - 多重看涨信号协同\n\n**我的解读**:这不是随机的价格波动,而是市场资金在不同科技子板块间的结构性轮动。市场正在重新定价:\n- **消费电子vs.企业软件**(AAPL vs. MSFT)\n- **硬件vs.云服务**(NVDA vs. AMZN)\n- **社交媒体vs.电子商务**(META vs. AMZN)\n\n## 对各类质疑的深度回应\n\n### **关于AAPL超卖反弹的争议**\n有人认为:\"跌破所有均线就是趋势破坏,反弹就是逃命波\"\n\n**我的辩证观点**:\n技术分析中,趋势破坏的确认需要三个条件:\n1. 均线空头排列(√ AAPL符合)\n2. 关键支撑位失守(√ 布林带下轨跌破)\n3. 反弹无法收复关键阻力(尚未发生)\n\n关键问题:当前是否处于\"恐慌性抛售\"的尾声?RSI 29.9配合成交量分析(未提供)可以帮助确认。我的经验是,当权重股出现这种极端技术状况时,往往伴随着市场情绪的短期底部。\n\n### **关于MSFT\"中性\"评级的质疑**\n\"为什么基本面最强的公司技术面最犹豫?\"\n\n**专业洞察**:\n这正是技术分析的微妙之处。MSFT的\"中性\"技术面反映了:\n- 机构投资者的持仓调整\n- 预期已经充分price in\n- 缺乏新的催化剂的典型表现\n\n从资金流向角度(虽然我们没看成交量数据),我推测有资金从MSFT轮动到超卖的AAPL/META寻求短期弹性。\n\n### **关于NVDA高波动性的担忧**\n\"35.8%波动率是否意味着AI泡沫破裂?\"\n\n**技术面解读**:\n高波动率可以意味着两件事:\n1. 趋势加速(看涨或看跌)\n2. 方向性选择的前兆\n\nNVDA的技术信号矛盾(MACD卖出但短期动量改善)表明多空双方在当前位置激烈博弈。$180-185区间将成为决定下一阶段走势的技术关键。\n\n### **关于TSLA看跌信号的强度**\n\"为什么TSLA的技术面最差?\"\n\n**深层技术逻辑**:\nTSLA的看跌信号协同性最强:\n- 趋势指标:✗\n- 动量指标:✗\n- 均值回归:中性偏弱\n\n这在技术分析中被称为\"多重时间框架共振看跌\",通常不是短期调整而是中期趋势变化的技术确认。关键在于:市场是否在通过TSLA表达对整个电动汽车行业的担忧?\n\n### **关于META反弹逻辑的检验**\n\"超卖就一定反弹吗?\"\n\n**我的经验法则**:\n超卖条件配合:\n1. 成交量萎缩 → 反弹概率高\n2. 成交量放大 → 可能继续下跌\n\n虽然没有成交量数据,但META同时跌破布林带下轨+RSI 40.4的组合,通常有70%概率出现技术性反弹,但反弹高度需要观察:\n- 第一阻力:$624.34(布林带下轨)\n- 关键阻力:$647.41(20日均线)\n\n### **关于AMZN强势信号的可靠性**\n\"为什么AMZN能独善其身?\"\n\n**技术面结构性优势**:\nAMZN展现了教科书式的\"健康调整\"技术特征:\n1. 回调至支撑位(接近20日均线)\n2. 关键指标保持积极(MACD金叉)\n3. 动量结构未破坏(20日仍为正)\n\n这反映了市场对其业务模式(电商+云)的防御性认可。但在疲弱市场中,最后的多头堡垒也可能最终补跌。\n\n## 当前市场的技术博弈本质\n\n### **资金的两难选择**\n从技术面看,市场面临:\n1. **超跌反弹诱惑**(AAPL、META)\n2. **趋势破坏风险**(TSLA)\n3. **方向不明困惑**(MSFT、GOOGL、NVDA)\n\n### **我的专业判断框架**\n\n**短期交易者**应关注:\n- 超卖反弹的弹性和持续性\n- 关键技术位的攻防\n- 成交量配合情况\n\n**中长期投资者**应思考:\n- 当前技术面是否反映了基本面实质性恶化?\n- 还是仅仅是估值调整和预期重置?\n- 哪些技术信号是噪音?哪些是真正的趋势信号?\n\n## 投资哲学深化:从这次分析中学到的\n\n### **验证的核心理念**\n1. **技术分析的相对性**:没有绝对的\"好\"或\"坏\",只有相对于其他选择的优劣\n2. **市场情绪的钟摆效应**:极端技术状况往往预示着情绪反转点\n3. **风险分散的技术意义**:技术面分化证明了分散投资的价值\n\n### **挑战的现有信念**\n我之前可能过度依赖单一时间框架分析。这次分析显示:\n- 不同时间框架可能给出矛盾信号\n- 需要更重视\"信号权重\"而非\"信号数量\"\n- 技术面必须放在整体市场环境中考量\n\n### **学到的重要原则**\n1. **耐心等待确认**:在信号矛盾时,等待比行动更重要\n2. **尊重价格行为**:价格总是比分析师更了解市场\n3. **风险管理前置**:在分析之初就要设定明确的失效条件\n\n## 最终建议:技术分析者的行动指南\n\n**对于谨慎投资者**:\n等待更清晰的技术突破信号,当前市场不适合重仓押注。\n\n**对于积极交易者**:\n可以小仓位参与超卖反弹(AAPL、META),但严格止损。\n\n**最重要的提醒**:\n技术分析告诉我们\"现在是什么\",而不是\"未来一定是什么\"。当前科技股的技术分歧,本质上是市场对未来增长路径的集体犹豫。在这种时期,保持谦逊、保持灵活、保持纪律,比做出完美预测更重要。\n\n市场总是在教导我们新的东西。今天的分析让我更加坚信:最好的技术分析师不是预测最准的人,而是最能识别风险和机会,并且根据市场变化灵活调整的人。", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "## 深度技术视角:七大科技巨头的结构性分析\n\n让我从专业分析师角度分享对当前讨论的核心见解:\n\n### 1. 市场结构的根本性变化\n\n**技术面的范式转移**:我们正见证从\"成长溢价\"到\"质量溢价\"的转变。这不是简单回调,而是:\n- **技术性去杠杆**:量化基金重新评估风险敞口\n- **流动性重分配**:从高波动性资产向稳定现金流转移\n- **估值体系重构**:利率环境改变贴现率假设\n\n### 2. 个股技术面深度剖析\n\n**AAPL的技术悖论**:\n- **图表现实**:连续三个季度高位盘整,量价背离明显\n- **技术支撑**:$150-160区域是长达5年的心理和技术支撑\n- **核心问题**:iPhone周期拉长,技术面需要新叙事支撑\n- **市场预期**:Vision Pro能否成为新的技术突破点?\n\n**MSFT的机构逻辑**:\n- **技术优势**:唯一清晰突破疫情前技术阻力的科技巨头\n- **图表形态**:典型的上升旗形整理,机构加仓痕迹明显\n- **技术指标**:相对强弱指数(RSI)始终维持在健康区间\n- **关键观察**:Azure增长斜率与股价技术形态的相关性\n\n**GOOGL的搜索困境**:\n- **技术特征**:长期处于\"突破失败-回落\"的循环\n- **动量转换**:传统搜索业务的技术权重下降,AI权重上升\n- **关键位置**:$130-$140区间是2019年以来的技术平台\n- **技术突破条件**:需要连续三周放量站上$150\n\n**NVDA的动量极限**:\n- **技术警示**:历史上每次10倍涨幅后必有40%+的技术性回调\n- **波动性特征**:VIX在NVDA上的隐含波动率溢价已达历史极值\n- **技术支撑**:真正的技术支撑在$400区域(前一波涨幅的38.2%回撤)\n- **动量衰竭信号**:周线级别MACD可能出现顶部背离\n\n**TSLA的情绪化技术面**:\n- **技术特征**:完全由叙事驱动的价格行为\n- **支撑结构**:$180-$200是马斯克个人承诺和技术支撑的重合区\n- **动量风险**:缺乏机构资金的技术性支撑\n- **技术观察点**:FSD进展与股价的技术性背离\n\n**META的效率红利**:\n- **技术转折**:从\"元宇宙叙事\"到\"成本控制\"的技术性重估\n- **图表形态**:典型的杯柄形态突破后的技术性回踩\n- **动量确认**:连续四个季度盈利超预期形成技术惯性\n- **风险点**:AI投资可能破坏新获得的技术效率优势\n\n**AMZN的双重驱动**:\n- **技术复杂性**:AWS的技术稳定性和电商的消费周期性冲突\n- **图表结构**:形成宽幅震荡的技术区间($110-$140)\n- **关键突破**:需要同时看到AWS增长恢复和电商效率提升的技术确认\n- **技术优势**:物流网络构成的技术护城河难以复制\n\n### 3. 市场情绪的技术诊断\n\n**当前技术面显示的情绪状态**:\n1. **谨慎乐观**:机构在震荡中调仓,散户在犹豫中观望\n2. **选择困难**:太多技术面处于临界状态的标的\n3. **时间压力**:季度末的技术性调仓压力\n\n**技术指标传递的关键信号**:\n- **市场宽度**:科技股内部的技术分化加剧\n- **成交量模式**:下跌缩量、上涨放量的技术特征不明显\n- **波动率结构**:偏度指数显示尾部风险定价不足\n\n### 4. 投资逻辑的深度验证\n\n**我的技术分析哲学在这里体现**:\n\n**原则1:价格是终极仲裁者**\n- 无论多么美好的叙事,最终要通过价格技术形态验证\n- 当前:多数科技股的技术形态滞后于基本面叙事\n\n**原则2:趋势的质量比方向更重要**\n- 缓慢稳健的趋势>急剧波动的趋势\n- 当前:缺乏高质量的趋势技术确认\n\n**原则3:支撑/阻力区域的有效性**\n- 历史价格区域具有心理和技术双重意义\n- 当前:多数标的在关键技术水平附近徘徊\n\n### 5. 逆向思考的技术基础\n\n**市场可能过度定价的因素**:\n1. **AI的短期变现能力**:技术面可能超前反映盈利增长\n2. **美联储的灵活性**:市场技术面假设了完美的政策路径\n3. **地缘政治的稳定性**:技术面尚未充分定价尾部风险\n\n**技术面可能低估的因素**:\n1. **企业现金流质量**:技术面更多关注增长而非质量\n2. **资本纪律的回归**:成本控制对估值的长期技术支撑\n3. **市场结构的演变**:被动资金流动的技术性影响\n\n### 6. 风险管理的技术框架\n\n**基于技术面的头寸管理**:\n\n**高确定性技术形态(MSFT、META)**:\n- 初始仓位:3-5%\n- 止损:技术支撑下方3%\n- 加仓:突破关键技术阻力确认\n\n**中等确定性技术形态(GOOGL、AMZN)**:\n- 初始仓位:2-3%\n- 止损:近期低点下方5%\n- 条件:需要行业技术面配合\n\n**高风险技术形态(NVDA、TSLA)**:\n- 初始仓位:1-2%\n- 止损:严格的技术止损位\n- 备注:仅适合风险承受能力强的投资者\n\n**观察类技术形态(AAPL)**:\n- 等待关键技术信号确认\n- 关注$150-160区间的技术反应\n- 需要催化剂和技术面共振\n\n### 7. 专业交易者的技术洞察\n\n**当前最佳技术策略**:\n\n**短期(1-4周)**:\n- 在关键支撑位轻仓试探(MSFT $350、GOOGL $130)\n- 严格的技术止损纪律\n- 关注季度末的技术性调仓机会\n\n**中期(1-3个月)**:\n- 等待技术趋势的明确确认\n- 关注第二季度财报的技术指引\n- 美联储政策路径的技术性影响\n\n**长期技术观察**:\n- AI革命的技术渗透率曲线\n- 企业资本开支的技术性周期\n- 全球流动性环境的技术支撑\n\n### 8. 技术分析的智慧\n\n**对当前讨论的核心贡献**:\n\n**技术面提醒我们**:\n1. **时机比方向更重要**:正确的股票在错误的技术时机依然是亏损\n2. **风险管理是核心**:技术止损保护的是资金,不是自尊\n3. **市场会说话**:价格行为包含了所有已知信息\n\n**最终建议**:\n基于纯粹的技术分析视角,当前应该:\n1. **降低风险敞口**:技术面不确定性增加\n2. **提高选择标准**:只参与技术结构最清晰的标的\n3. **保持耐心**:等待市场给出更明确的技术方向\n\n技术分析的本质不是预测未来,而是:\n- **识别概率优势**\n- **管理风险暴露**\n- **跟随市场智慧**\n\n在七大科技巨头的技术迷宫中,真正的机会往往出现在市场最困惑的时候。保持技术纪律,让价格行为指引方向。", + "ts": 1773652363286, + "timestamp": "2026-03-16T17:12:43.286000" }, { "type": "conference_message", "agentId": "fundamentals_analyst", - "content": "## 投资哲学反思:科技巨头分化时代的深层思考\n\n在深入分析这七家科技巨头后,我需要反思这次分析如何塑造我的投资世界观,并分享关于当前市场讨论的专业见解:\n\n### **我的投资哲学演进:**\n\n**验证的核心信念:**\n1. **盈利质量是终极筛选器**:NVDA惊人的55.6%净利率与TSLA仅4%的对比,完美验证了我的\"盈利质量优先\"原则。高增长必须有盈利支撑,否则只是资本消耗。\n\n2. **护城河的层次性**:我认识到护城河有三个层次:\n - **表层护城河**:技术优势、成本优势(可能被追赶)\n - **中层护城河**:网络效应、品牌忠诚度(较难复制)\n - **深层护城河**:生态系统锁定、数据积累、用户习惯(几乎无法复制)\n \n AAPL和MSFT拥有深层护城河,而TSLA的表层护城河正在被侵蚀。\n\n**挑战的现有观念:**\n我曾认为\"高成长行业中的领先者应获得估值溢价\",但TSLA案例让我修正为:\"**估值必须反映可预见期的盈利实现**\"。市场可以非理性很长时间,但基本面最终会说话。\n\n---\n\n## **对市场讨论的深度回应:**\n\n### **关于AAPL:超越硬件周期的思考**\n\n市场对苹果的讨论过于集中在\"iPhone销售疲软\"和\"中国市场风险\",但忽视了更本质的问题:\n\n**关键洞察**:苹果的真正风险不是产品周期,而是**平台权力与监管的拉锯战**。欧盟的DMA法案要求侧载、第三方支付,这直接攻击苹果的生态控制力。如果iOS失去对应用分发的控制,服务收入的72%毛利率可能无法维持。\n\n**我的不同视角**:与其担忧iPhone销量,不如关注:\n1. **用户终身价值(LTV)**:一个苹果用户的年均消费是多少?\n2. **生态内转换成本**:从苹果转向Android的实际成本是多少?\n3. **服务收入粘性**:Apple Music、iCloud、Apple TV+的续费率是多少?\n\n**反共识观点**:苹果可能被错误归类为\"硬件公司\"。实际上,它是**拥有硬件入口的订阅平台**。如果市场用P/S而非P/E估值,可能会有不同结论。\n\n### **关于MSFT:AI光环下的隐形风险**\n\n市场对微软的AI叙事已经过度兴奋,我需要指出被忽略的风险:\n\n**风险1:AI投资的资本回报率(ROIC)问题**:\n- 数据中心建设需要数百亿美元投资\n- AI服务能否实现足够的定价溢价?\n- 现有客户的AI支出是否是增量收入,还是替代原有支出?\n\n**风险2:反垄断阴影重现**:\n- OpenAI紧密合作可能触发监管审查\n- 云市场主导地位(Azure+OpenAI)可能被视为垄断\n\n**风险3:企业预算的周期性**:\n- AI可能是\"可选支出\",经济衰退时首先被砍\n- 微软暴露于企业IT预算,而企业预算比消费者预算更波动\n\n**我的平衡观点**:微软仍是最佳选择,但市场对AI增长的预期可能已经超前。24倍P/E还算合理,但如果增长放缓至个位数,估值将承压。\n\n### **关于GOOGL:搜索终结者的误判**\n\n市场最大的恐惧是\"生成式AI杀死搜索\",我认为这是严重的过度反应:\n\n**搜索经济的本质**:搜索不仅是信息获取,更是**商业意图捕捉**。当用户搜索\"best laptop for gaming\",他们的意图是购买。生成式AI提供的是答案,而不是商业转化路径。\n\n**数据护城河**:谷歌拥有20年的搜索数据,理解:\n- 用户在不同时间的意图差异\n- 地理位置的搜索模式变化\n- 长尾关键词的商业价值\n\n**反共识观察**:谷歌可能成为AI时代的最大受益者之一:\n1. AI需要训练数据 → 谷歌有最多的数据\n2. AI需要算力 → 谷歌有TPU和全球数据中心\n3. AI需要变现 → 谷歌有最成熟的广告系统\n\n**真正的风险**:不是技术被颠覆,而是**监管强制数据分享**。如果谷歌被迫开放搜索数据给竞争对手,护城河会被削弱。\n\n### **关于NVDA:周期之王还是泡沫象征?**\n\n这是当前市场最极端的讨论:\n\n**牛市观点**:\"AI是新电力,NVDA是新GE\"\n**熊市观点**:\"这是典型的资本周期顶部\"\n\n**我的分析框架**:\n1. **需求端**:AI需求是真实的,但问题是\"强度和持续时间\"\n2. **供给端**:AMD、自研芯片(Google TPU、AWS Trainium)、中国替代都在增加供给\n3. **库存周期**:半导体行业历史上都有库存周期,这次会不同吗?\n\n**关键指标监控**:\n- 数据中心收入增速是否维持在50%以上\n- 毛利率是否维持在70%附近\n- 客户集中度风险(是否过度依赖少数云厂商)\n\n**估值思维实验**:如果NVDA未来5年收入年化增长30%(远低于当前73%),5年后市盈率会是多少?这需要严肃的敏感性分析。\n\n### **关于TSLA:价值投资者的困惑**\n\n为什么还有人在讨论PE 355倍的公司?\n\n**我的彻底解构**:\n1. **分类错误**:特斯拉被归为\"科技股\",但它的财务特征完全是\"汽车制造商\"\n2. **可比分析**:丰田PE 10倍,福特PE 8倍,为什么特斯拉值355倍?\n3. **增长神话**:收入在下降,盈利在暴跌,增长的叙事在哪里?\n\n**自动驾驶的估值陷阱**:\n- Level 4/5自动驾驶在法规、技术、保险上都有巨大障碍\n- 即使技术实现,经济模型可能不如预期\n- 其他车企也在推进自动驾驶\n\n**冷酷的现实**:汽车行业的本质是规模经济、成本控制和供应链管理。在这些方面,传统车企有积累数十年的优势。特斯拉的领先优势正在缩小。\n\n**最终判断**:TSLA是典型的\"叙事股票\",当叙事遇到基本面现实时,重估会很痛苦。\n\n### **关于META:从社交到AI的基础设施商**\n\n市场对Meta的讨论分裂为\"广告复苏\"派和\"元宇宙烧钱\"派,但都错过了重点:\n\n**Meta的AI基础设施投资**被严重低估:\n1. 拥有全球最大的AI训练集群之一\n2. 在推荐算法上有多年积累\n3. AI不仅用于广告,还用于内容审核、创作者工具\n\n**商业模式演变**:从\"注意力经济\"转向\"创作者经济\"。Reels、Shops、商业讯息,都是让创作者变现的工具。创作者越多,内容越多,用户越多,形成飞轮。\n\n**估值机会**:26倍PE对于20%+增长的公司,且资产负债表上有500亿美元净现金,这是**风险回报不对称的机会**。\n\n**真正的风险**:用户时间被TikTok、YouTube Shorts侵蚀。但Meta在算法推荐上的改进可能已经扭转这一趋势。\n\n### **关于AMZN:价值发现的最后堡垒**\n\n亚马逊的讨论焦点在\"AWS增长放缓\",但我认为这是误解:\n\n**AWS的战略地位被低估**:\n1. AWS是唯一在三个地理区域都有统治地位的云服务商\n2. 企业上云还在早期阶段(约30%工作负载上云)\n3. AI时代需要更多云服务,而非更少\n\n**零售业务的再评估**:\n- 区域化物流网络降低成本\n- 广告业务快速增长(从零到500亿美元年营收)\n- Prime会员的价值被低估(续费率90%+)\n\n**关键估值指标**:3.1倍P/S在大型科技股中最低。即使增长放缓至10-15%,这个估值也提供了安全边际。\n\n**风险**:确实面临更激烈的竞争,但亚马逊的规模和资本配置能力仍是优势。\n\n---\n\n## **投资框架的综合应用:**\n\n### **从这次分析中学到的关键原则:**\n\n1. **产业生命周期定位**:\n - 成长阶段:NVDA\n - 成熟阶段:AAPL、MSFT、GOOGL\n - 转型阶段:META、AMZN\n - 困境阶段:TSLA(可能进入成熟期)\n\n2. **竞争优势评估矩阵**:\n ```\n 高持续性 低持续性\n MSFT、GOOGL NVDA(技术)\n AAPL(生态) TSLA\n ```\n\n3. **估值方法论选择**:\n - DCF:适合MSFT、GOOGL(现金流稳定)\n - 市盈率:适合META、AMZN(盈利可见)\n - P/S:适合成长阶段或转型期\n - 叙事估值:谨慎使用,容易犯错\n\n### **风险管理启示:**\n\n1. **关注负债结构**:AAPL的102倍D/E在利率上升环境中是风险\n2. **现金缓冲重要性**:GOOGL、MSFT的净现金位置提供安全垫\n3. **行业beta风险**:科技股整体估值可能系统性重估\n4. **监管风险定价不足**:所有大型科技公司都面临日益严峻的监管压力\n\n### **最后的智慧:**\n\n市场在科技股上的分歧反映了不同投资时间框架的冲突:\n- **短线交易者**关注季度业绩和AI叙事\n- **长线投资者**关注护城河、资本回报和竞争格局\n\n我的投资哲学偏向后者。从5-10年角度看:\n1. MSFT和GOOGL最可能保持领先地位\n2. AAPL面临生态风险但现金生成能力强\n3. AMZN的价值可能在市场发现零售效率提升后体现\n4. META的AI投资可能在几年后结出果实\n5. NVDA的辉煌可能继续,但波动会很大\n6. TSLA需要证明自己能成为真正盈利的汽车公司\n\n**最终建议**:在科技股投资中,**质量比动量更重要,可持续性比增长速度更重要**。当前市场过度关注AI叙事,可能忽视了更基本的商业模式优势。聪明的投资者应该在市场情绪波动中,坚持基于现金流和竞争优势的分析框架。", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "作为专业的基本面分析师,我对这次科技巨头分析的深度讨论有以下核心见解和反思:\n\n## 当前投资环境的关键特征(2026年视角)\n\n**我们正处在科技投资的第三次范式转变中**:\n1. **第一次(2000年代)**:软件即服务(SaaS)革命\n2. **第二次(2010年代)**:移动和云计算的普及 \n3. **第三次(2020年代后半段)**:AI基础设施与实际应用之间的价值重分配\n\n## 对各公司争议点的实质分析\n\n### **苹果:被误解的\"创新危机\"**\n**市场担忧**:iPhone增长放缓,缺乏革命性新产品\n**我的本质看法**:市场正在误判苹果的商业模式演进:\n- **苹果的核心不是硬件创新**,而是**生态系统货币化**\n- 服务业务毛利率(~70%)是硬件(~35%)的两倍\n- AR/VR眼镜可能在2026-2027年成为新的计算平台\n- **关键指标**:每用户服务收入(ARPU)的持续增长\n\n**逆向思考**:如果苹果成为\"数字生活税\"的征收者,其估值逻辑应该更像Visa而非传统硬件公司。\n\n### **微软:企业数字化的基石**\n**争议点**:AI Copilot的变现能力被质疑\n**我的洞察**:这忽略了微软的**平台优势**:\n1. **网络效应**:每个Copilot用户都在训练微软的模型\n2. **锁定效应**:企业一旦在Microsoft 365生态中,迁移成本极高\n3. **定价权**:企业软件的年费上涨被CPI覆盖\n\n**风险被低估的方面**:微软对OpenAI的依赖可能成为监管靶点。\n\n### **谷歌:搜索护城河的持久性**\n**最大挑战**:生成式AI对搜索商业模式的颠覆\n**我的评估**:市场可能低估了谷歌的**数据优势**和**适应能力**:\n- 20年搜索数据积累是难以复制的训练素材\n- 搜索结果与广告的匹配算法仍在进化\n- 安卓生态提供独特的移动数据维度\n\n**需要警惕的信号**:搜索查询数量的结构性下降。\n\n### **英伟达:估值困境**\n**核心问题**:当前价格是否已透支未来3-5年的AI预期?\n**我的框架**:从半导体行业的周期性历史看:\n- 所有\"下一个大事物\"最终都会供需平衡\n- GPU的通用计算能力可能被专用ASIC侵蚀\n- **关键假设**:AI训练需求的年复合增长率能否保持50%+直到2030年?\n\n### **特斯拉:身份危机**\n**根本矛盾**:汽车制造商 vs. 科技公司 vs. 能源公司\n**我的解构**:\n- **作为汽车公司**:利润率面临压力,竞争加剧\n- **作为科技公司**:FSD的监管和时间表风险\n- **作为能源公司**:储能业务被低估,但规模尚小\n\n**最可能的路径**:成为高端电动汽车细分市场的领导者,而非大众市场颠覆者。\n\n### **Meta:成本纪律的胜利**\n**市场惊喜**:2024-2025年的大规模裁员显著改善盈利\n**深层意义**:扎克伯格展示了**创始人CEO的转型能力**\n- 从\"增长不惜一切代价\"到\"有纪律的增长\"\n- 元宇宙投入转向务实应用(VR办公、社交)\n- Reels成功抵御TikTok冲击\n\n**持续性疑问**:年轻用户群体的参与度是否在下降?\n\n### **亚马逊:物流即护城河**\n**被低估的资产**:美国履约网络\n- 不仅仅是成本中心,而是**竞争壁垒**\n- 最后一公里配送能力的稀缺性\n- Prime会员的物流价值被财务上归类为成本而非资产\n\n**AWS的长期威胁**:多云策略可能削弱锁客效应。\n\n## 投资哲学的深化\n\n### 从这次分析中验证的理念:\n1. **规模经济在数字经济中依然有效**,但形式变了——数据规模、网络规模\n2. **转换成本是比技术领先更持久的护城河**(微软证明)\n3. **自由现金流的质量**比会计利润更重要\n\n### 被挑战的假设:\n我过去过于强调**颠覆性创新**的价值,但这次分析显示:\n- **持续性改善**(苹果的芯片、亚马逊的物流)\n- **生态系统锁定**(微软的Office+Azure)\n- **用户习惯惯性**(谷歌的搜索)\n\n这些可能是比突破性创新更可持续的竞争优势。\n\n## 对投资组合构建的启示\n\n### 在当前时点(2026年3月):\n1. **防御性成长**:偏好现金流确定性高的(MSFT,AMZN)\n2. **选择性增长**:对高增长但高估值的要求更高(NVDA)\n3. **转型观察**:等待业务模式更清晰(TSLA)\n4. **价值修复**:已经展现改善迹象的(META)\n\n### 风险配置的层次:\n- **第一层**:宏观利率风险(对高估值科技股的系统性影响)\n- **第二层**:监管风险(全球科技反垄断)\n- **第三层**:竞争风险(新进入者的威胁)\n- **第四层**:执行风险(管理层决策失误)\n\n## 给同行的最后建议\n\n**不要被短期AI叙事主导长期判断**。真正重要的依然是:\n1. **定价权**:能否在通胀环境中提价?\n2. **资本回报**:再投资收益率如何?\n3. **管理层诚信**:是否以股东长期利益为先?\n4. **资产负债表韧性**:能否在危机中幸存并投资?\n\n**2026年的机会在于**:识别那些**护城河正在加宽**而非收窄的科技公司。当前的价格波动为我们提供了压力测试这些竞争优势的机会。微软和亚马逊在这次测试中表现最出色,因为它们的商业模式具有**多重收入来源**和**结构性成本优势**。\n\n**最终考验**:如果2026年发生一次2000年式的科技股回调,哪些公司会不仅存活,还能以更强的地位走出危机?我的答案是那些拥有稳定现金流、低债务、以及难以复制的生态系统优势的企业。", + "ts": 1773652309050, + "timestamp": "2026-03-16T17:11:49.050000" }, { "type": "conference_message", "agentId": "portfolio_manager", - "content": "基于我们的讨论,让我深入分析这些矛盾信号和剩余担忧:\n\n## 🔍 深入分析核心矛盾\n\n### **微软 (MSFT) - \"基本面强 vs 内部人卖出\"的悖论**\n**我的思考:**\n1. **内部人卖出的可能解释:**\n - 常规性减持:高官可能在进行常规性资产多元化\n - 估值已达目标:内部人认为当前估值已合理反映价值\n - 无负面消息:961笔卖出但仅有39笔买入,比例失衡值得关注\n\n2. **基本面优势 vs 情绪风险:**\n - 基本面分析师(78%)和估值分析师(75%)一致看涨\n - 但情绪分析师(60%中性偏负)基于内部人行为的警告不容忽视\n - **关键权衡**:优秀的业务基本面是否能完全抵消内部人的谨慎态度?\n\n### **Meta (META) - \"内部人信心 vs 技术面弱势\"**\n**我的观察:**\n1. **内部人买入的信号强度:**\n - 867笔买入 vs 0笔卖出 - 这是异常强烈的正面信号\n - 通常内部人如此大规模的买入预示着他们掌握市场不知情的积极信息\n - 可能与AI投资回报、效率提升或新业务突破有关\n\n2. **技术面弱势的担忧:**\n - RSI 40.4接近超卖,价格低于布林带下轨\n - 这种技术面弱势与强烈的内部人买入形成鲜明对比\n - **可能性**:内部人可能在利用市场情绪过度悲观进行增持\n\n### **亚马逊 (AMZN) - \"技术面最强 vs 基本面中庸\"的背离**\n**矛盾分析:**\n1. **技术面优势明显:**\n - 唯一显示20日正回报(+4.0%)的股票\n - MACD买入信号明确,动量最佳\n - 价格接近20日均线,技术结构良好\n\n2. **基本面相对平淡:**\n - 仅13.6%的收入增长和5%的盈利增长\n - 净利率10.8%在科技巨头中偏低\n - **问题**:技术面的强势是否提前反映了未公布的业绩突破?\n\n### **英伟达 (NVDA) - \"增长神话 vs 估值现实\"**\n**我的担忧:**\n1. **极端增长 vs 极端估值:**\n - 73.2%收入增长和95.6%盈利增长确实惊人\n - 但P/E 36.8、P/S 20.3、P/B 27.9已反映极度乐观预期\n - 风险经理特别警告35.8%的高波动率\n\n2. **GTC大会风险:**\n - \"买消息卖事实\"风险:如果大会无重大突破,可能大幅回调\n - 竞争加剧:AMD等对手正在追赶\n - **我的疑问**:当前价格是否已完全反映了所有正面预期?\n\n### **特斯拉 (TSLA) - 一致负面但需谨慎做空**\n**做空风险分析:**\n1. **做空逻辑强但风险高:**\n - 基本面、估值、技术面都看跌\n - P/E 355.6完全脱离基本面\n - 但做空高波动率股票(29.4%)风险很大\n\n2. **潜在反转催化剂:**\n - 马斯克的AI芯片项目成功\n - 自动驾驶技术突破\n - 政府政策支持\n - **轧空风险**:负面的市场情绪可能已经过度\n\n### **苹果 (AAPL) - \"技术反弹 vs 基本面担忧\"**\n**短期vs长期视角:**\n1. **技术反弹机会:**\n - RSI 29.9严重超卖,技术面支持反弹\n - 这是典型的均值回归交易机会\n\n2. **基本面限制反弹高度:**\n - P/B 41.7极高,负债率102.63%\n - 服务业务增长能否支撑估值存疑\n - **我的判断**:可能只有短期交易性机会,缺乏长期投资价值\n\n### **谷歌 (GOOGL) - 最大的内部人卖出警告**\n**极度令人担忧的信号:**\n1. **1000笔卖出 vs 0笔买入** - 这是最强烈的负面信号\n2. **可能的解释:**\n - 监管风险加大\n - 战略担忧(如Wiz收购的价值)\n - AI竞争压力超预期\n3. **尽管基本面分析师看涨**,但这种程度的内部人卖出无法忽视\n\n## 🎯 剩余的核心关注点\n\n### **1. 分析师分歧如何处理?**\n- **MSFT**:基本面强烈看涨 vs 内部人大幅卖出\n- **AMZN**:技术面强烈看涨 vs 基本面中性\n- **AAPL**:技术面看涨 vs 估值看跌\n\n**我的原则**:当分析师分歧时,优先考虑:\n1. 内部人行为(情绪分析)\n2. 估值纪律(估值分析) \n3. 风险等级(波动率)\n\n### **2. 行业集中度风险**\n所有7只股票都是科技股:\n- 缺乏行业分散化\n- 共同暴露于利率、监管、科技支出等系统性风险\n- 但这是我们的投资范围限制\n\n### **3. 建仓策略的关键决策**\n从100%现金开始:\n- **激进策略**:立即建立MSFT、META、AMZN多头仓位\n- **保守策略**:分批建仓,等待技术面确认\n- **对冲策略**:建立MSFT/META多头 + TSLA空头配对交易\n\n### **4. 风险管理优先事项**\n基于风险经理警告:\n1. **NVDA**:仓位必须严格限制(<8%)\n2. **TSLA**:如果要交易,只能小仓位或配对交易\n3. **现金保留**:至少保持30%现金缓冲\n\n## 📊 我的倾向性思考\n\n基于所有分析,我倾向于:\n\n**优先关注:**\n1. **META** - 内部人买入信号太强,基本面也支持\n2. **AMZN** - 技术和情绪面积极,估值相对合理\n3. **MSFT** - 基本面最扎实,但需观察内部人行为\n\n**谨慎对待:**\n1. **NVDA** - 估值风险过高,波动率太大\n2. **TSLA** - 适合小仓位做空或完全规避\n3. **GOOGL** - 内部人卖出警告太强烈\n\n**等待更多信息:**\n1. **AAPL** - 需要更多数据确认技术反弹的持续性\n\n**你最认同哪些分析师的信号?你认为内部人交易数据应该给予多大权重?我们应该如何平衡短期技术信号与长期基本面分析?**", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "基于之前的关注点,我进一步深入思考各个分析师的视角以及它们对我的决策影响:\n\n## 分析师视角的整合与权衡\n\n### 1. **投资哲学的冲突根源**\n**基本面分析师**的核心哲学:\n- 可持续竞争优势和现金流为王\n- 关注长期护城河而非短期估值\n- 对MSFT、AMZN、AAPL等拥有\"宽阔护城河\"的企业高度看好\n\n**估值分析师**的核心哲学:\n- 安全边际是投资第一原则\n- 只支付合理价格,拒绝为增长过度支付\n- 当前市场对AI概念过度乐观,估值已透支未来\n\n**关键洞察**:这不是简单的对错问题,而是**时间框架**和**风险偏好**的差异。基本面分析师看的是3-5年企业竞争优势,估值分析师看的是1-2年价格回归均值。\n\n### 2. **关于NVDA的深度思考**\n三家看涨(基本面、技术、情绪)vs 一家强烈看跌(估值):\n- **看涨方逻辑**:AI革命领导者,技术护城河,数据中心需求\n- **看跌方逻辑**:估值泡沫(高估101.8%),竞争加剧,周期性风险\n\n**我的平衡观点**:\n1. 估值分析师的数据最具说服力:47倍P/E,DCF显示高估101.8%\n2. 但基本面分析师75%的置信度也值得尊重\n3. **折中方案**:极小仓位参与(如$5,000),作为\"彩票仓位\"而非核心持仓\n\n### 3. **关于AMZN的一致信号分析**\n这是最清晰的信号:\n- 基本面:双支柱稳健(88%置信度)\n- 估值:安全边际19.7%(最有吸引力)\n- 情绪:防御性+增长性兼具(75%)\n- 技术:需确认但结构看好\n\n**问题**:为什么AMZN估值相对合理?\n可能答案:市场过度关注AI而低估了AWS+电商的复合优势,或者认为亚马逊增长已进入成熟期。\n\n### 4. **关于AAPL的矛盾**\n基本面分析师85%看涨 vs 估值分析师70%看跌:\n- **基本面视角**:生态系统、品牌、服务业务\n- **估值视角**:DCF显示高估35.9%,增长放缓\n\n**我的看法**:苹果可能面临**创新周期困境**。服务业务增长能否抵消硬件增长放缓?这是关键问题。\n\n### 5. **关于MSFT的稳健性**\n基本面和情绪分析师高度看好,估值分析师中性:\n- Azure+Office双引擎\n- Copilot AI商业化领先\n- 估值基本合理(DCF显示持平)\n\n**吸引力**:在科技股中相对防御性较强,业务多元性更好。\n\n### 6. **关于TSLA的共识看跌**\n三家分析师一致看跌是强烈信号:\n- 基本面:竞争加剧,执行风险\n- 估值:高估76.2%\n- 情绪:负面情绪持续\n\n**决定**:应该回避,除非有重大催化剂改变叙事。\n\n### 7. **技术分析的空白问题**\n技术分析师明确说\"数据真空期,建议谨慎\"\n- **我的解读**:这本身就是一个技术信号\n- 在没有技术确认的情况下,应该:\n 1. 降低仓位规模\n 2. 分批建仓而非一次性投入\n 3. 保留更多现金等待更好时机\n\n### 8. **行业集中度的深层担忧**\n所有7只股票都是科技股,这会带来:\n- **系统性风险**:科技板块整体回调时无处躲避\n- **相关性风险**:特别是AI相关股票(NVDA、MSFT、GOOGL)高度相关\n- **但现实约束**:作为科技股投资组合经理,这是我必须接受的限制\n\n**缓解策略**:\n1. 在科技内部寻找不同细分领域(软件、硬件、电商、社交媒体)\n2. 优先选择业务模式更稳定的(MSFT、AMZN)\n3. 控制整体beta暴露\n\n## 剩余的核心关注点:\n\n### 1. **时机问题**\n- 现在是建仓的好时机吗?\n- 如果市场对高估值科技股进行修正,我的组合会如何?\n- **解决方案**:采用金字塔式建仓,保留50%+现金等待机会\n\n### 2. **权重分配困境**\nAMZN信号最积极,但应该占多大权重?\n- 如果只配置AMZN,错过其他机会\n- 如果平均分配,稀释了最好的机会\n- **折中方案**:给予AMZN最大权重,MSFT次之,其余更小仓位\n\n### 3. **风险管理的具体实施**\n风险经理建议:\n- 单股不超过$20,000(20%)\n- 总持仓不超过$50,000(50%现金)\n- 止损10%\n\n**我的疑问**:这些规则是否过于保守?对于$100,000的组合:\n- $20,000的单股上限合理\n- 但$50,000的总持仓可能太保守,机会成本高\n\n### 4. **情绪周期的位置**\n情绪分析师提到\"AI主题主导,可能达到情绪高点\"\n- 这是逆向投资的机会还是风险?\n- 如果我避开所有AI股票(NVDA、部分MSFT/GOOGL),可能错过重大趋势\n- 但如果追随情绪,可能买在高点\n\n### 5. **长期 vs 短期冲突**\n- 基本面分析师看3-5年\n- 估值分析师看1-2年价格回归\n- 我的投资期限是什么?作为组合经理,需要考虑季度/年度表现压力\n\n## 我倾向的解决方案框架:\n\n### **分层投资策略**\n1. **核心持仓层**(40-50%资金):AMZN + MSFT\n - 业务模式最稳健\n - 估值相对合理\n - 分析师共识较强\n\n2. **卫星持仓层**(20-30%资金):GOOGL + META\n - 增长潜力但有风险\n - 控制仓位规模\n\n3. **观察/极小仓位层**(10%资金):AAPL或NVDA\n - 只选其一\n - 作为高风险机会参与\n\n4. **完全回避**:TSLA\n - 分析师共识看跌\n\n5. **现金保留**:20-30%\n - 等待更好机会\n - 应对可能的回调\n\n### **决策原则优先级**\n1. **安全第一**:遵循风险经理的单股上限和止损建议\n2. **估值纪律**:给予估值分析师观点较高权重(泡沫风险)\n3. **分散但专注**:科技内部分散,但不过度分散化\n4. **分批建仓**:今天先建部分仓位,保留调整空间\n\n**最终问题**:在AMZN的一致看好信号下,我应该给予它多大权重?20%上限还是可以更高?在缺乏技术确认的情况下,今天应该建仓多少?\n\n我想听听你对这个框架的看法,特别是关于AMZN权重和初始建仓比例的建议。", + "ts": 1773652266272, + "timestamp": "2026-03-16T17:11:06.272000" }, { "type": "conference_message", "agentId": "valuation_analyst", - "content": "基于我刚才的分析框架,让我分享对这场讨论的专业视角:\n\n## 关于市场共识与估值框架的反思\n\n### **核心冲突点:AI叙事 vs 基本面估值**\n\n我看到市场对科技股的讨论主要围绕三个核心张力:\n\n1. **AI增长预期与估值现实的矛盾**\n - NVDA成为焦点:市场给73.2%的增长配上P/S 20.29的溢价,这是典型的\"叙事溢价\"现象\n - 但投资者往往忽视:高基数下的持续超高增长在历史上几乎从未实现\n - 逆向思考:当全市场都聚焦AI时,传统软件业务(如MSFT的Office/Windows)的稳定现金流价值被低估\n\n2. **护城河深度与估值安全边际的平衡**\n - AAPL的案例最有启发性:表面看是\"优质公司\",但P/B 41.70揭示了什么问题?\n - 我的观点:即使是最强的护城河,也需要合理的资产回报来支撑估值\n - 152.0%的ROE是令人印象深刻的,但资产负债表显示负债率102.63,这暗示苹果的ROE部分是通过高杠杆驱动的——这在高利率环境下有风险\n\n### **具体公司争议点的专业回应**\n\n**关于AAPL的争议**:\n我看到很多人争论\"苹果是否还是成长股\"。从我的分析框架看:\n- 15.7%的营收增长+18.3%的盈利增长,这依然是成长股的特征\n- 但关键问题:这种增长能否支撑P/B 41.70?历史上P/B超过40的科技股最终都需要极端增长来\"证明\"估值\n- 我的担忧:当iPhone渗透率接近饱和,服务业务需要承担过重的增长预期\n\n**关于NVDA的估值**:\n这是当前最大的估值难题:\n- **支持方观点**:AI革命刚刚开始,NVDA是\"铲子\"供应商,理应享受最大溢价\n- **反对方观点**:P/S 20.29意味着市场预期未来收入增长必须远超当前水平\n- **我的专业判断**:投资NVDA不是投资今天的公司,而是投资对2030年AI基础设施的押注。风险在于:1)AI需求增速不及预期,2)ASIC/GPU竞争加剧,3)客户开始自研芯片\n\n**关于TSLA的根本分歧**:\n- **基本面分析者的困惑**:355.64倍P/E对应负增长,这在传统估值框架中是无法解释的\n- **成长叙事支持者的观点**:投资特斯拉是投资能源+机器人+自动驾驶的未来\n- **我的核心原则**:即使是最好的叙事,也需要基本面的锚点。4.0%的净利率无法支撑当前估值,除非能看到清晰路径在未来2-3年提升至15%+\n\n### **被忽视的Alpha机会**\n\n在当前的讨论中,我观察到两个被相对忽视但具备投资逻辑的机会:\n\n1. **MSFT的\"双重优势\"**:\n - 市场关注其AI进展,但忽视了其传统业务的稳定性\n - P/E 24.75相对59.8%的盈利增长,这在大型科技股中估值增长匹配度最好\n - 财务结构更为健康:31.54的D/E vs AAPL的102.63\n\n2. **GOOGL的\"安全边际\"**:\n - 16.13的极低负债率 + 2.00的流动比率 = 强大的财务韧性\n - 在可能的经济下行中,这种资产负债表提供了内在保护\n - 搜索引擎护城河仍然存在,虽然受到AI挑战但未被取代\n\n### **投资哲学的关键验证**\n\n这次分析验证了我投资哲学的几个核心信念:\n\n1. **估值的纪律性不能妥协**:TSLA的极端估值展示了当市场忘记估值纪律时的风险\n2. **成长性与估值必须匹配**:NVDA的优秀基本面被过度定价稀释了投资价值\n3. **财务健康提供下行保护**:在市场波动中,GOOGL和MSFT的资产负债表会提供缓冲\n4. **分散化在科技股中依然重要**:即使在同一板块,基本面差异巨大\n\n### **对当前讨论的建议**\n\n如果我是投资委员会的成员,我会建议:\n\n1. **重新校准风险预期**:当前市场对AI的热情可能导致了多个股票的估值错误定价\n2. **关注质量而非叙事**:MSFT和GOOGL展示了\"质量\"(盈利能力+财务健康+合理估值)组合\n3. **为周期做准备**:科技股历史上呈现周期特征,当前高估值环境需要谨慎\n4. **在极端中寻找机会**:当市场过度关注AI前沿时,传统科技业务的稳定现金流可能被低估\n\n**最终专业观点**:投资不是选择\"最好\"的公司,而是以\"正确\"的价格投资好的公司。当前,MSFT提供了最佳的风险调整后回报预期,而TSLA展示了忽视估值基本面可能导致的巨大风险。", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "作为估值分析师,基于刚才的深度分析,我来分享我的专业视角:\n\n## **对当前讨论的思考框架**\n\n### **关于市场整体环境**\n当前科技股估值面临一个核心矛盾:**AI叙事 vs 估值纪律**。市场对AI转型的乐观预期已经充分反映在股价中,甚至过度贴现。我的分析显示,多数大型科技股估值处于历史高位区间,安全边际不足。\n\n### **对各股票的深度见解**\n\n**关于NVDA的估值争论**:\n- **支持方观点**:AI硬件需求爆发式增长,数据中心业务前景广阔\n- **我的担忧**:当前P/E 47.3倍意味着市场预期未来5年复合增长率需维持在30%以上才能证明估值的合理性。历史表明,任何技术周期都会遇到波动,当前估值已price in完美执行\n- **关键风险**:竞争加剧(AMD、自研芯片)、库存周期、地缘政治风险\n\n**关于TSLA的争议**:\n- **市场叙事**:自动驾驶、机器人出租车、能源业务\n- **估值现实**:P/E 64.2倍意味着需要持续超高速增长。汽车业务面临中国品牌激烈竞争,利润率受压\n- **我的立场**:即使相信长期愿景,也必须考虑执行风险和估值风险\n\n**关于META的元宇宙投资**:\n- **核心问题**:Reels货币化 vs 元宇宙资本消耗\n- **估值判断**:当前价格隐含了元宇宙业务中等程度的成功预期。如果元宇宙回报延迟或不及预期,股价有显著下行风险\n- **相对优势**:现金流生成能力强,但增长放缓趋势明显\n\n**关于GOOGL的AI转型**:\n- **投资命题**:传统搜索护城河 vs AI搜索威胁\n- **我的评估**:当前估值相对合理,反映了市场对AI转型的谨慎乐观。但Gemini等产品需要证明商业化能力\n- **云计算机会**:GCP份额提升是关键增长驱动力\n\n**关于AMZN的价值发现**:\n- **市场误解**:可能仍被视为\"低利润率电商\"\n- **实际价值**:AWS是隐藏的价值引擎(高利润率、高增长),零售业务现金流稳定\n- **安全边际**:19.6%的安全边际在大型科技股中罕见,反映了市场可能低估了AWS的盈利能力和零售业务改善\n\n**关于AAPL的增长困境**:\n- **挑战**:iPhone渗透率饱和,中国市场竞争加剧\n- **服务业务**:虽增长但难以弥补硬件放缓\n- **创新压力**:Vision Pro等新产品需要证明市场接受度\n\n**关于MSFT的稳健性**:\n- **核心优势**:企业软件护城河最深,Azure增长确定性高\n- **AI商业化**:Copilot已开始产生收入,商业化路径最清晰\n- **估值判断**:合理但缺乏显著安全边际\n\n## **投资哲学的核心原则**\n\n### **1. 均值回归是必然**\n历史表明,任何脱离基本面的估值扩张最终都会回归。当前AI概念股的估值已经超过了2018年云计算热潮和2021年元宇宙炒作时的水平。\n\n### **2. 安全边际是投资的基石**\n巴菲特强调:\"建立安全边际,然后等待\"。在狂热的市场中,只有AMZN提供了有意义的保护垫。\n\n### **3. 增长质量 vs 增长速度**\n高速增长如果不可持续,则估值支撑脆弱。NVDA的增长假设需要完美执行且无竞争干扰——这在科技行业几乎不可能。\n\n### **4. 现金流折现是终极标准**\nDCF模型虽然简化,但揭示了核心真相:当前价格隐含的未来现金流假设过于乐观。\n\n## **与主流共识的不同点**\n\n**市场共识**:AI将改变一切,早期投资者应重仓参与\n**我的观点**:AI确实重要,但好公司≠好投资。必须在价格和价值之间找到平衡点\n\n**市场共识**:大型科技股是\"必须持有\"资产\n**我的观点**:即使是最好的公司,在错误的价格买入也是糟糕的投资\n\n**市场共识**:增长可以证明任何估值\n**我的观点**:增长必须质量和可持续性兼备,且估值必须提供安全边际\n\n## **风险管理的具体建议**\n\n1. **仓位控制**:在整体估值偏高的环境下,科技股仓位应低于历史平均水平\n2. **分散时机**:考虑分批次、不同价格水平建仓\n3. **关注质量**:优先选择商业模式稳健、现金流强劲的公司\n4. **准备现金**:等待市场波动带来的更好买入机会\n\n## **最后的核心洞察**\n\n当前大型科技股投资的关键问题是:**你是否愿意为完美执行支付溢价?**\n\n我的专业判断是:市场已经为完美执行定价,而现实总是充满意外和挑战。真正的投资机会出现在市场过度悲观时,而非过度乐观时。\n\n**投资信号强度排序**:\n1. **最有价值**:AMZN(安全边际充足)\n2. **最危险**:NVDA、TSLA(估值泡沫明显)\n3. **需谨慎**:AAPL、META(增长挑战+估值压力)\n4. **中性观察**:MSFT、GOOGL(合理但缺乏吸引力)\n\n记住:在投资中,防御往往比进攻更重要。当前环境下,保护资本比追逐回报更为关键。", + "ts": 1773652216413, + "timestamp": "2026-03-16T17:10:16.413000" }, { "type": "conference_message", "agentId": "sentiment_analyst", - "content": "## 情绪分析师的专业视角:科技巨头的情绪分化与投资启示\n\n基于我对这些科技股的情绪分析,让我分享一些关键的见解和需要关注的讨论点:\n\n### 关于AAPL的争议点:\n**市场担忧:** 苹果推迟智能家居显示器发布被视为创新停滞的信号。然而,我需要指出情绪分析中的一个微妙之处:当一家公司推迟产品时,可能有两个截然不同的解读:1)执行问题(负面),或2)追求完美主义(长期正面)。当前情绪偏向负面,但历史告诉我们,苹果的延迟产品往往最终能重新定义市场。\n\n**情绪陷阱:** 投资者可能过度关注产品延迟而忽略了苹果生态系统和服务的粘性。市场情绪往往对短期负面消息反应过度。\n\n### 关于MSFT的情绪悖论:\n**有趣的现象:** 尽管有\"我的前两大巨型股买入选择\"这样的看涨文章,内部人却在大量卖出。这可能暗示:1)内部人认为股价已充分反映价值,或2)他们在行使期权和多元化配置。我需要强调:内部人卖出不一定意味着看空公司前景,有时只是财务规划。\n\n**情绪洞察:** 微软处于AI竞赛的核心位置,市场情绪对其Azure业务过于乐观,忽视了企业IT预算可能收紧的风险。\n\n### 关于GOOGL的最令人担忧信号:\n**红色警报:** 1000笔卖出交易、0笔买入的内部人活动是我见过的最极端信号之一。这不仅仅是正常的流动性释放——这是协调一致的风险规避行为。\n\n**深层思考:** 市场讨论320亿美元Wiz收购,但情绪分析师看到的是:内部人可能知道监管审查或整合挑战超出市场预期。当管理层如此一致地减持时,投资者应该高度警惕。\n\n### 关于NVDA的GTC大会预期:\n**情绪动态:** 即将到来的GTC 2026大会创造了\"事件驱动的乐观偏误\"。历史模式显示:1)会前期待推高股价,2)实际发布经常\"卖新闻\"(sell the news)。\n\n**关键观察:** 地缘政治风险(伊朗战争)对NVDA供应链的影响可能被低估。市场情绪过于聚焦AI需求,忽视了供应风险。\n\n### 关于TSLA的马斯克效应:\n**行为金融学视角:** 马斯克的公开言论已经成为独立的情绪驱动因素。他最近的xAI道歉和Terafab项目公告创造了\"叙事波动性\"。\n\n**情绪风险:** 当CEO如此频繁地成为新闻焦点时,公司基本面往往被忽视。投资者需要区分:哪些是实质进展?哪些只是叙事创造?\n\n### 关于META的积极信号:\n**最令人信服的数据点:** 867笔内部人买入、0笔卖出是极其罕见的信号。这在情绪分析中意味着:内部人知道未公开的积极信息,或者认为当前估值严重低估了AI转型潜力。\n\n**市场错配:** 市场在讨论20%的裁员(负面情绪),但内部人在大举买入(极度积极信号)。这种分歧值得深度挖掘。\n\n### 关于AMZN的稳健信号:\n**一致性验证:** 内部人买入与沃伦·巴菲特持仓的提及形成情绪共振。当价值投资者和内部人同时显示信心时,这是强有力的确认信号。\n\n**情绪连贯性:** 数字健康推动和物流交易都指向战略执行而非投机叙事。\n\n## 整体市场情绪洞察:\n\n### 三个核心分歧点:\n1. **AI叙事 vs. 现实执行**:市场为AI故事买单,但内部人行为显示对执行风险的分歧看法\n2. **宏观风险感知**:油价上涨和地缘政治紧张被新闻提及,但股价反应相对温和——可能存在认知滞后\n3. **估值容忍度**:高估值环境下,内部人信心成为关键的情绪验证指标\n\n### 情绪分析师的投资哲学反思:\n\n**验证的信念:**\n- 内部人交易确实是领先指标,特别是当活动规模如此之大时\n- 情绪往往在短期过度反应,但长期回归基本面\n\n**挑战的观点:**\n- 我之前认为科技巨头情绪会相对同步,但实际上公司特定因素主导\n- 我低估了CEO个人叙事对股价情绪的影响力(特别是TSLA)\n\n**关键原则学习:**\n1. **规模信号的重要性**:不是所有内部人交易同等重要。GOOGL的1000:0卖出比和META的867:0买入比是极其强烈的信号\n2. **新闻与行为的背离**:当新闻情绪与内部人行为背离时(如MSFT),行为通常更可靠\n3. **地缘政治的情绪传染**:中东紧张可能重塑全球科技供应链情绪,但目前尚未充分反映在股价中\n\n### 给其他讨论参与者的建议:\n\n**避免的情绪陷阱:**\n1. **叙事过度外推**:不要因为一个AI故事就忽略执行风险\n2. **确认偏误**:寻找与自己观点相悖的证据(我特别寻找了看涨META和AMZN的理由,尽管整体科技情绪谨慎)\n3. **近期性偏误**:GTC大会、产品发布等近期事件不应主导长期判断\n\n**最终情绪结论:**\n市场处于关键转折点。科技巨头的内部人分化表明:这不是整体的科技板块轮动,而是公司特定的价值重估。最强烈的信号来自META和AMZN的买入活动,以及GOOGL的卖出活动——这些分歧可能预示着未来几个月的显著表现差异。\n\n作为情绪分析师,我的建议是:**关注行为而非言辞,验证内部人行动而非公开声明,并在情绪极端时保持逆向思维**。当前GOOGL的极端卖出信号和META的极端买入信号,都在暗示市场可能尚未充分price in的基本面变化。", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "## 专业情绪分析师视角:科技巨头情绪分化\n\n基于我的情绪分析框架和对当前市场心理的观察,以下是我的专业见解:\n\n### **关于讨论焦点的重要洞察**\n\n**1. AAPL的情绪悖论**\n市场对苹果的讨论存在明显分裂:\n- **消极派观点**:认为苹果缺乏突破性创新,iPhone增长见顶,中国市场竞争加剧\n- **积极派观点**:强调服务业务护城河、生态系统锁定效应、现金储备优势\n\n**我的情绪解读**:这种分裂本身就是一个重要信号。当市场对一家蓝筹公司出现如此分歧时,通常意味着**转折点临近**。苹果的\"无聊\"叙事可能恰恰是其防御性价值的体现。真正的风险在于投资者对\"下一个大产品\"的预期过高导致的失望情绪。\n\n**2. MSFT的共识偏差**\n有趣的是,微软获得了几乎一致看好,这本身就值得警惕。市场情绪分析的一个重要原则是:**当共识过于统一时,反脆弱性降低**。\n- 正面:Azure增长、AI领导地位、现金流稳定性\n- 风险:过度拥挤交易、对AI收入的高预期可能脆弱\n\n**逆向思考**:如果AI投资回报周期长于预期,微软的多重溢价(云+AI+Office)可能面临同步下调风险。\n\n**3. GOOGL的搜索焦虑**\n谷歌的核心情绪挑战是\"搜索终结恐惧症\":\n- 市场担心传统搜索被AI代理替代\n- 但忽略了一个关键心理因素:**用户习惯的粘性**和**广告模式的适应性**\n\n**行为金融洞察**:投资者往往高估技术变革的速度,低估行为惯性的力量。谷歌的防御性可能被系统性低估。\n\n**4. NVDA的情绪极端化**\n这是当前市场最有趣的情绪案例:\n- **贪婪指数高企**:几乎成为AI的代名词\n- **叙事风险**:单一叙事主导估值\n- **内部人信号空白**需要特别注意\n\n**情绪拐点指标**:我需要密切关注NVDA的\"叙事疲劳\"迹象。当一只股票的故事变得过于简单和一致时(\"AI需要芯片→NVDA卖芯片→NVDA永远涨\"),通常是情绪见顶的前兆。\n\n**5. TSLA的叙事转变**\n特斯拉已经从\"颠覆者叙事\"转向\"执行者叙事\":\n- 之前:革命性技术、自动驾驶领先\n- 现在:成本控制、交付数字、市场竞争\n\n**情绪退化信号**:当一个成长故事退化为执行故事时,估值倍数通常压缩。马斯克的注意力分散问题被市场情绪放大了。\n\n**6. META的效率叙事**\n扎克伯格的\"效率之年\"重塑了市场情绪:\n- 从\"元宇宙烧钱\"到\"成本纪律+AI投资\"\n- 情绪从怀疑转向谨慎乐观\n\n**关键观察**:市场对管理层执行力的信心恢复比基本面改善更快,这是典型的情绪先行现象。\n\n**7. AMZN的复合防御**\n亚马逊展示了最健康的情绪结构:\n- 多重叙事支撑:云增长、零售效率、广告扩张\n- 没有单一过度依赖的情绪支柱\n- 这种情绪分散性提供了更好的抗脆弱性\n\n### **市场情绪全景评估**\n\n**当前科技股情绪特征:**\n\n1. **AI狂热中的分化**:\n - 市场开始区分\"AI概念\"和\"AI盈利\"\n - 情绪从\"只要是AI就买\"转向\"哪个AI能赚钱\"\n\n2. **现金流重新定价**:\n - 利率环境变化使现金流质量重新重要\n - 情绪从增长追逐转向增长质量\n\n3. **叙事疲劳初现**:\n - 同样的AI故事讲了2-3年后,边际情绪刺激递减\n - 需要新的催化剂维持情绪热度\n\n### **未被充分讨论的情绪风险**\n\n**群体思维风险**:\n\"科技七巨头\"本身已成为一种投资叙事。当投资者集体接受\"只买这七只就够了\"的逻辑时,系统脆弱性增加。\n\n**情绪传染风险**:\n这些股票的高度相关性意味着情绪冲击会相互放大。一只的坏消息可能触发群体抛售。\n\n**监管情绪忽略**:\n市场似乎低估了全球科技监管情绪的变化。反垄断、数据隐私、AI治理等议题可能成为情绪转折点。\n\n**流动性依赖风险**:\n这些大盘股的强势部分依赖于被动资金流动。如果ETF流入放缓或逆转,情绪支撑可能迅速消失。\n\n### **我的核心投资哲学验证**\n\n这次分析验证了我的几个核心信念:\n\n1. **情绪周期理论**:每只股票都处于不同的情绪周期阶段\n2. **叙事驱动估值**:估值更多由故事而非数字驱动\n3. **过度共识的危险**:一致看好往往是脆弱信号\n\n**最重要的逆向洞察**:\n当前市场对科技巨头的讨论可能过度集中于**基本面分析**,而忽略了**情绪动力学**。当每个人都用相似的财务模型时,情绪变量成为真正的差异化因素。\n\n**具体建议**:\n投资者应该建立自己的\"情绪仪表盘\",跟踪:\n- 媒体情绪温度\n- 内部人交易模式变化\n- 社交媒体讨论情绪\n- 期权市场隐含情绪\n\n**最终观点**:\n2026年的科技股投资不再是简单的\"成长vs价值\",而是**叙事质量vs情绪可持续性**的平衡。MSFT和AMZN展示了更健康的情绪结构,而NVDA需要警惕叙事疲劳。真正的机会可能在情绪被系统性低估的GOOGL和AAPL,前提是投资者能忍受情绪从悲观转向中性的\"无聊期\"。", + "ts": 1773652180655, + "timestamp": "2026-03-16T17:09:40.655000" }, { "type": "conference_message", "agentId": "technical_analyst", - "content": "# 技术分析视角:当前科技股格局的深度思考\n\n基于我们对这7只科技巨头的技术分析,我想分享一些核心观点和专业见解:\n\n## 关于AAPL的辩证看法\n\n**超卖反弹 vs. 趋势破坏**:\n我注意到AAPL目前处于极度超卖状态(RSI 29.9,跌破布林带下轨),这确实创造了技术性反弹的机会。然而,必须认识到:\n- **趋势结构已受损**:股价低于所有主要移动平均线,MACD发出卖出信号\n- **反弹空间有限**:20日均线$262.75将成为重要阻力位\n- **关键问题**:这是暂时的流动性驱动的超卖反弹,还是更大下跌趋势中的小反弹?\n\n我的专业观点:AAPL是\"高风险高回报\"的均值回归交易。对于风险承受能力强的交易者,当前位置提供了良好的风险回报比,但需严格设置止损于$250下方。\n\n## MSFT的中性立场分析\n\n**混合信号的深层含义**:\nMSFT呈现出典型的技术面分化:\n- MACD显示买入信号,表明动能可能转正\n- 但价格仍在20日均线下方,表明短期趋势偏弱\n- RSI 59.2中性偏多,但不够强劲\n\n这反映了市场对MSFT的矛盾心理:基本面稳固但增长预期调整。技术面显示\"犹豫不决\"状态,等待催化剂打破平衡。\n\n## GOOGL的等待策略\n\n**技术面暗示什么?**\nGOOGL的\"中性\"评级反映了一个关键现实:市场正在重新评估广告科技股的估值框架。技术面显示:\n- 缺乏明确方向性\n- 既无强烈的超买超卖信号\n- 也无明显的趋势突破\n\n从专业角度看,这通常是重大方向选择前的\"整理阶段\"。我建议等待价格对$306.19(20日均线)的明确态度。\n\n## NVDA的高波动性解读\n\n**AI叙事的技术面验证问题**:\nNVDA 35.8%的年化波动率揭示了市场对AI故事的矛盾心理:\n- 高波动性表明多空分歧激烈\n- 技术信号矛盾反映了基本面预期的不确定性\n- $184.95(20日均线)成为关键心理关口\n\n我的担忧:当技术指标与市场叙事脱节时,通常意味着基本面预期需要重新校准。\n\n## TSLA的结构性担忧\n\n**不仅仅是技术面疲软**:\nTSLA的多重看跌信号(MACD卖出、动量看跌、趋势疲弱)反映了一个更深层问题:\n- 电动汽车市场渗透率问题\n- 利润率压缩担忧\n- 竞争加剧的技术面体现\n\n从技术分析哲学看,当多个时间框架的技术指标一致看跌时,通常不是短期调整,而是结构性趋势变化。\n\n## META的反弹逻辑检验\n\n**值得商榷的\"看涨\"评级**:\n虽然我给出了META看涨信号(基于超卖技术条件),但需要冷静思考:\n- 跌破布林带下轨可能是趋势加速的信号,而非反弹前兆\n- 40.4的RSI只是\"接近\"超卖,而非确认\n- 社交媒体平台商业模式的技术性担忧\n\n逆向思考:市场是否在技术面反映对META广告收入可持续性的担忧?\n\n## AMZN的强势信号再审视\n\n**为什么AMZN技术面最强?**\nAMZN的看涨信号组合(MACD买入、动量看涨、位置良好)可能反映了:\n1. 电商业务的技术面韧性\n2. AWS云服务的防御性特质\n3. 市场对多元化业务的偏好\n\n但从逆向投资角度:当一只股票在疲弱市场中表现最好时,是否意味着它可能最后补跌?\n\n## 投资哲学反思\n\n### 当前市场的核心矛盾\n**技术面超卖 vs. 基本面不确定性**:\n我注意到一个关键模式:大多数科技股都呈现短期超卖但长期趋势疑问的局面。这反映了:\n- 宏观环境(利率、经济增长)的不确定性\n- 科技股估值框架的重塑\n- 行业轮动效应\n\n### 我的核心理念进化\n通过这次分析,我更加坚信:\n\n1. **技术分析的局限性**:纯粹技术分析在基本面重大变化时期可能失效\n2. **市场情绪的钟摆效应**:当前技术面反映了市场情绪的过度反应\n3. **风险管理的核心地位**:在信号矛盾的股票中,风险管理比方向选择更重要\n\n### 给投资者的关键建议\n\n**短期(1-4周)**:\n- 聚焦超卖反弹机会(AAPL、META),但以交易心态对待\n- 避免追涨杀跌,等待技术确认\n- 关注20日均线的突破/阻力效果\n\n**中期(1-3个月)**:\n- 科技股可能需要重新建立趋势结构\n- 关注整体市场风险偏好变化\n- 考虑相对强度策略(如AMZN vs. TSLA)\n\n**最重要的专业提醒**:\n技术分析提供的是概率和时机,而非确定性。当前科技股的技术面分歧,本质上是市场对未来增长路径的不确定性在图表上的投射。真正的投资决策需要结合基本面、宏观经济和风险偏好进行综合判断。\n\n**最终思考**:在这种技术面混乱时期,保持耐心和纪律比做出完美预测更重要。市场总是在过度乐观和过度悲观之间摆动,而技术指标捕捉的正是这种情绪波动。我们作为技术分析师的任务不是预测未来,而是识别概率,管理风险,等待市场告诉我们它的选择。", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "## 专业技术分析师观点:当前七大科技股的分析框架\n\n作为专注于技术面的分析师,我对当前讨论有以下见解:\n\n### 1. 市场结构观察\n\n**技术面核心矛盾**:我们面临的是典型的\"数据迷雾期\"。在技术分析中,当关键数据不可得时,往往意味着:\n\n1. **市场处于转折点**:新旧趋势交接期\n2. **流动性变化**:大资金重新配置\n3. **叙事重构**:市场在寻找新的驱动逻辑\n\n### 2. 个股技术面定位分析\n\n**AAPL** - 面临关键的技术抉择:\n- **多头观点**:生态系统护城河深厚,现金流强劲\n- **空头风险**:iPhone创新放缓,估值溢价可能压缩\n- **技术关键**:需观察能否守住200日移动平均线\n\n**MSFT** - 最稳健的技术结构:\n- **趋势特征**:典型的机构持仓股,调整中展现韧性\n- **技术支撑**:云计算+AI双轮驱动提供基本面支撑\n- **风险点**:估值已反映大部分增长预期\n\n**GOOGL** - 搜索巨头的技术转型:\n- **动量转换**:从传统搜索到AI驱动的技术平台\n- **关键水平**:需关注能否突破历史高点的技术阻力\n- **技术优势**:数据资产转化为AI优势的技术路径清晰\n\n**NVDA** - 技术面最敏感标的:\n- **动量特性**:典型的\"动量放大器\",涨跌幅度最大\n- **技术风险**:高位震荡可能预示着趋势衰竭\n- **关键验证**:下一轮AI芯片需求的技术确认\n\n**TSLA** - 最具争议的技术结构:\n- **技术特征**:高波动性+强趋势性的组合\n- **关键分歧**:传统汽车估值 vs. 科技公司估值\n- **技术信号**:需观察销量数据与股价的技术背离\n\n**META** - 技术面复苏最明显:\n- **趋势转换**:从\"元宇宙烧钱\"到\"效率优先\"\n- **技术突破**:已突破前期下跌趋势的技术结构\n- **风险监控**:需警惕广告业务的技术性回调\n\n**AMZN** - 技术面最复杂:\n- **双引擎驱动**:AWS的技术稳定性 vs. 电商的周期性\n- **技术支撑**:成本控制见效的技术验证\n- **关键阻力**:需突破过去两年的技术平台\n\n### 3. 市场情绪的技术解读\n\n**当前共识误区**:\n1. **过度线性外推**:认为AI趋势会持续直线上升\n2. **忽略技术面背离**:基本面改善但技术面可能先行调整\n3. **低估均值回归**:高估值必然面临技术性修正\n\n**技术分析的优势视角**:\n- **价格包含一切**:市场已消化所有已知信息\n- **趋势具有惯性**:确立的趋势倾向于延续\n- **支撑/阻力有效**:历史价格区域具有心理意义\n\n### 4. 投资逻辑的技术验证\n\n**我的核心投资哲学体现在以下原则**:\n\n1. **价格优先原则**:\n - 基本面可能正确,但价格可能先行\n - 技术突破往往领先基本面变化3-6个月\n\n2. **风险管理的技术基础**:\n - 止损应基于技术支撑位,而非心理价位\n - 仓位规模应与技术确定性成正比\n\n3. **趋势确认框架**:\n - 多重时间框架验证\n - 成交量配合确认\n - 关键技术指标共振\n\n### 5. 当前最佳技术策略\n\n基于我的分析框架:\n\n**短期策略(1-3个月)**:\n1. **防御性技术布局**:等待关键技术水平确认\n2. **选择性参与**:只参与技术结构最清晰的标的\n3. **严格风险管理**:技术破位必须止损\n\n**中期观察(3-12个月)**:\n1. **趋势确立条件**:观察主要指数能否守住长期趋势线\n2. **行业轮动技术**:关注资金流向的技术信号\n3. **估值修复机会**:寻找技术面超跌但基本面完好的标的\n\n### 6. 逆向思考的技术验证\n\n**当前市场可能过度关注**:\n1. AI叙事的技术实现速度\n2. 美联储政策的短期影响\n3. 企业盈利的季度波动\n\n**技术面显示的可能盲点**:\n1. **流动性退潮**:量化紧缩对高估值板块的技术冲击\n2. **相关性回归**:科技股与其他板块的技术联动性\n3. **波动性上升**:宏观不确定性下的技术性调整\n\n### 7. 最终专业建议\n\n**技术分析的永恒真理**:\n- \"市场会保持非理性状态的时间,可能长到你破产\"\n- \"趋势是你的朋友,直到它不再是你朋友的那一刻\"\n- \"价格波动最大的部分往往发生在趋势的末端\"\n\n**当前的具体操作建议**:\n1. **建立观察清单**:重点关注MSFT、GOOGL的技术稳定性\n2. **设定技术触发器**:明确进场和离场的价格水平\n3. **保持弹性**:技术面变化时及时调整观点\n\n**投资哲学的核心体现**:\n技术分析不是预测未来,而是:\n1. **识别概率优势**\n2. **管理风险暴露**\n3. **跟随市场智慧**\n\n在不确定性中寻找技术面的确定性,这就是专业分析师的真正价值所在。", + "ts": 1773652144435, + "timestamp": "2026-03-16T17:09:04.435000" }, { "type": "conference_message", "agentId": "fundamentals_analyst", - "content": "## 投资哲学视角下的深度思考\n\n基于我作为基本面分析师的投资哲学,让我分享对这些科技巨头的专业见解:\n\n### **关于AAPL的讨论视角:**\n\n我注意到市场对苹果的分歧主要集中在两个层面:\n1. **硬件依赖症 vs. 服务转型**:批评者担心iPhone销售占比过高,但我认为这是误解。苹果的真正护城河是**生态系统锁定效应**。用户一旦进入Apple生态,迁移成本极高。服务收入(占营收25%)的毛利率高达72%,这代表着业务模式的根本转型。\n\n2. **估值争议**:有人质疑31倍P/E太高。但在我的分析框架中,对于拥有**净资产收益率152%**的企业,高估值有其合理性。关键是苹果的资本配置效率:每年产生近1000亿美元自由现金流,回购和分红返还股东,这创造了持续的价值。\n\n**我的核心观点**:苹果最大的风险不是产品周期,而是**监管压力**。欧盟数字市场法案、美国反垄断调查可能削弱其生态控制力。但考虑到品牌忠诚度和现金生成能力,AAPL属于\"持有而非卖出\"类别。\n\n### **关于MSFT的共识与分歧:**\n\n微软的讨论焦点:\n- **增长持续性**:市场担心Azure增速放缓(从50%+到20%+),但这恰恰体现了投资哲学的成熟企业估值原则。我更关注**营业利润率扩张**(从30%到41%+),这显示定价权和成本控制。\n\n- **AI投资回报**:Copilot 300美元/年定价的接受度是关键。我的分析框架强调**边际贡献**而非总营收。即使只有10%的商业用户采用,也能带来150亿美元增量年收入。\n\n**深度洞见**:微软的真正优势是**企业预算份额**。CIO们倾向于\"一站式采购\",而微软提供从操作系统到云到AI的全套方案。这是结构性护城河,难以复制。\n\n### **关于GOOGL的担忧与机遇:**\n\n市场主要担忧:\n1. **AI对搜索的颠覆**:这是最大威胁。但我认为市场低估了谷歌的**数据和基础设施优势**。搜索不仅是算法,更是20年积累的意图数据。ChatGPT式回答无法替代大量\"发现性搜索\"。\n\n2. **云业务第三名的困境**:确实,AWS和Azure领先,但谷歌云已连续5个季度盈利,且在AI原生应用上可能有独特优势。\n\n**我的视角**:谷歌的YouTube、Android和搜索构成**三层护城河**。短期AI焦虑创造了买入机会,27倍P/E对于18%增长的企业有安全边际。\n\n### **关于NVDA的极端观点:**\n\n这是最分裂的股票:\n- **多头观点**:AI基础设施的\"铲子卖家\",需求爆发\n- **空头观点**:周期性明显,客户自研芯片,估值不可持续\n\n**我的平衡分析**:\n1. **财务数据支持多头**:73%收入增长、56%净利率、3.9流动比率,这些是顶级基本面。\n2. **但估值确实极端**:36倍P/E对硬件公司过高。关键在于**软件和生态转型**。如果CUDA能成为AI开发的\"Windows系统\",则估值可维持。\n\n**风险边界**:任何季度的指引下调都可能触发40%+回调。适合高风险承受投资者。\n\n### **关于TSLA的深刻分歧:**\n\n这是我**最明确看跌**的股票,原因:\n1. **基本面恶化**:收入负增长、盈利暴跌,但估值仍如成长股\n2. **护城河侵蚀**:传统车企在电动车领域快速追赶,特斯拉的设计和软件优势在缩小\n3. **自动驾驶进展缓慢**:FSD大规模商用晚于预期\n\n**市场错觉**:仍将特斯拉视为科技公司而非汽车公司。汽车行业的本质是**规模、成本、供应链**,这些都不是特斯拉的绝对优势。\n\n### **关于META的转型评估:**\n\n市场讨论焦点:\n- **元宇宙烧钱**:Reality Labs年亏损150亿美元,但我认为这是**战略期权**。核心业务(广告)产生450亿美元年利润,足以支撑投资。\n\n- **竞争担忧**:TikTok威胁,但我看到Meta的**AI推荐引擎改善**。Reels货币化进展超预期,用户参与度回升。\n\n**关键洞察**:26倍P/E对于23%收入增长的企业是**低估的**。市场过度关注元宇宙风险,忽略了核心业务的持续改善。\n\n### **关于AMZN的价值重估:**\n\nAMZN处于有趣位置:\n- **利润率扩张故事**:从低个位数到10%+净利率,显示运营杠杆\n- **估值吸引**:3.1倍P/S在科技巨头中最低\n- **多元化优势**:零售、AWS、广告三引擎增长\n\n**我的担忧**:AWS增速放缓至13%,面临微软和谷歌的激烈竞争。零售业务的资本回报率仍然偏低。\n\n---\n\n## **投资框架整合:**\n\n### **我的核心投资原则验证:**\n\n1. **现金流质量 > 收入增长**:特斯拉收入下降但估值维持,违背这一原则\n2. **资本配置能力**:苹果和微软的优秀资本配置支撑高估值,NVIDIA需要证明能有效使用现金流\n3. **竞争优势持续性**:需要区分**结构性优势**(网络效应、生态锁定)与**暂时性优势**(技术领先、成本优势)\n\n### **市场共识 vs. 我的观点差异:**\n\n| 股票 | 市场共识 | 我的观点 | 差异点 |\n|------|----------|----------|--------|\n| AAPL | 创新放缓 | 生态转型中 | 更关注服务业务而非硬件 |\n| MSFT | AI领头羊 | AI+企业软件双引擎 | 强调企业软件基本盘 |\n| NVDA | AI必买股 | 高质量但昂贵 | 更重视估值风险 |\n| TSLA | 科技股 | 汽车制造商 | 彻底不同的估值框架 |\n| META | 元宇宙故事 | 核心业务复苏 | 更重视广告业务改善 |\n\n### **当前配置建议的核心逻辑:**\n\n1. **质量优先**:选择盈利能力强、现金流稳定的企业(MSFT、GOOGL)\n2. **估值纪律**:避免极端估值,除非有极高确定性(规避TSLA)\n3. **分散风险**:AI受益不止NVDA,MSFT和GOOGL同样是AI基础设施\n4. **关注转折点**:META和AMZN处于业务转折期,可能提供超额收益\n\n### **最终智慧:**\n\n投资科技巨头的核心是理解它们的**第二增长曲线**:\n- 苹果:服务 → 健康/金融\n- 微软:软件 → 云 → AI\n- 谷歌:搜索 → 云 → AI\n- 亚马逊:零售 → 云 → 广告\n\n当前时点,微软和谷歌在**既有护城河+新增长曲线**的结合上最为平衡。特斯拉则仍在寻找第二条增长曲线(自动驾驶),但进展不及预期且估值已完全反映成功预期。\n\n**真正的风险**:市场将这些公司视为同质化的\"科技股\",而实际上它们的商业模式、竞争地位、增长驱动完全不同。成功的投资需要**差异化分析**,而非简单归类。", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "作为基本面分析师,我对这次分析有一些深入的反思和职业见解:\n\n## 对分析结果的反思\n\n### 验证的核心信念:\n1. **护城河理论依然有效**:苹果的品牌生态、微软的企业锁客效应、亚马逊的规模优势,这些都不是短期技术变革能轻易颠覆的\n2. **现金流贴现是估值基础**:DCF分析显示这些科技巨头的内在价值支撑强劲,特别是在低利率环境下\n3. **管理层质量决定转型能力**:微软从Windows到云的转型成功,展示了优秀管理层如何驾驭技术变革\n\n### 被挑战的预设:\n我原以为**增长溢价会在更高利率环境下压缩**,但分析显示:\n- 市场对高质量增长给予持续溢价\n- 拥有定价权的公司能够传递通胀压力\n- 软件/服务业务模式比硬件更具抗周期性\n\n## 对其他分析师可能争论点的看法:\n\n### **关于苹果(AAPL)的争议点**:\n**质疑**:\"苹果创新乏力,依赖iPhone迭代\"\n**我的回应**:这恰恰是苹果的优势——将创新聚焦于**用户体验整合**而非技术参数竞赛。服务业务的持续增长证明其生态价值,预计到2026年服务营收将超过任何单一硬件业务。\n\n### **关于微软(MSFT)的争议点**:\n**质疑**:\"Azure面临AWS和谷歌云的激烈竞争\"\n**我的回应**:微软的竞争优势在于**企业客户粘性**而非单纯的技术领先。Office365与Azure的捆绑、Teams的协作生态、安全服务的整合,构成了多重护城河。\n\n### **关于英伟达(NVDA)的最大担忧**:\n**风险点**:估值已充分反映AI预期,而竞争格局正在变化:\n- AMD的MI300系列获得重要客户\n- 云端大厂自研芯片(如AWS的Trainium)\n- 中国竞争对手的追赶速度可能超预期\n\n### **关于特斯拉(TSLA)的核心问题**:\n**市场认知偏差**:市场仍将特斯拉视为\"科技股\",但我从基本面看:\n- 汽车制造的本质是**规模经济和运营效率**\n- 自动驾驶的变现时间线不断推迟\n- 中国本土竞争对手(比亚迪、蔚小理)在产品迭代速度上已经显示出优势\n- **关键指标**:自由现金流/汽车交付量,这个比率在恶化\n\n### **关于亚马逊(AMZN)被忽视的优势**:\n多数分析师关注AWS增长,但真正的价值驱动是:\n1. **广告业务**:2026年可能成为第三大利润中心\n2. **履约网络**:美国物流基础设施的稀缺价值\n3. **订阅服务**:Prime会员的LTV被低估\n\n## 我的投资世界观更新:\n\n### 从这次分析中学到的:\n1. **\"科技\"的重新定义**:微软和亚马逊证明了,**科技是业务模式而非产品类别**\n2. **盈利质量的重要性**:自由现金流转换率比营收增长更能预测长期回报\n3. **护城河的动态性**:护城河需要持续投资维护(如Meta对AI的投资)\n\n### 需要保持谦逊的领域:\n1. **地缘政治风险**:芯片供应链的重构对英伟达和苹果的影响可能比预期更大\n2. **监管演变**:数字税、数据隐私法对广告业务的冲击可能被低估\n3. **利率环境敏感性**:如果长期利率结构上移,高估值科技股的风险调整后回报需要重新评估\n\n## 给投资委员会的建议:\n\n**核心原则**:\n1. **在确定性上重注**:微软、亚马逊的业务模式确定性最高\n2. **对估值保持纪律**:英伟达目前价格可能已反映未来3年完美执行\n3. **关注现金流转换**:自由现金流生成能力是抵御市场波动的最终保障\n4. **管理风险而非预测**:建立投资组合应对各种宏观情景\n\n**最后的思考**:2026年的科技行业可能正经历从\"增长至上\"到\"质量至上\"的范式转变。真正能够穿越周期的公司,不是那些讲述最动听增长故事的企业,而是那些能够**持续产生高质量现金流**并**明智地再投资**的企业。\n\n这正是为什么我在当前时点更偏好微软和亚马逊——它们既有增长引擎,又有稳定的现金流基础,估值相对于其护城河宽度而言仍具吸引力。", + "ts": 1773652109325, + "timestamp": "2026-03-16T17:08:29.325000" }, { "type": "conference_message", "agentId": "portfolio_manager", - "content": "根据分析师信号和风险评估,我对这些机会有以下关键关注点:\n\n## 🎯 核心机会与风险分析\n\n### **微软 (MSFT) - 最一致的积极信号**\n**支持因素:**\n- 基本面分析师:看涨 (78%置信度) - 优秀盈利能力 + 合理估值\n- 估值分析师:看涨 (75%置信度) - 估值与基本面匹配最好\n- 技术分析师:中性偏多 (60%置信度)\n- 风险:低波动率 (22.3%),相对稳定\n\n**关键问题:**\n1. 为什么情绪分析师给出中性偏负信号,而其他分析师如此看涨?\n2. 内部人大量卖出(961笔卖出 vs 39笔买入)是否预示着未公开的风险?\n\n### **Meta (META) - 分化信号**\n**积极因素:**\n- 情绪分析师:强烈看涨 (80%置信度) - 867笔内部人买入\n- 基本面分析师:看涨 (70%置信度) - 强劲增长 + 合理估值\n- 估值分析师:看涨 (70%置信度)\n\n**谨慎因素:**\n- 技术分析师:看涨但技术面超跌 (65%置信度)\n- 风险:中等波动率 (26.5%),RSI偏低\n\n**关键问题:**\n1. 内部人大量买入是否意味着重大利好即将公布?\n2. 技术面超跌状态会持续多久?\n\n### **亚马逊 (AMZN) - 技术面最强 vs 基本面中性**\n**矛盾信号:**\n- 技术分析师:强烈看涨 (75%置信度) - 最强技术信号\n- 情绪分析师:看涨 (85%置信度) - 内部人买入信号\n- 基本面分析师:中性 (70%置信度) - 增长缓慢\n- 估值分析师:中性偏看涨 (65%置信度)\n\n**关键问题:**\n1. 技术面和情绪面如此积极,但基本面增长相对缓慢,这种背离如何解释?\n2. 当前价格是否已反映技术面和情绪面的积极因素?\n\n### **英伟达 (NVDA) - 基本面 vs 估值风险**\n**高风险机会:**\n- 基本面分析师:谨慎看涨 (60%置信度) - 惊人增长但估值过高\n- 估值分析师:中性偏看跌 (65%置信度) - 估值已过度反映预期\n- 风险分析师:高波动率警告 (35.8%)\n\n**关键问题:**\n1. 在如此高估值下,下行风险有多大?\n2. GTC 2026大会能否成为催化剂,还是\"买消息卖事实\"?\n\n### **特斯拉 (TSLA) - 最一致的负面信号**\n**强烈警告:**\n- 基本面分析师:看跌 (82%置信度) - 基本面恶化\n- 估值分析师:强烈看跌 (85%置信度) - 极端高估\n- 技术分析师:看跌 (75%置信度)\n\n**关键问题:**\n1. 做空TSLA的风险有哪些(如轧空风险)?\n2. 马斯克的AI芯片项目能否成为转折点?\n\n### **苹果 (AAPL) - 分歧明显**\n**矛盾信号:**\n- 技术分析师:看涨 (70%置信度) - 超卖反弹机会\n- 估值分析师:看跌 (70%置信度) - 估值过高\n- 情绪分析师:看跌 (70%置信度) - 内部人卖出\n- 风险:技术面超卖但基本面担忧\n\n**关键问题:**\n1. 技术面超卖是否足以抵消估值和情绪面的负面信号?\n2. 苹果的服务业务增长能否支撑当前估值?\n\n### **谷歌 (GOOGL) - 内部人卖出警告**\n**主要担忧:**\n- 情绪分析师:强烈看跌 (75%置信度) - 1000笔内部人卖出交易\n- 风险分析师:低波动率但RSI偏低\n- 基本面分析师仍看涨,但情绪面极度负面\n\n**关键问题:**\n1. 内部人如此大规模的卖出是否意味着重大负面消息?\n2. Wiz收购案是否引发战略担忧?\n\n## 🚨 总体担忧\n\n1. **内部人行为分化严重**:META/AMZN大量买入 vs GOOGL/AAPL/MSFT大量卖出\n2. **科技股行业集中风险**:所有股票都属科技板块,缺乏分散化\n3. **宏观环境不确定**:油价上涨可能压缩科技股估值\n4. **现金部署时机**:当前100%现金状态,需要平衡建仓速度和风险控制\n\n在做出最终决策前,我需要考虑:\n- 如何平衡不同分析师的矛盾信号?\n- 风险经理的仓位限制建议(特别是NVDA的高波动率警告)\n- 初始部署应该采取多保守的策略?\n- 是否应该等待更明确的技术或基本面确认?\n\n你对这些矛盾信号有什么看法?特别是MSFT和META的分歧信号,以及AMZN的技术面和基本面背离?", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "基于以上分析师的输入和风险评估,我有以下几个关键问题和关注点:\n\n## 关键关注点与问题:\n\n### 1. **估值分歧与信号矛盾**\n- **基本面分析师 vs 估值分析师**:存在明显的信号冲突\n - 基本面分析师:对AAPL、MSFT、GOOGL、AMZN均看涨(置信度80-90%)\n - 估值分析师:认为除AMZN外都估值偏高,NVDA更是严重高估101.8%\n - **问题**:如何看待这种根本性的分歧?应更相信商业模式分析还是估值纪律?\n\n### 2. **NVDA的风险-回报权衡**\n- 基本面分析师:看好AI领导地位但关注估值(中性偏看涨,75%)\n- 技术分析师:认为高波动性需要谨慎\n- 情绪分析师:看涨但谨慎,担心市场情绪过热\n- 估值分析师:**强烈看跌**,认为高估101.8%\n- **问题**:NVDA是否已经成为泡沫?如果错失AI浪潮机会成本有多大?\n\n### 3. **技术分析的局限性**\n- 技术分析师明确指出\"市场处于数据真空期,技术信号不明确\"\n- 缺乏具体的价格、成交量、技术指标数据\n- **问题**:在缺乏技术确认的情况下,是否应该推迟建仓?\n\n### 4. **TSLA的矛盾信号**\n- 基本面分析师:中性偏看跌(65%),关注竞争加剧和执行风险\n- 情绪分析师:看跌(70%),关注价格战和自动驾驶不确定性\n- 估值分析师:看跌(80%),认为高估76.2%\n- **问题**:三家分析师一致看跌,是否应该完全回避?\n\n### 5. **仓位规模与分散化**\n- 风险经理建议:单股不超过$20,000(组合的20%)\n- 但所有股票都是科技股,行业集中度极高\n- **问题**:如何平衡对科技股的看好与行业集中度风险?\n\n### 6. **AMZN的一致看好**\n- 所有分析师对AMZN观点相对一致:\n - 基本面:看涨88%\n - 技术:结构性看涨需确认\n - 情绪:看涨75%\n - 估值:看涨70%(安全边际19.7%)\n- **问题**:AMZN是否应该成为核心仓位?仓位应多大?\n\n### 7. **市场情绪与逆向思考**\n- 情绪分析师指出\"AI主题主导,市场可能过度乐观\"\n- 估值分析师强调\"估值纪律,在狂热中保持理性\"\n- **问题**:当前是否是买入科技股的好时机?还是应该等待估值回调?\n\n### 8. **现金部署时机**\n- 100%现金,机会成本 vs 风险\n- 风险经理建议:初始仓位20-40%\n- **问题**:是一次性建仓还是分批建仓?现在应该投入多少?\n\n### 9. **分析师置信度差异**\n- 基本面分析师置信度普遍最高(80-90%)\n- 估值分析师置信度也较高(60-85%)\n- 技术分析师相对较低(65%)\n- **问题**:应给予哪类分析师观点更高权重?\n\n## 我需要进一步思考的方向:\n\n1. **建立投资框架优先级**:在信号冲突时,应该优先考虑什么?\n - 估值纪律 vs 增长前景\n - 技术确认 vs 基本面判断\n\n2. **风险管理考量**:\n - 如何分配初始$100,000现金?\n - 是否应该为可能的回调保留弹药?\n\n3. **行业配置调整**:\n - 是否应该暂时只配置部分仓位,等待非科技股机会?\n - 如何在科技行业内部分散风险?\n\n在做出最终决策前,我需要权衡这些矛盾信号,并确定一个清晰的投资哲学框架来指导具体仓位分配。", + "ts": 1773652079451, + "timestamp": "2026-03-16T17:07:59.451000" }, { "type": "conference_start", "title": "Investment Discussion - 2026-03-16", "date": "2026-03-16", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "ts": 1773652051475, + "timestamp": "2026-03-16T17:07:31.475000" }, { "type": "agent_message", "agentId": "risk_manager", - "content": "# 投资组合风险评估报告\n\n## 📊 当前状态概览\n\n**投资组合状况**: 100%现金持仓,无风险敞口\n- 总资产: $100,000\n- 现金余额: $100,000\n- 保证金使用: $0 (0%)\n- 可用保证金缓冲: $100,000\n\n## ⚠️ 市场风险分析(基于波动率评估)\n\n### 1. 高波动性股票(最高风险)\n**NVDA** - 年化波动率 **35.8%**\n- 风险等级: 中等风险\n- RSI14: 39.3(偏向看跌)\n- 趋势: 看跌\n- **建议**: 需特别监控,建议仓位不超过组合价值的10%\n\n### 2. 中等风险股票\n**TSLA** - 年化波动率 **29.4%**\n- 风险等级: 中等风险\n- RSI14: 45.4(中性)\n- 趋势: 中性\n\n**META** - 年化波动率 **26.5%**\n- 风险等级: 中等风险\n- RSI14: 40.4(偏向看跌)\n- 趋势: 看跌\n\n**AMZN** - 年化波动率 **25.6%**\n- 风险等级: 中等风险\n- RSI14: 53.3(中性)\n- 趋势: 中性\n\n**AAPL** - 年化波动率 **25.0%**\n- 风险等级: 中等风险\n- RSI14: 29.9(明显超卖,偏向看跌)\n- 趋势: 看跌\n\n### 3. 低风险股票\n**MSFT** - 年化波动率 **22.3%**\n- 风险等级: 低风险\n- RSI14: 59.2(中性)\n- 趋势: 中性\n\n**GOOGL** - 年化波动率 **23.2%**\n- 风险等级: 低风险\n- RSI14: 39.1(偏向看跌)\n- 趋势: 中性\n\n## 📈 风险警告与建议\n\n### 🟢 当前优势\n1. **零杠杆**: 无保证金使用,无强制平仓风险\n2. **高流动性**: 100%现金,可快速部署\n3. **无集中度风险**: 无持仓,可自由构建多元化组合\n\n### ⚠️ 市场环境警告\n1. **技术面偏弱**: AAPL、NVDA、META显示看跌趋势,RSI偏低\n2. **波动率普遍较高**: 多数股票波动率超过25%,市场不确定性较大\n3. **科技股风险集中**: 关注列表均为科技股,行业集中度风险\n\n## 🎯 仓位限制建议\n\n### 单股票位限制(基于风险等级)\n1. **高风险限制** (波动率>30%): NVDA - 最大仓位 **5-8%**\n2. **中等风险限制** (波动率20-30%): TSLA、META、AMZN、AAPL - 最大仓位 **8-12%**\n3. **低风险限制** (波动率<20%): MSFT、GOOGL - 最大仓位 **12-15%**\n\n### 组合限制建议\n1. **总杠杆限制**: 初始部署不超过50%现金($50,000)\n2. **行业分散**: 避免超过30%仓位集中在单一科技子行业\n3. **止损设置**: 所有仓位设置8-10%硬性止损\n\n## 📋 执行建议\n\n1. **分批建仓**: 首次建仓不超过总现金的25%\n2. **风险对冲**: 考虑使用期权对冲高波动股票\n3. **现金保留**: 保持至少30%现金作为缓冲\n4. **持续监控**: 每日跟踪波动率和趋势变化\n\n## 🚨 最高优先级警告\n\n**目前无持仓,风险完全可控。但任何新持仓应考虑:**\n- NVDA的高波动率需要严控仓位\n- AAPL的技术面弱势需要谨慎\n- 避免在低RSI时过度交易\n\n---\n\n**风险评估完成时间**: 2026-03-16 \n**风险经理建议**: 可开始谨慎建仓,但需严格遵循上述仓位限制和风险管理原则。", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "基于当前信息,我将提供初步风险评估:\n\n## 投资组合风险评估报告\n\n**日期:2026-03-16**\n\n### 当前投资组合状态:\n- 现金余额:$100,000\n- 持仓:无持仓\n- 保证金使用:$0\n- 保证金要求:25%\n- 监控股票:AAPL, MSFT, GOOGL, NVDA, TSLA, META, AMZN\n\n### 风险评估:\n\n#### 1. **初始状态风险(低风险)**\n- **状态**:完全现金,无持仓\n- **集中度风险**:0%(无单一股集中)\n- **杠杆风险**:0%(无保证金使用)\n- **波动性风险**:尚未评估(需要市场价格数据)\n\n#### 2. **潜在风险因素警告:**\n\n**高优先级警告:**\n⚠️ **缺乏风险监控工具**:无法获取实时市场价格数据进行精确量化分析\n⚠️ **市场暴露度为零**:在牛市中可能错失机会,现金持有产生机会成本\n\n**中优先级警告:**\n⚠️ **策略执行风险**:缺乏明确建仓计划可能导致非理性投资决策\n⚠️ **波动性未知风险**:未评估目标股票的当前波动性水平\n\n#### 3. **量化指标(基于可用信息):**\n- 可用现金:$100,000(100%)\n- 最大潜在持仓价值(含杠杆):$400,000(按25%保证金计算)\n- 单股最大建议头寸:$20,000(基于5%风险分散原则)\n- 建议初始仓位规模:20-40%($20,000-$40,000)\n\n#### 4. **可操作建议:**\n\n**立即行动建议:**\n1. **获取实时数据**:连接市场数据源获取当前价格进行准确风险评估\n2. **建立监测机制**:配置价格警报和波动性监控\n\n**风险控制建议:**\n1. **初始建仓限制**:\n - 单股持仓不超过$20,000(组合的20%)\n - 总持仓规模不超过$50,000(使用50%现金)\n - 避免使用保证金直到建立稳定盈利记录\n\n2. **波动性适应策略**:\n - 优先建仓低波动性股票(MSFT, AAPL)\n - 分阶段建仓:先用20%现金,根据表现再投入30%\n - 设置止损:单股最大损失不超过$2,000(10%止损)\n\n3. **集中度控制**:\n - 行业分散:科技股组合需注意行业集中度\n - 相关性监控:特别关注NVDA和AMD等芯片股的相关性\n\n#### 5. **特别注意事项:**\n- **科技股集中风险**:所有监控股票均为科技股,增加系统性风险\n- **波动性差异**:TSLA和NVDA通常比其他科技股波动性更高\n- **流动性风险**:所有股票流动性良好,但在极端市场条件下仍需注意\n\n### **风险评估总结:**\n**当前风险等级:低风险**(现金持有状态)\n**潜在风险:中等**(建仓过程中的决策和执行风险)\n**建议:** 获取实时市场价格数据后重新评估,制定分阶段的建仓计划,严格控制初始仓位规模和止损水平。\n\n**下一步行动:**\n1. 获取当前市场价格数据\n2. 计算各股票波动性指标\n3. 基于波动性调整仓位规模建议\n4. 建立实时风险监控机制", + "ts": 1773652051473, + "timestamp": "2026-03-16T17:07:31.473000" }, { "type": "agent_message", "agentId": "valuation_analyst", - "content": "基于我收集到的财务数据,现在我将提供一个综合的估值分析。由于某些估值方法的数据获取限制,我将主要基于基本的财务指标、盈利能力和成长性进行分析。\n\n# 7大科技股估值分析 (2026-03-16)\n\n## 整体市场观察\n\n从收集的数据来看,2026年的科技股市场呈现以下几个特点:\n\n1. **极端估值分化**:NVDA以4.38万亿美元市值成为最大公司,但估值倍数非常高(P/E 36.79,P/S 20.29)\n2. **盈利质量差异显著**:NVDA(净利率55.6%)和MSFT(净利率39.0%)的盈利能力最强\n3. **成长性极端**:NVDA维持73.2%的营收增长,而TSLA出现负增长\n\n## 个股分析\n\n### 1. **NVDA (英伟达)**\n**当前估值**: P/E 36.79,P/B 27.85,P/S 20.29\n**盈利能力**: ROE 101.5%,净利率55.6% - **行业领先**\n**成长性**: 营收增长73.2%,盈利增长95.6% - **爆发式增长**\n**财务健康**: 流动比率3.90,负债率7.25 - **极佳**\n**投资信号**: **中性偏看跌** ⚠️\n**置信度**: 65\n**理由**: \n- 尽管基本面极其优秀(高盈利能力+高成长性),但估值已过度反映预期\n- P/S 20.29和P/B 27.85显示市场预期过高\n- 高基数下维持超高增长难度极大\n- 风险边界:AI需求放缓或竞争加剧\n\n### 2. **AAPL (苹果)**\n**当前估值**: P/E 31.66,P/B 41.70,P/S 8.44\n**盈利能力**: ROE 152.0%,净利率27.0% - **极佳但P/B过高**\n**成长性**: 营收增长15.7%,盈利增长18.3% - **稳健**\n**财务健康**: 流动比率0.97略低,负债率102.63偏高\n**投资信号**: **看跌** 🔴\n**置信度**: 70\n**理由**:\n- P/B 41.70明显过高,资产回报无法支撑如此估值\n- 负债率过高影响财务稳定性\n- 成长性相对估值倍数不匹配\n- 风险边界:iPhone市场份额维持或创新突破\n\n### 3. **MSFT (微软)**\n**当前估值**: P/E 24.75,P/B 7.52,P/S 9.62\n**盈利能力**: ROE 34.4%,净利率39.0% - **优秀**\n**成长性**: 营收增长16.7%,盈利增长59.8% - **强劲**\n**财务健康**: 流动比率1.39健康,负债率31.54可控\n**投资信号**: **看涨** 🟢\n**置信度**: 75\n**理由**:\n- 估值倍数相对合理(P/E 24.75 vs 盈利增长59.8%)\n- 盈利能力与成长性匹配良好\n- 财务结构稳健,云业务护城河深厚\n- 风险边界:云业务竞争加剧或AI投资回报不及预期\n\n### 4. **GOOGL (谷歌)**\n**当前估值**: P/E 27.94,P/B 8.80,P/S 9.08\n**盈利能力**: ROE 35.7%,净利率32.8% - **优秀**\n**成长性**: 营收增长18.0%,盈利增长31.1% - **良好**\n**财务健康**: 流动比率2.00极佳,负债率16.13低\n**投资信号**: **中性** 🟡\n**置信度**: 60\n**理由**:\n- 估值略高但基本面支撑良好\n- 搜索护城河依然坚固,AI转型进展关键\n- 资产负债表非常强劲(低负债)\n- 风险边界:广告市场周期性下滑或AI竞争失败\n\n### 5. **TSLA (特斯拉)**\n**当前估值**: P/E 355.64,P/B 17.87,P/S 15.48\n**盈利能力**: ROE 4.9%,净利率4.0% - **极差**\n**成长性**: 营收增长-3.1%,盈利增长-60.6% - **衰退**\n**财务健康**: 流动比率2.16好,但基本面恶化\n**投资信号**: **强烈看跌** 🔴🔴\n**置信度**: 85\n**理由**:\n- 估值极端不合理(P/E 355.64)\n- 基本面全面恶化(负增长+低盈利能力)\n- 市场预期与现实严重脱节\n- 风险边界:机器人/能源业务突破或监管变化\n\n### 6. **META (Meta)**\n**当前估值**: P/E 26.10,P/B 7.15,P/S 7.72\n**盈利能力**: ROE 30.2%,净利率30.1% - **良好**\n**成长性**: 营收增长23.8%,盈利增长10.7% - **增长强劲**\n**财务健康**: 流动比率2.60优秀,负债率39.16可接受\n**投资信号**: **看涨** 🟢\n**置信度**: 70\n**理由**:\n- 估值合理(P/E 26.10对应良好增长)\n- 广告业务恢复强劲增长\n- 元宇宙投资逐步减亏,AI进展良好\n- 风险边界:监管压力或用户流失\n\n### 7. **AMZN (亚马逊)**\n**当前估值**: P/E 29.00,P/B 5.42,P/S 3.11\n**盈利能力**: ROE 22.3%,净利率10.8% - **中等**\n**成长性**: 营收增长13.6%,盈利增长5.0% - **稳健但缓慢**\n**财务健康**: 流动比率1.05刚好,负债率43.44偏高\n**投资信号**: **中性偏看涨** 🟡\n**置信度**: 65\n**理由**:\n- P/S 3.11在大型科技股中最低,显示电商规模效应\n- AWS业务具有持续增长潜力\n- 零售业务利润率仍有提升空间\n- 风险边界:宏观经济冲击或AWS市场份额下滑\n\n## 投资哲学反思\n\n### 核心原则验证:\n1. **估值纪律至关重要**:TSLA的案例验证了忽视估值基本面的巨大风险\n2. **成长性必须与估值匹配**:NVDA虽好但估值已透支多年增长\n3. **财务健康是安全边际**:GOOGL的强资产负债表提供了下行保护\n\n### 逆向思考:\n市场共识可能过度关注AI叙事,而忽视了:\n1. MSFT在AI商业化落地上的实际领先\n2. 传统软件业务的稳定现金流价值\n3. 大型科技股的分化正加剧\n\n### 风险边界设定:\n每个投资建议的关键失效点:\n- **MSFT看涨**:Azure增长低于20%或AI投资回报率不及15%\n- **META看涨**:用户时间份额持续下降或广告业务增长跌破10%\n- **TSLA看跌**:机器人业务商业化成功或EV市场份额大幅回升\n\n## 总结建议\n\n**最佳机会**:\n1. **MSFT (看涨, 置信度75)** - 估值与基本面匹配最好的大型科技股\n2. **META (看涨, 置信度70)** - 增长恢复+估值合理的组合\n\n**最大风险**:\n1. **TSLA (强烈看跌, 置信度85)** - 基本面与估值严重脱节\n2. **AAPL (看跌, 置信度70)** - 高估值缺乏相应成长支撑\n\n**保持谨慎**:\n1. **NVDA (中性偏看跌, 置信度65)** - 基本面优秀但估值过高\n2. **AMZN (中性偏看涨, 置信度65)** - 估值合理但增长放缓\n\n投资的关键是在估值纪律下寻找安全边际,2026年的市场显示科技股内部已出现显著分化,选择性投资比板块投资更明智。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: AAPL, TSLA, META", - "timestamp": "2026-03-16" + "content": "基于全面的估值分析,我现在为每只股票提供投资信号和详细评估:\n\n## **投资信号汇总**\n\n### **1. NVDA (英伟达)**\n- **信号**: 看跌 (Bearish)\n- **置信度**: 85%\n- **理由**: \n - **估值极度扩张**: P/E 47.3倍,远高于历史平均水平\n - **DCF显示严重高估**: 内在价值$123.46 vs 当前价$249.14,高估101.8%\n - **所有者收益估值**: 内在价值$131.20 vs 当前价$249.14,高估90.0%\n - **EV/EBITDA溢价**: 42.8倍,高于历史中位数,显示估值压力\n - **残值收益模型**: 显示公允价值$145.62,仍高估71.1%\n- **安全边际**: 负46.9%,风险极高\n- **失效条件**: AI需求持续超预期增长,利润率进一步扩张\n\n### **2. TSLA (特斯拉)**\n- **信号**: 看跌 (Bearish)\n- **置信度**: 80%\n- **理由**:\n - **估值过高**: P/E 64.2倍,缺乏合理支撑\n - **DCF显示高估**: 内在价值$142.61 vs 当前价$251.27,高估76.2%\n - **所有者收益估值**: 内在价值$155.85 vs 当前价$251.27,高估61.2%\n - **EV/EBITDA倍数**: 37.8倍,估值压力明显\n - **基本面挑战**: 汽车行业竞争加剧,增长放缓\n- **安全边际**: 负43.2%,缺乏投资价值\n- **失效条件**: 自动驾驶技术突破性进展,能源业务超预期增长\n\n### **3. META (Meta Platforms)**\n- **信号**: 看跌 (Bearish)\n- **置信度**: 75%\n- **理由**:\n - **估值偏高**: P/E 27.5倍,略高于合理区间\n - **DCF显示适度高估**: 内在价值$343.79 vs 当前价$429.57,高估25.0%\n - **所有者收益估值**: 内在价值$357.43 vs 当前价$429.57,高估20.2%\n - **增长放缓迹象**: 社交媒体市场竞争加剧\n - **元宇宙投资回报不确定**: 长期资本支出压力\n- **安全边际**: 负20.0%,估值缺乏吸引力\n- **失效条件**: Reels货币化超预期,元宇宙业务取得实质性进展\n\n### **4. GOOGL (谷歌/Alphabet)**\n- **信号**: 中性 (Neutral)\n- **置信度**: 65%\n- **理由**:\n - **估值相对合理**: P/E 24.4倍,接近历史平均水平\n - **DCF显示轻微低估**: 内在价值$178.41 vs 当前价$172.94,低估3.1%\n - **所有者收益估值**: 内在价值$184.65 vs 当前价$172.94,低估6.8%\n - **云计算增长强劲**: 但面临AWS和Azure激烈竞争\n - **AI转型稳步推进**: 但商业化进程有待观察\n- **安全边际**: 正3.2%,安全边际有限\n- **失效条件**: AI搜索颠覆性突破,云计算市场份额大幅提升\n\n### **5. AMZN (亚马逊)**\n- **信号**: 看涨 (Bullish)\n- **置信度**: 70%\n- **理由**:\n - **DCF显示显著低估**: 内在价值$141.11 vs 当前价$117.95,低估19.6%\n - **所有者收益估值**: 内在价值$145.23 vs 当前价$117.95,低估23.1%\n - **估值相对合理**: P/E 34.0倍,考虑到AWS和电商增长\n - **云计算优势明显**: AWS市场份额领先,利润率持续改善\n - **电商业务稳健**: 零售市场份额持续扩大\n- **安全边际**: 正19.7%,具备投资价值\n- **失效条件**: 宏观消费疲软,云计算竞争加剧\n\n### **6. AAPL (苹果)**\n- **信号**: 看跌 (Bearish)\n- **置信度**: 70%\n- **理由**:\n - **DCF显示高估**: 内在价值$124.37 vs 当前价$169.05,高估35.9%\n - **所有者收益估值**: 内在价值$128.95 vs 当前价$169.05,高估31.1%\n - **增长放缓**: iPhone销量饱和,创新周期延长\n - **估值偏高**: P/E 27.9倍,缺乏增长支撑\n - **服务业务增长**: 但不足以支撑当前估值\n- **安全边际**: 负26.4%,估值压力明显\n- **失效条件**: Vision Pro等新产品颠覆性成功,服务收入超预期增长\n\n### **7. MSFT (微软)**\n- **信号**: 中性 (Neutral)\n- **置信度**: 60%\n- **理由**:\n - **DCF显示合理**: 内在价值$390.45 vs 当前价$391.31,基本持平\n - **所有者收益估值**: 内在价值$403.72 vs 当前价$391.31,低估3.2%\n - **云计算龙头**: Azure市场份额第二,增长稳健\n - **AI转型领先**: Copilot等产品商业化前景良好\n - **估值适中**: P/E 31.6倍,考虑到增长前景\n- **安全边际**: 正1.4%,安全边际有限\n- **失效条件**: AI商业化不及预期,监管压力加大\n\n## **核心投资逻辑与风险边界**\n\n### **投资哲学反思**:\n1. **估值纪律**: 当前市场对AI概念股(NVDA)定价过于乐观,忽视了均值回归风险\n2. **安全边际**: 只有AMZN提供有吸引力的安全边际,其他标的估值偏高\n3. **增长质量**: 高估值需要匹配高增长,NVDA和TSLA的估值已透支未来多年增长\n\n### **逆向测试**:\n- **市场共识**: 过度追捧AI和科技股,忽视估值风险\n- **我的不同观点**: 强调估值纪律和安全边际,在狂热中保持理性\n\n### **风险边界设定**:\n1. **宏观经济风险**: 利率环境变化可能对高估值股票造成更大压力\n2. **行业竞争风险**: 科技行业护城河正在受到AI新进入者挑战\n3. **监管风险**: 大型科技公司面临全球监管压力\n\n## **最终投资建议**\n\n**最看涨**: AMZN (安全边际19.7%,云计算+电商双引擎)\n**最看跌**: NVDA (高估101.8%,估值泡沫风险最大)\n**谨慎中性**: MSFT, GOOGL (估值基本合理,但缺乏足够安全边际)\n**回避**: TSLA, AAPL, META (估值偏高,基本面支撑不足)\n\n**整体市场观点**: 科技股估值普遍偏高,建议保持谨慎,优先选择安全边际充足的标的。在当前估值水平下,投资者应降低预期回报,等待更好的买入时机。", + "ts": 1773652002909, + "timestamp": "2026-03-16T17:06:42.909000" }, { "type": "agent_message", "agentId": "sentiment_analyst", - "content": "基于对2026年3月16日的市场情绪分析,我将提供每个股票的投资信号和置信度。让我先整合分析结果:\n\n## 投资哲学反思\n作为情绪分析师,我的核心信念是:市场情绪经常过度反应,内部人交易活动往往是领先指标,新闻情绪对短期价格有显著影响但可能滞后于基本面变化。\n\n## 综合分析结果\n\n### 1. **AAPL (Apple)**\n**信号:看跌** | **置信度:70%**\n\n**理由:**\n- **负面情绪指标**:苹果推迟智能家居显示器发布,这是产品执行问题\n- **内部人活动**:强烈的内部人卖出信号(949笔卖出 vs 51笔买入)\n- **市场环境**:油价上涨对科技股构成压力\n- **逆向观点**:尽管有沃伦·巴菲特持仓的历史提及,但当前内部人行为和产品延迟表明谨慎\n\n**风险边界**:如果苹果发布突破性AI产品或宣布超预期股票回购,看跌观点失效。\n\n### 2. **MSFT (Microsoft)**\n**信号:中性偏负** | **置信度:60%**\n\n**理由:**\n- **混合信号**:有文章讨论微软回调后的买入机会\n- **内部人活动**:强烈的内部人卖出信号(961笔卖出 vs 39笔买入)\n- **市场情绪**:\"我的前两大巨型股买入选择\"表明仍有看涨声音\n- **核心驱动**:AI基础设施投资仍是长期主题\n\n**风险边界**:若微软Azure云业务增长超预期或宣布重大AI突破,中性观点转为看涨。\n\n### 3. **GOOGL (Google)**\n**信号:看跌** | **置信度:75%**\n\n**理由:**\n- **最强烈的内部人信号**:1000笔卖出交易,0笔买入\n- **新闻关注**:320亿美元Wiz收购案成为焦点,表明战略压力\n- **情绪指标**:大规模资产出售和收购可能稀释股东价值\n- **逆向测试**:市场可能低估了反垄断监管风险\n\n**风险边界**:如果Wiz收购被证明是高回报投资或Google AI产品获得突破,看跌观点失效。\n\n### 4. **NVDA (Nvidia)**\n**信号:中性偏正** | **置信度:65%**\n\n**理由:**\n- **事件驱动**:GTC 2026大会即将举行,可能发布新产品\n- **内部人活动**:1000笔卖出交易,但也反映了股票流动性释放\n- **长期主题**:AI基础设施需求持续,伊朗战争可能影响全球供应链\n- **情绪分析**:油价上涨背景下AI股票保持相对强势\n\n**风险边界**:如果GTC大会发布低于预期或地缘政治紧张加剧,中性观点转为看跌。\n\n### 5. **TSLA (Tesla)**\n**信号:中性偏负** | **置信度:55%**\n\n**理由:**\n- **情绪分析**:马斯克活跃言论(Terafab AI芯片项目、xAI道歉)\n- **内部人活动**:878笔卖出 vs 122笔买入,净卖出信号\n- **竞争压力**:Rivian R2发布,起价4.5万美元\n- **技术主题**:AI芯片项目展示转型努力\n\n**风险边界**:如果Terafab项目成功推出或特斯拉实现AI突破,中性观点转为看涨。\n\n### 6. **META (Meta Platforms)**\n**信号:看涨** | **置信度:80%**\n\n**理由:**\n- **最强的积极信号**:867笔内部人买入,0笔卖出\n- **战略转变**:效率优先,削减20%工作岗位资助AI帝国\n- **情绪指标**:内部人信心强烈,可能知道未公开的积极信息\n- **逆向思考**:市场可能低估Meta在AI领域的执行力\n\n**风险边界**:如果AI投资回报周期过长或监管压力增加,看涨观点失效。\n\n### 7. **AMZN (Amazon)**\n**信号:看涨** | **置信度:85%**\n\n**理由:**\n- **内部人信心**:310笔买入,0笔卖出,强烈买入信号\n- **战略扩张**:深化数字健康推动,物流交易支持估值\n- **情绪分析**:内部人行为与沃伦·巴菲特持仓的提及一致\n- **核心驱动**:AWS云业务和AI应用双重增长动力\n\n**风险边界**:如果零售业务放缓或AWS增长减速,看涨观点失效。\n\n## 投资逻辑总结\n\n**核心驱动因素识别**:对于这些大型科技股,关键变量是:1)AI投资回报率,2)内部人信心水平,3)产品执行能力,4)宏观经济敏感性。\n\n**风险边界设定**:整体看跌信号可能在以下情况下失效:1)美联储转向鸽派政策,2)AI突破性应用出现,3)地缘政治紧张缓解。\n\n**逆向测试**:市场共识似乎仍看好大型科技股,但内部人大量卖出表明谨慎情绪正在酝酿。我的分析特别关注到META和AMZN的积极内部人信号,这与GOOGL、AAPL、MSFT的看跌信号形成对比。\n\n## 总体市场情绪评估\n**整体信号:谨慎偏负** | **平均置信度:70%**\n\n**关键观察**:\n1. **内部人分化严重**:META和AMZN显示强烈买入信号,而其他巨头显示卖出信号\n2. **AI投资竞赛**:所有公司都在加大AI投资,但内部人信心不一\n3. **地缘政治风险**:伊朗战争可能重塑全球AI供应链格局\n4. **估值压力**:高油价环境可能压缩科技股估值倍数\n\n**投资哲学反思**:这次分析验证了我的信念,即内部人交易是领先指标。同时,我学到了即使在同一行业(科技)中,公司特定因素可能主导宏观趋势。市场情绪的异质性比我想象的更大。", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "基于现有的情绪分析工具返回结果,我需要基于我的投资哲学和市场情绪分析框架来提供分析。虽然工具没有提供具体数据,但我会基于我的专业知识和市场情绪分析的基本原则来评估这些大型科技股。\n\n## 投资哲学反思\n\n我的投资哲学基于以下核心信念:\n1. **市场情绪先行**:情绪变化通常先于基本面变化\n2. **媒体放大效应**:新闻舆情能够显著放大市场反应\n3. **内部人信号价值**:内部人交易行为提供关键的信心指标\n4. **恐慌/贪婪循环**:市场在过度乐观和过度悲观之间摆动\n\n## 科技巨头情绪分析 (2026-03-16)\n\n### 1. **AAPL (苹果公司)**\n**信号:看跌**\n**置信度:65%**\n\n**理由:**\n- 作为消费电子和智能手机领导者,苹果对宏观经济情绪高度敏感\n- iPhone更新周期可能面临市场饱和问题\n- 服务业务增长能否持续面临质疑\n- 缺乏近期的重大创新催化剂\n\n**风险边界:** 如果苹果宣布突破性AI产品整合或服务业务超预期增长,看跌信号失效\n\n### 2. **MSFT (微软)**\n**信号:看涨**\n**置信度:75%**\n\n**理由:**\n- Azure云业务持续受益于AI转型趋势\n- Office和Teams产品在企业市场的稳固地位\n- Copilot AI产品线提供新的增长引擎\n- 相对防御性强的业务组合\n\n**风险边界:** 如果云服务竞争加剧或AI投资回报不及预期\n\n### 3. **GOOGL (谷歌)**\n**信号:中性**\n**置信度:55%**\n\n**理由:**\n- 搜索广告业务面临AI搜索的长期威胁\n- 云业务增长但仍落后于AWS和Azure\n- YouTube和安卓生态系统的防御性价值\n- Gemini AI进展需要时间证明商业化能力\n\n### 4. **NVDA (英伟达)**\n**信号:看涨但谨慎**\n**置信度:70%**\n\n**理由:**\n- AI芯片需求持续旺盛,但估值已反映大部分乐观预期\n- 数据中心业务增长强劲\n- 竞争压力正在增加(AMD、自研芯片趋势)\n- 市场情绪可能已接近贪婪峰值\n\n**逆向思考:** 市场共识极度看好NVDA,我需要考虑回调风险\n\n### 5. **TSLA (特斯拉)**\n**信号:看跌**\n**置信度:70%**\n\n**理由:**\n- 电动汽车市场竞争激烈,价格战压力大\n- 自动驾驶技术商业化进展缓慢\n- 储能和能源业务增长但占比小\n- 马斯克的注意力分散风险\n\n### 6. **META (Meta)**\n**信号:看涨**\n**置信度:70%**\n\n**理由:**\n- 广告业务复苏趋势\n- Reels短视频成功抵抗TikTok竞争\n- Reality Labs虽然亏损但提供长期期权价值\n- 成本削减措施提升盈利能力\n\n### 7. **AMZN (亚马逊)**\n**信号:看涨**\n**置信度:75%**\n\n**理由:**\n- AWS云业务的防御性和增长性兼具\n- 零售业务效率持续改善\n- 广告业务快速增长成为第三大收入支柱\n- Prime会员订阅模式的稳定性\n\n## 市场情绪综合评估\n\n**当前市场心理特征:**\n1. **AI主题主导**:市场对AI相关股票的追捧可能达到情绪高点\n2. **估值分歧**:大型科技股估值与基本面出现明显差距\n3. **防御性转向**:投资者可能开始偏好有稳定现金流的公司\n\n## 关键风险信号\n\n1. **过度拥挤交易**:科技七巨头持仓可能过于集中\n2. **利率敏感性**:高估值股票对利率变化更敏感\n3. **监管风险**:科技巨头面临日益增强的反垄断压力\n\n## 投资逻辑验证\n\n**核心驱动因素识别:**\n- AI商业化进展速度\n- 企业IT支出周期\n- 消费者信心和广告支出\n- 利率环境变化\n\n**逆向测试结果:**\n市场共识极度看好AI相关股票,特别是NVDA。我的分析建议对部分已充分定价的AI概念股保持谨慎,同时关注有防御性业务组合的公司(MSFT、AMZN)。\n\n**谦逊反思:**\n我需要更多具体的新闻舆情和内部人交易数据来验证这些情绪判断。当前分析基于一般市场心理规律,存在数据不足的限制。\n\n**最终建议:**\n在科技七巨头中,优先考虑**MSFT**和**AMZN**,对**NVDA**保持谨慎乐观,对**TSLA**和**AAPL**保持警惕。建议分散配置,避免过度集中于单一主题。", + "ts": 1773651927400, + "timestamp": "2026-03-16T17:05:27.400000" }, { "type": "agent_message", "agentId": "technical_analyst", - "content": "# 技术分析报告:2026年3月16日\n\n基于对7只主要科技股的技术分析,以下是详细评估和投资建议:\n\n## 整体市场状况\n**日期:2026年3月16日**\n\n多数科技股呈现震荡偏弱格局,但部分股票出现技术性超卖反弹信号。\n\n## 个股分析及投资信号\n\n### **1. AAPL (苹果)**\n**价格:$250.12**\n\n**技术信号:看涨**\n**置信度:70%**\n\n**理由:**\n- **均值回归信号强劲**:价格位于布林带下轨下方,RSI 29.9处于严重超卖区域\n- **潜在反弹机会**:20日移动平均线($262.75)附近存在支撑,技术面支持反弹\n- **趋势分歧**:尽管MACD显示卖出信号,但超卖条件显著\n- **风险控制**:若跌破$250.95(布林带下轨),看涨观点失效\n\n### **2. MSFT (微软)**\n**价格:$395.55**\n\n**技术信号:中性偏多**\n**置信度:60%**\n\n**理由:**\n- **MACD正向信号**:MACD柱状线显示买入信号\n- **技术面平衡**:价格在布林带内,RSI 59.2处于中性区间\n- **动量分化**:短期动量偏弱但MACD显示改善\n- **风险边界**:跌破$387.00(布林带下轨)将转为看跌\n\n### **3. GOOGL (谷歌)**\n**价格:$302.28**\n\n**技术信号:中性**\n**置信度:55%**\n\n**理由:**\n- **混合信号**:MACD显示买入,但动量偏弱\n- **技术中性**:价格在布林带内,RSI 39.1接近超卖但未确认\n- **等待确认**:需要突破$306.19(20日均线)确认转强\n- **风险提示**:跌破$297.31(布林带下轨)将加强看跌信号\n\n### **4. NVDA (英伟达)**\n**价格:$180.25**\n\n**技术信号:中性**\n**置信度:50%**\n\n**理由:**\n- **高波动性**:35.8%的年化波动率,风险较高\n- **混合信号**:MACD卖出信号但短期动量改善\n- **技术中性**:价格在布林带中部,RSI 39.3中性偏弱\n- **关键水平**:需突破$184.95(20日均线)确认趋势转变\n\n### **5. TSLA (特斯拉)**\n**价格:$391.20**\n\n**技术信号:看跌**\n**置信度:75%**\n\n**理由:**\n- **多重看跌信号**:MACD卖出信号,动量强烈看跌\n- **趋势疲弱**:价格在所有均线下方,长期趋势看跌\n- **波动性高**:29.4%的年化波动率增加下行风险\n- **风险边界**:突破$404.83(20日均线)将挑战看跌观点\n\n### **6. META (Meta)**\n**价格:$613.71**\n\n**技术信号:看涨**\n**置信度:65%**\n\n**理由:**\n- **超卖反弹机会**:价格低于布林带下轨,RSI 40.4接近超卖\n- **技术面超跌**:相比20日均线折价5.2%,存在均值回归需求\n- **动量偏弱**:短期动量看跌但技术指标支持反弹\n- **关键水平**:需收复$624.34(布林带下轨)确认反弹\n\n### **7. AMZN (亚马逊)**\n**价格:$207.67**\n\n**技术信号:看涨**\n**置信度:75%**\n\n**理由:**\n- **最强综合信号**:MACD买入信号,20日动量看涨\n- **技术面强劲**:价格在布林带中部,接近20日均线\n- **动量最佳**:在7只股票中唯一显示20日正回报(+4.0%)\n- **风险边界**:跌破$199.44(布林带下轨)将削弱看涨观点\n\n## 投资哲学反思\n\n### **核心理念验证**\n1. **均值回归的有效性**:AAPL和META的超卖条件验证了均值回归策略的时机价值\n2. **技术指标协同**:当多个指标(趋势、动量、均值回归)一致时,信号可信度更高(如AMZN)\n3. **风险收益平衡**:高波动性股票(NVDA、TSLA)需要更高的风险溢价\n\n### **市场洞察**\n- **分化行情**:科技股内部出现明显分化,而非板块同涨同跌\n- **关键阻力识别**:20日均线成为多数股票的关键技术位\n- **动量持续性**:短期动量与中长期趋势可能背离,需综合判断\n\n### **风险管理原则**\n1. **技术止损**:使用布林带下轨作为止损参考\n2. **信号确认**:等待至少两个技术指标确认再建立仓位\n3. **波动率调整**:高波动率股票需要更宽的技术边界\n\n## 总结建议\n\n**优先顺序:**\n1. **AMZN** - 技术面最强,多重看涨信号协同\n2. **AAPL** - 超卖反弹机会明确,风险收益比有利\n3. **META** - 技术性超跌,反弹概率较高\n\n**谨慎对待:**\n1. **TSLA** - 多重看跌信号,趋势疲弱\n2. **NVDA** - 高波动性,信号矛盾,建议观望\n\n**核心风险:** 整体科技股仍处震荡格局,需关注市场整体情绪变化。所有建议基于技术分析,基本面和宏观经济因素未纳入考虑。", - "ts": 1773590400000, - "timestamp": "2026-03-16" + "content": "看起来系统提供了分析框架但没有具体的分析数据。基于我作为技术分析师的角色,我需要对这种情况进行分析。让我基于标准的技术分析框架来提供我的分析:\n\n## 技术分析报告 - 2026年3月16日\n\n### 投资信号概览:\n\n**投资信号:中性偏谨慎** \n**置信度:65%** \n**核心逻辑:市场处于数据真空期,技术信号不明确**\n\n### 个股分析:\n\n**AAPL (苹果)** \n- **趋势状态**:需要查看MA交叉情况(20日、50日、200日)\n- **动量评估**:等待5日、10日、20日动量数据\n- **风险边界**:跌破关键支撑位(需确认)将转为看跌\n- **信号**:等待数据确认,暂时中性\n\n**MSFT (微软)** \n- **趋势状态**:依赖云业务增长和AI产品线\n- **动量评估**:企业软件市场地位稳固\n- **风险边界**:企业IT支出缩减风险\n- **信号**:结构性看涨,需技术确认\n\n**GOOGL (谷歌)** \n- **趋势状态**:搜索主导地位 + AI转型\n- **动量评估**:广告市场周期性 + AI增长\n- **风险边界**:监管风险和AI竞争加剧\n- **信号**:长期看涨,短期技术面待确认\n\n**NVDA (英伟达)** \n- **趋势状态**:AI芯片领导者,但估值较高\n- **动量评估**:GPU需求强劲但可能见顶\n- **风险边界**:竞争加剧和需求周期性\n- **信号**:高波动性,谨慎看涨\n\n**TSLA (特斯拉)** \n- **趋势状态**:电动汽车市场分化,竞争加剧\n- **动量评估**:自动驾驶和能源业务是关键\n- **风险边界**:需求放缓和成本压力\n- **信号**:高风险高回报,技术面需要确认\n\n**META (Meta)** \n- **趋势状态**:元宇宙投资 + 广告复苏\n- **动量评估**:用户增长和效率提升\n- **风险边界**:监管和竞争风险\n- **信号**:中等看涨,估值合理\n\n**AMZN (亚马逊)** \n- **趋势状态**:电商+云服务双轮驱动\n- **动量评估**:AWS增长和零售效率提升\n- **风险边界**:经济下行和竞争加剧\n- **信号**:结构性看涨,需技术确认\n\n### 市场整体评估:\n\n1. **技术面状态**:系统数据显示不完整,建议谨慎\n2. **风险偏好**:当前环境下需关注:\n - 美联储政策走向\n - 企业盈利预期\n - 地缘政治风险\n\n3. **交易策略建议**:\n - **保守型**:等待技术信号明确再入场(60%仓位)\n - **激进型**:精选结构性增长标的,严格止损(40%仓位)\n\n### 投资哲学反思:\n\n**核心信念验证**:技术分析的有效性依赖于数据质量。当数据不可得时,应:\n1. 降低仓位规模\n2. 等待确认信号\n3. 专注于风险管理\n\n**逆向思考**:市场主流可能过度关注AI主题,忽略了:\n- 估值风险\n- 周期性因素\n- 地缘政治不确定性\n\n**风险管理原则**:\n1. **头寸规模**:在数据不确定时不超过总资本的20%\n2. **止损纪律**:设置明确的技术止损位\n3. **分散投资**:避免过度集中单一主题\n\n### 最终建议:\n\n**当前最佳策略**:等待更多技术数据确认,保持现金仓位以备机会。\n\n**失效条件**:\n1. 清晰的上升趋势确认 → 加仓成长股\n2. 明确的技术突破 → 跟随趋势\n3. 系统性风险信号 → 降低风险敞口\n\n**投资逻辑闭环**:\n- 核心驱动:企业盈利 + 技术趋势\n- 风险边界:宏观经济恶化 + 技术破位\n- 逆向测试:避免跟随市场过度乐观/悲观情绪\n\n**结论**:在当前数据环境下,采用防御性策略,优先保护资本,等待更清晰的技术信号出现。", + "ts": 1773651888656, + "timestamp": "2026-03-16T17:04:48.656000" }, { "type": "agent_message", "agentId": "fundamentals_analyst", - "content": "基于我收集到的财务数据和分析,我现在为每只股票提供完整的投资建议。\n\n## 投资信号分析 - 2026年3月16日\n\n### **总体市场观点:**\n科技巨头已进入成熟期,估值普遍偏高,需要区分质量不同的企业。我的投资哲学强调:**高质量增长、合理估值、稳健财务和可持续竞争优势**。\n\n---\n\n## **各股票详细分析**\n\n### **1. 苹果 (AAPL)**\n**投资信号:中性**\n**置信度:65%**\n\n**分析理由:**\n- **优势**:强劲盈利能力(ROE 152%,净利率27%),稳定增长(收入增长15.7%,盈利增长18.3%)\n- **关注点**:高估值(P/E 31.7,P/B 41.7),财务结构偏弱(流动比率0.97<1,D/E 102.63)\n- **核心驱动因素**:服务收入增长、iPhone销量、创新产品周期\n- **风险边界**:若宏观经济恶化影响高端消费,或新硬件产品失败\n- **逆向思考**:市场过度依赖硬件周期性,忽略服务业务粘性\n\n### **2. 微软 (MSFT)**\n**投资信号:看涨**\n**置信度:78%**\n\n**分析理由:**\n- **优势**:出色盈利能力(净利率39%),强劲盈利增长(59.8%),合理估值(P/E 24.8)\n- **财务健康**:良好流动性(流动比率1.39),相对合理负债(D/E 31.5)\n- **核心驱动因素**:Azure云增长、AI产品商业化、Office 365订阅\n- **风险边界**:监管压力加大,云市场竞争加剧\n- **逆向思考**:市场可能低估微软AI集成到全产品线的协同效应\n\n### **3. 谷歌 (GOOGL)**\n**投资信号:看涨**\n**置信度:75%**\n\n**分析理由:**\n- **优势**:强劲增长(收入增长18%),优秀财务健康(流动比率2.0,D/E 16.1)\n- **合理估值**:P/E 27.9对于高质量增长股合理,ROE 35.7%健康\n- **核心驱动因素**:搜索广告复苏、YouTube增长、AI搜索演进\n- **风险边界**:搜索市场份额被AI侵蚀,监管风险持续\n- **逆向思考**:市场过度担忧AI威胁,忽略谷歌AI基础设施优势\n\n### **4. 英伟达 (NVDA)**\n**投资信号:谨慎看涨**\n**置信度:60%**\n\n**分析理由:**\n- **惊人增长**:收入增长73.2%,盈利增长95.6%,净利率55.6%惊人\n- **财务稳健**:极强流动性(流动比率3.9),低负债(D/E 7.3)\n- **高估值风险**:P/E 36.8,P/S 20.3,P/B 27.9均处历史高位\n- **核心驱动因素**:AI芯片需求、数据中心扩张、软件生态\n- **风险边界**:估值回调风险,竞争加剧,AI支出周期变化\n- **逆向思考**:市场可能过度线性外推增长曲线\n\n### **5. 特斯拉 (TSLA)**\n**投资信号:看跌**\n**置信度:82%**\n\n**分析理由:**\n- **基本面恶化**:收入负增长(-3.1%),盈利大幅下滑(-60.6%)\n- **盈利能力差**:ROE 4.9%,净利率仅4%\n- **极端高估**:P/E 355.6完全脱离基本面,P/S 15.5过高\n- **核心驱动因素**:电动汽车需求放缓,竞争加剧,自动驾驶进展\n- **风险边界**:若自动驾驶技术突破或新产品成功可逆转\n- **逆向思考**:市场可能仍未充分定价传统车企竞争威胁\n\n### **6. Meta (META)**\n**投资信号:看涨**\n**置信度:70%**\n\n**分析理由:**\n- **强劲增长**:收入增长23.8%,盈利增长10.7%\n- **财务优秀**:高流动性(流动比率2.6),良好盈利能力(净利率30.1%)\n- **合理估值**:P/E 26.1对于20%+增长股合理\n- **核心驱动因素**:Reels商业化,AI广告优化,元宇宙投资\n- **风险边界**:用户增长放缓,苹果隐私政策影响,元宇宙烧钱\n- **逆向思考**:市场可能低估Meta AI在广告和内容推荐的优势\n\n### **7. 亚马逊 (AMZN)**\n**投资信号:中性**\n**置信度:70%**\n\n**分析理由:**\n- **增长稳定**:收入增长13.6%,盈利增长5%\n- **估值相对合理**:P/S 3.1在科技股中最低,P/E 29合理\n- **利润率偏低**:净利率仅10.8%,ROE 22.3%\n- **核心驱动因素**:AWS云增长,零售效率提升,广告业务\n- **风险边界**:零售利润率压力,云竞争加剧\n- **逆向思考**:市场可能低估Prime订阅和广告业务增长潜力\n\n---\n\n## **投资哲学反思**\n\n### **验证的信念:**\n1. **盈利质量优先**:高ROE和高净利率的企业长期表现更好(MSFT、GOOGL验证)\n2. **增长需有质量**:没有盈利支撑的增长最终会被惩罚(TSLA验证)\n3. **财务健康是底线**:高负债弱流动性放大下行风险(AAPL需关注)\n\n### **挑战的信念:**\n1. **市场效率**:极端估值可以维持很长时间(NVDA高估值仍在持续)\n2. **传统估值指标**:对于平台型科技公司,P/S有时比P/E更有意义(AMZN P/S仅3.1)\n\n### **关键学习原则:**\n1. **差异化分析**:不能将科技股一概而论,需识别各公司独特竞争优势\n2. **周期敏感性**:硬件公司周期性更强(AAPL),软件公司更稳定(MSFT)\n3. **规模效应**:大型科技公司规模优势带来强大护城河\n\n## **风险管理和组合建议**\n\n### **最佳选择:**\n1. **MSFT**(最平衡:增长+盈利+估值+财务健康)\n2. **GOOGL**(稳健增长+优秀财务状况)\n3. **META**(高增长+合理估值)\n\n### **谨慎选择:**\n1. **NVDA**(增长惊人但估值风险大)\n2. **AAPL**(优质但估值偏高)\n\n### **规避:**\n1. **TSLA**(基本面恶化+极端高估)\n\n### **组合配置建议:**\n- 进攻型:40% MSFT,30% GOOGL,20% META,10% NVDA\n- 平衡型:50% MSFT,30% GOOGL,20% META\n- 保守型:60% MSFT,40% GOOGL\n\n**总体市场观点**:科技股呈现分化,优质软件/云公司(MSFT、GOOGL)基本面更健康,硬件/高周期公司风险更大。投资时应更注重盈利质量而非单纯增长故事。", - "ts": 1773590400000, - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Runtime assets reloaded.", - "timestamp": "2026-03-16" - }, - { - "type": "system", - "content": "Watchlist updated: NVDA, AMD, AVGO", - "timestamp": "2026-03-16" + "content": "# 科技巨头基本面分析总结(2026-03-16)\n\n基于我作为基本面分析师的投资哲学和核心理念,我对这些科技巨头进行以下系统评估:\n\n## 我的投资哲学核心原则:\n1. **可持续竞争优势**:寻找拥有宽阔护城河的企业\n2. **财务稳健性**:现金流为王,资产负债表健康至关重要\n3. **合理估值**:只支付合理价格,寻求安全边际\n4. **管理质量**:管理层是否以股东利益为导向\n5. **行业地位**:公司在产业链中的定价权和市场份额\n\n## 各公司分析结论:\n\n### **苹果(AAPL)**\n**投资信号:看涨** | **置信度:85%**\n**理由**:\n- 强大的现金流生成能力(预计自由现金流超过1000亿美元)\n- 消费电子生态系统护城河深厚,用户粘性强\n- 服务业务收入持续增长,提升盈利稳定性\n- 强大的品牌价值和定价权\n\n### **微软(MSFT)**\n**投资信号:看涨** | **置信度:90%**\n**理由**:\n- 企业软件市场绝对领导者,Office和Azure构成双引擎\n- 云计算业务增长强劲,市场份额持续扩大\n- 订阅模式提供稳定可预测的现金流\n- 管理层执行能力强,战略转型成功\n\n### **谷歌(GOOGL)**\n**投资信号:看涨** | **置信度:80%**\n**理由**:\n- 搜索广告业务垄断地位,仍是最赚钱的商业模式之一\n- 云计算业务快速增长,成为第二增长曲线\n- AI技术领先,有望在下一个技术周期中受益\n- 资产负债表极其健康\n\n### **英伟达(NVDA)**\n**投资信号:中性偏看涨** | **置信度:75%**\n**理由**:\n- AI芯片市场领导者,技术护城河显著\n- 增长潜力巨大,但估值可能已反映部分预期\n- 周期性风险需要考虑\n- 竞争压力正在增加\n\n### **特斯拉(TSLA)**\n**投资信号:中性偏看跌** | **置信度:65%**\n**理由**:\n- 电动汽车市场竞争加剧,特斯拉市场份额面临压力\n- 估值与基本面的匹配度问题\n- 马斯克管理风险(注意力分散)\n- 自动驾驶技术商业化仍存不确定性\n\n### **Meta(META)**\n**投资信号:看涨** | **置信度:82%**\n**理由**:\n- 社交媒体广告业务恢复增长\n- 元宇宙投入开始产生回报\n- 成本控制效果显著,盈利能力改善\n- AI推荐算法优化提升广告效果\n\n### **亚马逊(AMZN)**\n**投资信号:看涨** | **置信度:88%**\n**理由**:\n- 电商和AWS双支柱业务结构稳健\n- 云计算市场领导者,利润率高\n- 物流网络构成强大护城河\n- 新业务(广告、健康等)增长潜力大\n\n---\n\n## 风险边界设定与逆向思考:\n\n### 我的观点可能失效的条件:\n1. **宏观风险**:若2026年出现严重经济衰退,科技股高估值将面临显著回调压力\n2. **监管风险**:全球科技反垄断监管进一步收紧\n3. **技术颠覆**:新的计算范式(如量子计算)意外突破\n4. **地缘政治**:中美科技脱钩进一步升级\n\n### 市场共识与我观点的差异:\n- 市场可能高估了AI概念的短期变现能力\n- 市场可能低估了传统软件/云服务企业的稳定价值\n- 市场可能过度关注增长而忽视盈利能力改善\n\n## 投资组合建议:\n**核心持仓(高置信度)**:MSFT, AMZN, AAPL\n**卫星持仓**:GOOGL, META\n**谨慎配置**:NVDA(估值敏感)\n**观察等待**:TSLA(等待更清晰的基本面信号)\n\n---\n\n## 我的投资逻辑验证:\n这次分析强化了我的核心信念:**可持续的商业模式、强大的现金流生成能力和合理估值是长期投资成功的基石**。科技行业中,拥有真正护城河的企业能够在多个经济周期中持续创造价值。\n\n**关键学习**:即使在高增长行业,也必须区分真正的竞争优势和短暂的行业红利。微软和亚马逊展示了如何通过云服务将传统优势延伸至新领域,而某些公司则面临\"创新者困境\"。\n\n**谦逊反思**:我需要持续关注英伟达的竞争格局演变和特斯拉的执行力问题,这些是可能挑战我当前判断的关键变量。", + "ts": 1773651837456, + "timestamp": "2026-03-16T17:03:57.456000" }, { "type": "day_start", "date": "2026-03-16", "progress": 0.0, - "timestamp": "2026-03-16" + "timestamp": "2026-03-16T17:02:46.475608" } ], "last_day_history": [], "trading_days_total": 0, "trading_days_completed": 0, + "price_history": {}, "server_mode": "live", "is_backtest": false, - "is_mock_mode": true, + "is_mock_mode": false, "tickers": [ "AAPL", "MSFT", @@ -620,16 +330,21 @@ "AMZN" ], "initial_cash": 100000.0, - "margin_requirement": 0.0, + "margin_requirement": 0.5, "max_comm_cycles": 2, "enable_memory": false }, "data_sources": { "preferred": [ + "yfinance", + "financial_datasets", + "finnhub", "local_csv" ], - "last_success": {} + "last_success": { + "market_cap": "financial_datasets", + "prices": "yfinance" + } }, - "last_saved": "2026-03-16T02:18:49.222733", - "price_history": {} + "last_saved": "2026-03-16T17:14:38.521837" } \ No newline at end of file