Add explain analysis workflow and UI
This commit is contained in:
420
backend/cli.py
420
backend/cli.py
@@ -12,7 +12,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
@@ -21,18 +21,27 @@ import typer
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.prompt import Confirm
|
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.prompt_loader import PromptLoader
|
||||||
from backend.agents.workspace_manager import WorkspaceManager
|
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(
|
app = typer.Typer(
|
||||||
name="evotraders",
|
name="evotraders",
|
||||||
help="EvoTraders: A self-evolving multi-agent trading system",
|
help="EvoTraders: A self-evolving multi-agent trading system",
|
||||||
add_completion=False,
|
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()
|
console = Console()
|
||||||
_prompt_loader = PromptLoader()
|
_prompt_loader = PromptLoader()
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
def get_project_root() -> Path:
|
def get_project_root() -> Path:
|
||||||
@@ -204,6 +213,189 @@ def initialize_workspace(config_name: str) -> Path:
|
|||||||
return workspace_manager.get_run_dir(config_name)
|
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")
|
@app.command("init-workspace")
|
||||||
def init_workspace(
|
def init_workspace(
|
||||||
config_name: str = typer.Option(
|
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()
|
@app.command()
|
||||||
def backtest(
|
def backtest(
|
||||||
start: Optional[str] = typer.Option(
|
start: Optional[str] = typer.Option(
|
||||||
@@ -331,6 +730,16 @@ def backtest(
|
|||||||
|
|
||||||
# Run data updater
|
# Run data updater
|
||||||
run_data_updater(project_root)
|
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
|
# Build command using backend.main
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -514,6 +923,15 @@ def live(
|
|||||||
# Data update (if not mock mode)
|
# Data update (if not mock mode)
|
||||||
if not mock:
|
if not mock:
|
||||||
run_data_updater(project_root)
|
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:
|
else:
|
||||||
console.print(
|
console.print(
|
||||||
"\n[dim]Mock mode enabled - skipping data update[/dim]\n",
|
"\n[dim]Mock mode enabled - skipping data update[/dim]\n",
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ class StateSync:
|
|||||||
"""Set current simulation date for backtest-compatible timestamps"""
|
"""Set current simulation date for backtest-compatible timestamps"""
|
||||||
self._simulation_date = date
|
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:
|
def _get_timestamp_ms(self) -> int:
|
||||||
"""
|
"""
|
||||||
Get timestamp in milliseconds.
|
Get timestamp in milliseconds.
|
||||||
@@ -97,9 +101,21 @@ class StateSync:
|
|||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
return
|
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 "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}"
|
event["timestamp"] = f"{self._simulation_date}"
|
||||||
else:
|
else:
|
||||||
event["timestamp"] = datetime.now().isoformat()
|
event["timestamp"] = datetime.now().isoformat()
|
||||||
@@ -238,9 +254,12 @@ class StateSync:
|
|||||||
"""Called at start of trading cycle"""
|
"""Called at start of trading cycle"""
|
||||||
self._state["current_date"] = date
|
self._state["current_date"] = date
|
||||||
self._state["status"] = "running"
|
self._state["status"] = "running"
|
||||||
self.set_simulation_date(
|
if self._state.get("server_mode") == "backtest":
|
||||||
date,
|
self.set_simulation_date(
|
||||||
) # Set for backtest-compatible timestamps
|
date,
|
||||||
|
) # Set for backtest-compatible timestamps
|
||||||
|
else:
|
||||||
|
self.clear_simulation_date()
|
||||||
|
|
||||||
await self.emit(
|
await self.emit(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
|||||||
from typing import Callable, Dict, List, Optional
|
from typing import Callable, Dict, List, Optional
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
from backend.data.market_store import MarketStore
|
||||||
from backend.data.provider_utils import normalize_symbol
|
from backend.data.provider_utils import normalize_symbol
|
||||||
from backend.data.provider_router import get_provider_router
|
from backend.data.provider_router import get_provider_router
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ class HistoricalPriceManager:
|
|||||||
self.close_prices = {}
|
self.close_prices = {}
|
||||||
self.running = False
|
self.running = False
|
||||||
self._router = get_provider_router()
|
self._router = get_provider_router()
|
||||||
|
self._market_store = MarketStore()
|
||||||
|
|
||||||
def subscribe(
|
def subscribe(
|
||||||
self,
|
self,
|
||||||
@@ -58,21 +60,48 @@ class HistoricalPriceManager:
|
|||||||
logger.warning(f"Failed to load CSV for {symbol}: {e}")
|
logger.warning(f"Failed to load CSV for {symbol}: {e}")
|
||||||
return None
|
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):
|
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}")
|
logger.info(f"Preloading data: {start_date} to {end_date}")
|
||||||
|
|
||||||
for symbol in self.subscribed_symbols:
|
for symbol in self.subscribed_symbols:
|
||||||
if symbol in self._price_cache:
|
if symbol in self._price_cache:
|
||||||
continue
|
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)
|
df = self._load_from_csv(symbol)
|
||||||
if df is not None and not df.empty:
|
if df is not None and not df.empty:
|
||||||
self._price_cache[symbol] = df
|
self._price_cache[symbol] = df
|
||||||
logger.info(f"Loaded {symbol} from CSV: {len(df)} records")
|
logger.info(f"Loaded {symbol} from CSV: {len(df)} records")
|
||||||
else:
|
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):
|
def set_date(self, date: str):
|
||||||
"""Set current trading date and update prices"""
|
"""Set current trading date and update prices"""
|
||||||
|
|||||||
149
backend/data/market_ingest.py
Normal file
149
backend/data/market_ingest.py
Normal file
@@ -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
|
||||||
1074
backend/data/market_store.py
Normal file
1074
backend/data/market_store.py
Normal file
File diff suppressed because it is too large
Load Diff
64
backend/data/news_alignment.py
Normal file
64
backend/data/news_alignment.py
Normal file
@@ -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)
|
||||||
161
backend/data/polygon_client.py
Normal file
161
backend/data/polygon_client.py
Normal file
@@ -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
|
||||||
2
backend/enrich/__init__.py
Normal file
2
backend/enrich/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""News enrichment utilities for explain-oriented market research."""
|
||||||
|
|
||||||
296
backend/enrich/llm_enricher.py
Normal file
296
backend/enrich/llm_enricher.py
Normal file
@@ -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"],
|
||||||
|
}
|
||||||
362
backend/enrich/news_enricher.py
Normal file
362
backend/enrich/news_enricher.py
Normal file
@@ -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
|
||||||
2
backend/explain/__init__.py
Normal file
2
backend/explain/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Explain-oriented services for stock narratives and news research."""
|
||||||
69
backend/explain/category_engine.py
Normal file
69
backend/explain/category_engine.py
Normal file
@@ -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
|
||||||
214
backend/explain/range_explainer.py
Normal file
214
backend/explain/range_explainer.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
202
backend/explain/similarity_service.py
Normal file
202
backend/explain/similarity_service.py
Normal file
@@ -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
|
||||||
127
backend/explain/story_service.py
Normal file
127
backend/explain/story_service.py
Normal file
@@ -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",
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ from backend.config.bootstrap_config import (
|
|||||||
update_bootstrap_values_for_run,
|
update_bootstrap_values_for_run,
|
||||||
)
|
)
|
||||||
from backend.data.provider_utils import normalize_symbol
|
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.msg_adapter import FrontendAdapter
|
||||||
from backend.utils.terminal_dashboard import get_dashboard
|
from backend.utils.terminal_dashboard import get_dashboard
|
||||||
from backend.core.pipeline import TradingPipeline
|
from backend.core.pipeline import TradingPipeline
|
||||||
@@ -25,6 +31,7 @@ from backend.services.market import MarketService
|
|||||||
from backend.services.storage import StorageService
|
from backend.services.storage import StorageService
|
||||||
from backend.data.provider_router import get_provider_router
|
from backend.data.provider_router import get_provider_router
|
||||||
from backend.tools.data_tools import get_prices
|
from backend.tools.data_tools import get_prices
|
||||||
|
from backend.tools.data_tools import get_company_news
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -65,6 +72,7 @@ class Gateway:
|
|||||||
self._backtest_end_date: Optional[str] = None
|
self._backtest_end_date: Optional[str] = None
|
||||||
self._dashboard = get_dashboard()
|
self._dashboard = get_dashboard()
|
||||||
self._market_status_task: Optional[asyncio.Task] = None
|
self._market_status_task: Optional[asyncio.Task] = None
|
||||||
|
self._watchlist_ingest_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
# Session tracking for live returns
|
# Session tracking for live returns
|
||||||
self._session_start_portfolio_value: Optional[float] = None
|
self._session_start_portfolio_value: Optional[float] = None
|
||||||
@@ -182,6 +190,17 @@ class Gateway:
|
|||||||
def state(self) -> Dict[str, Any]:
|
def state(self) -> Dict[str, Any]:
|
||||||
return self.state_sync.state
|
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):
|
async def handle_client(self, websocket: ServerConnection):
|
||||||
"""Handle WebSocket client connection"""
|
"""Handle WebSocket client connection"""
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
@@ -250,6 +269,22 @@ class Gateway:
|
|||||||
await self._handle_get_stock_history(websocket, data)
|
await self._handle_get_stock_history(websocket, data)
|
||||||
elif msg_type == "get_stock_explain_events":
|
elif msg_type == "get_stock_explain_events":
|
||||||
await self._handle_get_stock_explain_events(websocket, data)
|
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:
|
except websockets.ConnectionClosed:
|
||||||
pass
|
pass
|
||||||
@@ -298,20 +333,38 @@ class Gateway:
|
|||||||
)
|
)
|
||||||
|
|
||||||
prices = await asyncio.to_thread(
|
prices = await asyncio.to_thread(
|
||||||
get_prices,
|
self.storage.market_store.get_ohlc,
|
||||||
ticker,
|
ticker,
|
||||||
start_date,
|
start_date,
|
||||||
end_date,
|
end_date,
|
||||||
)
|
)
|
||||||
usage_snapshot = self._provider_router.get_usage_snapshot()
|
source = "polygon"
|
||||||
source = usage_snapshot.get("last_success", {}).get("prices")
|
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(
|
await websocket.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"type": "stock_history_loaded",
|
"type": "stock_history_loaded",
|
||||||
"ticker": ticker,
|
"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,
|
"source": source,
|
||||||
"start_date": start_date,
|
"start_date": start_date,
|
||||||
"end_date": end_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]):
|
async def _handle_start_backtest(self, data: Dict[str, Any]):
|
||||||
if not self.is_backtest:
|
if not self.is_backtest:
|
||||||
return
|
return
|
||||||
@@ -410,6 +1093,7 @@ class Gateway:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
await self._handle_reload_runtime_assets()
|
await self._handle_reload_runtime_assets()
|
||||||
|
self._schedule_watchlist_market_store_refresh(tickers)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_watchlist(raw_tickers: Any) -> List[str]:
|
def _normalize_watchlist(raw_tickers: Any) -> List[str]:
|
||||||
@@ -538,6 +1222,48 @@ class Gateway:
|
|||||||
trades=trades,
|
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]):
|
async def broadcast(self, message: Dict[str, Any]):
|
||||||
"""Broadcast message to all connected clients"""
|
"""Broadcast message to all connected clients"""
|
||||||
if not self.connected_clients:
|
if not self.connected_clients:
|
||||||
@@ -896,4 +1622,6 @@ class Gateway:
|
|||||||
self._backtest_task.cancel()
|
self._backtest_task.cancel()
|
||||||
if self._market_status_task:
|
if self._market_status_task:
|
||||||
self._market_status_task.cancel()
|
self._market_status_task.cancel()
|
||||||
|
if self._watchlist_ingest_task:
|
||||||
|
self._watchlist_ingest_task.cancel()
|
||||||
self._dashboard.stop()
|
self._dashboard.stop()
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ class MarketService:
|
|||||||
self._session_start_values: Optional[Dict[str, float]] = None
|
self._session_start_values: Optional[Dict[str, float]] = None
|
||||||
self._session_start_timestamp: Optional[int] = 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
|
@property
|
||||||
def mode_name(self) -> str:
|
def mode_name(self) -> str:
|
||||||
if self.backtest_mode:
|
if self.backtest_mode:
|
||||||
@@ -532,6 +544,7 @@ class MarketService:
|
|||||||
"status": MarketStatus.OPEN,
|
"status": MarketStatus.OPEN,
|
||||||
"status_text": "Backtest Mode",
|
"status_text": "Backtest Mode",
|
||||||
"is_trading_day": True,
|
"is_trading_day": True,
|
||||||
|
"live_quote_provider": self.get_live_quote_provider(),
|
||||||
}
|
}
|
||||||
|
|
||||||
now = self._now_nyse()
|
now = self._now_nyse()
|
||||||
@@ -544,6 +557,7 @@ class MarketService:
|
|||||||
"status": MarketStatus.CLOSED,
|
"status": MarketStatus.CLOSED,
|
||||||
"status_text": "Market Closed (Non-trading Day)",
|
"status_text": "Market Closed (Non-trading Day)",
|
||||||
"is_trading_day": False,
|
"is_trading_day": False,
|
||||||
|
"live_quote_provider": self.get_live_quote_provider(),
|
||||||
}
|
}
|
||||||
|
|
||||||
market_open, market_close = self._get_market_hours(today)
|
market_open, market_close = self._get_market_hours(today)
|
||||||
@@ -553,6 +567,7 @@ class MarketService:
|
|||||||
"status": MarketStatus.CLOSED,
|
"status": MarketStatus.CLOSED,
|
||||||
"status_text": "Market Closed",
|
"status_text": "Market Closed",
|
||||||
"is_trading_day": is_trading,
|
"is_trading_day": is_trading,
|
||||||
|
"live_quote_provider": self.get_live_quote_provider(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Determine status based on current time
|
# Determine status based on current time
|
||||||
@@ -563,6 +578,7 @@ class MarketService:
|
|||||||
"is_trading_day": True,
|
"is_trading_day": True,
|
||||||
"market_open": market_open.isoformat(),
|
"market_open": market_open.isoformat(),
|
||||||
"market_close": market_close.isoformat(),
|
"market_close": market_close.isoformat(),
|
||||||
|
"live_quote_provider": self.get_live_quote_provider(),
|
||||||
}
|
}
|
||||||
elif now > market_close:
|
elif now > market_close:
|
||||||
return {
|
return {
|
||||||
@@ -571,6 +587,7 @@ class MarketService:
|
|||||||
"is_trading_day": True,
|
"is_trading_day": True,
|
||||||
"market_open": market_open.isoformat(),
|
"market_open": market_open.isoformat(),
|
||||||
"market_close": market_close.isoformat(),
|
"market_close": market_close.isoformat(),
|
||||||
|
"live_quote_provider": self.get_live_quote_provider(),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
@@ -579,6 +596,7 @@ class MarketService:
|
|||||||
"is_trading_day": True,
|
"is_trading_day": True,
|
||||||
"market_open": market_open.isoformat(),
|
"market_open": market_open.isoformat(),
|
||||||
"market_close": market_close.isoformat(),
|
"market_close": market_close.isoformat(),
|
||||||
|
"live_quote_provider": self.get_live_quote_provider(),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def check_and_broadcast_market_status(self):
|
async def check_and_broadcast_market_status(self):
|
||||||
|
|||||||
280
backend/services/research_db.py
Normal file
280
backend/services/research_db.py
Normal file
@@ -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
|
||||||
|
]
|
||||||
@@ -10,6 +10,8 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from backend.data.market_store import MarketStore
|
||||||
|
from .research_db import ResearchDb
|
||||||
from .runtime_db import RuntimeDb
|
from .runtime_db import RuntimeDb
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -64,6 +66,8 @@ class StorageService:
|
|||||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.server_state_file = self.state_dir / "server_state.json"
|
self.server_state_file = self.state_dir / "server_state.json"
|
||||||
self.runtime_db = RuntimeDb(self.state_dir / "runtime.db")
|
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)
|
# Feed history (for agent messages)
|
||||||
self.max_feed_history = 200
|
self.max_feed_history = 200
|
||||||
|
|||||||
236
backend/tests/test_cli.py
Normal file
236
backend/tests/test_cli.py
Normal file
@@ -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"]
|
||||||
384
backend/tests/test_gateway_explain_handlers.py
Normal file
384
backend/tests/test_gateway_explain_handlers.py
Normal file
@@ -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]
|
||||||
65
backend/tests/test_historical_price_manager.py
Normal file
65
backend/tests/test_historical_price_manager.py
Normal file
@@ -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
|
||||||
133
backend/tests/test_llm_enricher.py
Normal file
133
backend/tests/test_llm_enricher.py
Normal file
@@ -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
|
||||||
54
backend/tests/test_market_store_report.py
Normal file
54
backend/tests/test_market_store_report.py
Normal file
@@ -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
|
||||||
174
backend/tests/test_news_enricher.py
Normal file
174
backend/tests/test_news_enricher.py
Normal file
@@ -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"]
|
||||||
54
backend/tests/test_range_explainer.py
Normal file
54
backend/tests/test_range_explainer.py
Normal file
@@ -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
|
||||||
@@ -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 | 可启用数据源列表
|
ENABLED_DATA_SOURCES = # Optional allowlist, comma-separated, e.g. financial_datasets,finnhub,yfinance,local_csv | 可启用数据源列表
|
||||||
FINANCIAL_DATASETS_API_KEY= #required | 必填
|
FINANCIAL_DATASETS_API_KEY= #required | 必填
|
||||||
FINNHUB_API_KEY= #optional | 可选
|
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
|
# Model API
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_BASE_URL=
|
OPENAI_BASE_URL=
|
||||||
MODEL_NAME=qwen3-max-preview
|
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)
|
#记忆模块(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
|
# default to use aliyun dashscope url, more details: https://help.aliyun.com/zh/model-studio/what-is-model-studio
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"preview:host": "vite preview --host"
|
"preview:host": "vite preview --host"
|
||||||
},
|
},
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
@@ -45,11 +48,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"jsdom": "^29.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^7.1.2",
|
"vite": "^7.1.2",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Configuration and constants
|
||||||
import { AGENTS, INITIAL_TICKERS } from './config/constants';
|
import { AGENTS, INITIAL_TICKERS } from './config/constants';
|
||||||
@@ -13,19 +13,40 @@ import { useFeedProcessor } from './hooks/useFeedProcessor';
|
|||||||
import GlobalStyles from './styles/GlobalStyles';
|
import GlobalStyles from './styles/GlobalStyles';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import RoomView from './components/RoomView';
|
|
||||||
import NetValueChart from './components/NetValueChart';
|
import NetValueChart from './components/NetValueChart';
|
||||||
import AgentFeed from './components/AgentFeed';
|
|
||||||
import StockLogo from './components/StockLogo';
|
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 Header from './components/Header.jsx';
|
||||||
import WatchlistPanel from './components/WatchlistPanel.jsx';
|
import WatchlistPanel from './components/WatchlistPanel.jsx';
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { formatNumber, formatTickerPrice } from './utils/formatters';
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: 240,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#ffffff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.4
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live Trading Intelligence Platform - Read-Only Dashboard
|
* Live Trading Intelligence Platform - Read-Only Dashboard
|
||||||
* Geek Style - Terminal-inspired, minimal, monochrome
|
* Geek Style - Terminal-inspired, minimal, monochrome
|
||||||
@@ -73,6 +94,7 @@ export default function LiveTradingApp() {
|
|||||||
const [priceHistoryByTicker, setPriceHistoryByTicker] = useState({});
|
const [priceHistoryByTicker, setPriceHistoryByTicker] = useState({});
|
||||||
const [ohlcHistoryByTicker, setOhlcHistoryByTicker] = useState({});
|
const [ohlcHistoryByTicker, setOhlcHistoryByTicker] = useState({});
|
||||||
const [explainEventsByTicker, setExplainEventsByTicker] = useState({});
|
const [explainEventsByTicker, setExplainEventsByTicker] = useState({});
|
||||||
|
const [newsByTicker, setNewsByTicker] = useState({});
|
||||||
const [selectedExplainSymbol, setSelectedExplainSymbol] = useState('');
|
const [selectedExplainSymbol, setSelectedExplainSymbol] = useState('');
|
||||||
const [historySourceByTicker, setHistorySourceByTicker] = 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 raw = typeof marketStatus.status_text === 'string' ? marketStatus.status_text.trim() : '';
|
||||||
const normalized = raw.toLowerCase();
|
const normalized = raw.toLowerCase();
|
||||||
if (normalized === 'market closed (non-trading day)') {
|
const byStatus = {
|
||||||
return '休市';
|
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') {
|
if (marketStatus.status && byStatus[marketStatus.status]) {
|
||||||
return '开盘';
|
return byStatus[marketStatus.status];
|
||||||
}
|
}
|
||||||
if (normalized === 'market closed') {
|
return raw || '状态未知';
|
||||||
return '收盘';
|
|
||||||
}
|
|
||||||
|
|
||||||
return raw || (marketStatus.status === 'open' ? '开盘' : '收盘');
|
|
||||||
}, [marketStatus]);
|
}, [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;
|
const source = dataSources?.last_success?.prices;
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = String(source).trim().toLowerCase();
|
const normalized = String(source).trim().toLowerCase();
|
||||||
const labels = {
|
return `历史 ${providerLabelMap[normalized] || String(source).trim()}`;
|
||||||
yfinance: '数据源 Yahoo',
|
}, [dataSources, providerLabelMap]);
|
||||||
finnhub: '数据源 Finnhub',
|
|
||||||
financial_datasets: '数据源 Financial Datasets',
|
|
||||||
local_csv: '数据源 CSV'
|
|
||||||
};
|
|
||||||
|
|
||||||
return labels[normalized] || `数据源 ${String(source).trim()}`;
|
|
||||||
}, [dataSources]);
|
|
||||||
|
|
||||||
const parseWatchlistInput = useCallback((value) => {
|
const parseWatchlistInput = useCallback((value) => {
|
||||||
if (typeof value !== 'string') {
|
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
|
// Switch away from LIVE tab when market closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLiveEnabled && chartTab === 'live') {
|
if (!isLiveEnabled && chartTab === 'live') {
|
||||||
@@ -439,7 +610,21 @@ export default function LiveTradingApp() {
|
|||||||
}
|
}
|
||||||
requestStockHistory(selectedExplainSymbol);
|
requestStockHistory(selectedExplainSymbol);
|
||||||
requestStockExplainEvents(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)
|
// Clock - use virtual time if available (for mock mode)
|
||||||
useEffect(() => {
|
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
|
// Real-time price updates
|
||||||
price_update: (e) => {
|
price_update: (e) => {
|
||||||
try {
|
try {
|
||||||
@@ -1146,7 +1518,14 @@ export default function LiveTradingApp() {
|
|||||||
clientRef.current.disconnect();
|
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
|
// Resizing handlers
|
||||||
const handleMouseDown = (e) => {
|
const handleMouseDown = (e) => {
|
||||||
@@ -1318,16 +1697,24 @@ export default function LiveTradingApp() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{priceSourceLabel && (
|
{livePriceSourceLabel && (
|
||||||
<>
|
<>
|
||||||
<span className="status-sep">·</span>
|
<span className="status-sep">·</span>
|
||||||
<span className="market-text backtest">
|
<span className="market-text backtest">
|
||||||
{priceSourceLabel}
|
{livePriceSourceLabel}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{historicalPriceSourceLabel && (
|
||||||
|
<>
|
||||||
|
<span className="status-sep">·</span>
|
||||||
|
<span className="market-text backtest">
|
||||||
|
{historicalPriceSourceLabel}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="status-sep">·</span>
|
<span className="status-sep">·</span>
|
||||||
<span className="time-text">{lastUpdate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
|
<span className="time-text">{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WatchlistPanel
|
<WatchlistPanel
|
||||||
@@ -1407,7 +1794,7 @@ export default function LiveTradingApp() {
|
|||||||
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
|
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
|
||||||
onClick={() => setCurrentView('explain')}
|
onClick={() => setCurrentView('explain')}
|
||||||
>
|
>
|
||||||
个股解释
|
个股分析
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -1437,30 +1824,41 @@ export default function LiveTradingApp() {
|
|||||||
} ${!isInitialAnimating ? 'normal-speed' : ''}`}>
|
} ${!isInitialAnimating ? 'normal-speed' : ''}`}>
|
||||||
{/* Room View Panel */}
|
{/* Room View Panel */}
|
||||||
<div className="view-panel">
|
<div className="view-panel">
|
||||||
<RoomView
|
<Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}>
|
||||||
bubbles={bubbles}
|
<RoomView
|
||||||
bubbleFor={bubbleFor}
|
bubbles={bubbles}
|
||||||
leaderboard={leaderboard}
|
bubbleFor={bubbleFor}
|
||||||
feed={feed}
|
leaderboard={leaderboard}
|
||||||
onJumpToMessage={handleJumpToMessage}
|
feed={feed}
|
||||||
/>
|
onJumpToMessage={handleJumpToMessage}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stock Explain View Panel */}
|
{/* Stock Explain View Panel */}
|
||||||
<div className="view-panel">
|
<div className="view-panel">
|
||||||
<StockExplainView
|
<Suspense fallback={<ViewLoadingFallback label="加载个股分析..." />}>
|
||||||
tickers={displayTickers}
|
<StockExplainView
|
||||||
holdings={holdings}
|
tickers={displayTickers}
|
||||||
trades={trades}
|
holdings={holdings}
|
||||||
leaderboard={leaderboard}
|
trades={trades}
|
||||||
feed={feed}
|
leaderboard={leaderboard}
|
||||||
priceHistoryByTicker={priceHistoryByTicker}
|
feed={feed}
|
||||||
ohlcHistoryByTicker={ohlcHistoryByTicker}
|
priceHistoryByTicker={priceHistoryByTicker}
|
||||||
selectedSymbol={selectedExplainSymbol}
|
ohlcHistoryByTicker={ohlcHistoryByTicker}
|
||||||
onSelectedSymbolChange={setSelectedExplainSymbol}
|
selectedSymbol={selectedExplainSymbol}
|
||||||
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
|
onSelectedSymbolChange={setSelectedExplainSymbol}
|
||||||
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
|
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
|
||||||
/>
|
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
|
||||||
|
newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
|
||||||
|
onRequestRangeExplain={requestStockRangeExplain}
|
||||||
|
onRequestNewsForDate={requestStockNewsForDate}
|
||||||
|
onRequestStory={requestStockStory}
|
||||||
|
currentDate={currentDate}
|
||||||
|
onRequestSimilarDays={requestStockSimilarDays}
|
||||||
|
onRequestStockEnrich={requestStockEnrich}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart View Panel */}
|
{/* Chart View Panel */}
|
||||||
@@ -1502,14 +1900,16 @@ export default function LiveTradingApp() {
|
|||||||
|
|
||||||
{/* Statistics View Panel */}
|
{/* Statistics View Panel */}
|
||||||
<div className="view-panel">
|
<div className="view-panel">
|
||||||
<StatisticsView
|
<Suspense fallback={<ViewLoadingFallback label="加载统计视图..." />}>
|
||||||
trades={trades}
|
<StatisticsView
|
||||||
holdings={holdings}
|
trades={trades}
|
||||||
stats={stats}
|
holdings={holdings}
|
||||||
baseline_vw={portfolioData.baseline_vw}
|
stats={stats}
|
||||||
equity={portfolioData.equity}
|
baseline_vw={portfolioData.baseline_vw}
|
||||||
leaderboard={leaderboard}
|
equity={portfolioData.equity}
|
||||||
/>
|
leaderboard={leaderboard}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1524,7 +1924,9 @@ export default function LiveTradingApp() {
|
|||||||
|
|
||||||
{/* Right Panel: Agent Feed */}
|
{/* Right Panel: Agent Feed */}
|
||||||
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
|
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
|
||||||
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} />
|
<Suspense fallback={<ViewLoadingFallback label="加载消息流..." />}>
|
||||||
|
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ export default function WatchlistPanel({
|
|||||||
onSave
|
onSave
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative', marginLeft: -6 }}>
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
style={{
|
style={{
|
||||||
@@ -36,7 +36,7 @@ export default function WatchlistPanel({
|
|||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
WATCHLIST
|
自选股
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
|||||||
157
frontend/src/components/explain/ExplainEventsSection.jsx
Normal file
157
frontend/src/components/explain/ExplainEventsSection.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">关键事件时间线</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
图上点击事件点可切换对应日期
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isOpen ? '#111111' : '#ffffff',
|
||||||
|
color: isOpen ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '收起关键事件' : `展开关键事件 ${explainTimeline.length}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{explainTimeline.length === 0 ? (
|
||||||
|
<div className="empty-state">当前还没有可以串起来看的关键事件。</div>
|
||||||
|
) : !isOpen ? (
|
||||||
|
<div className="empty-state">关键事件默认收起,需要时再展开查看和筛选。</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 14 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{availableEventDates.map((dateKey) => {
|
||||||
|
const isActive = dateKey === selectedEventDate;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dateKey}
|
||||||
|
onClick={() => onSelectEventDate(dateKey)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isActive ? '#111111' : '#ffffff',
|
||||||
|
color: isActive ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dateKey}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{Object.entries(eventCategoryMeta)
|
||||||
|
.filter(([key]) => (eventCategoryCounts[key] || 0) > 0 || key === 'all')
|
||||||
|
.map(([key, meta]) => {
|
||||||
|
const isActive = key === activeEventCategory;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onSelectEventCategory(key)}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${meta.color}`,
|
||||||
|
background: isActive ? meta.color : '#ffffff',
|
||||||
|
color: isActive ? '#ffffff' : meta.color,
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.label} {eventCategoryCounts[key] || 0}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visibleExplainEvents.length === 0 ? (
|
||||||
|
<div className="empty-state">当前日期下没有符合筛选条件的事件</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
|
||||||
|
{visibleExplainEvents.map((event) => {
|
||||||
|
const accent = event.tone === 'positive' ? '#00C853' : event.tone === 'negative' ? '#FF1744' : '#000000';
|
||||||
|
const categoryMeta = eventCategoryMeta[event.category] || eventCategoryMeta.other;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#ffffff',
|
||||||
|
padding: 14,
|
||||||
|
minHeight: 180
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
padding: '2px 6px',
|
||||||
|
border: `1px solid ${categoryMeta.color}`,
|
||||||
|
color: categoryMeta.color,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700
|
||||||
|
}}>
|
||||||
|
{categoryMeta.label}
|
||||||
|
</span>
|
||||||
|
<strong style={{ fontSize: 13 }}>{event.title}</strong>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
|
||||||
|
{formatDateTime(event.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: accent
|
||||||
|
}} />
|
||||||
|
<span style={{ fontSize: 10, color: '#666666', textTransform: 'uppercase', letterSpacing: 0.6 }}>
|
||||||
|
{event.meta}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#000000', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||||
|
{event.body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
frontend/src/components/explain/ExplainMaintenanceSection.jsx
Normal file
249
frontend/src/components/explain/ExplainMaintenanceSection.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">分析数据维护</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
当前标的 {selectedSymbol || '-'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isOpen ? '#111111' : '#ffffff',
|
||||||
|
color: isOpen ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '收起刷新工具' : '展开刷新工具'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOpen ? (
|
||||||
|
<div className="empty-state">刷新工具默认收起,需要时再展开重新分析数据或查看历史。</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#ffffff',
|
||||||
|
padding: 14,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||||
|
<label style={{ display: 'grid', gap: 6, fontSize: 11, fontWeight: 700 }}>
|
||||||
|
开始日期
|
||||||
|
<input type="date" value={enrichStartDate} onChange={(e) => onChangeStartDate(e.target.value)} style={{ border: '1px solid #111111', padding: '8px 10px', fontFamily: 'inherit' }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'grid', gap: 6, fontSize: 11, fontWeight: 700 }}>
|
||||||
|
结束日期
|
||||||
|
<input type="date" value={enrichEndDate} onChange={(e) => onChangeEndDate(e.target.value)} style={{ border: '1px solid #111111', padding: '8px 10px', fontFamily: 'inherit' }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={onToggleForce} style={toggleButtonStyle(forceEnrich, '#b91c1c')}>
|
||||||
|
{forceEnrich ? '覆盖已有分析' : '仅补缺失'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onToggleOnlyLocalToLlm} style={toggleButtonStyle(onlyLocalToLlm, '#7c3aed')}>
|
||||||
|
{onlyLocalToLlm ? '仅将规则分析升级为 LLM分析' : '不限制分析来源'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onToggleRebuildStory} style={toggleButtonStyle(rebuildStory, '#2563eb')}>
|
||||||
|
{rebuildStory ? '重建主线叙事' : '跳过主线叙事'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onToggleRebuildSimilarDays} style={toggleButtonStyle(rebuildSimilarDays, '#15803d')}>
|
||||||
|
{rebuildSimilarDays ? '重建相似交易日' : '跳过相似交易日'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={onRunEnrich}
|
||||||
|
disabled={isRunning || !selectedSymbol || !enrichStartDate || !enrichEndDate}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isRunning ? '#d1d5db' : '#111111',
|
||||||
|
color: '#ffffff',
|
||||||
|
padding: '9px 14px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: isRunning ? 'wait' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRunning ? '执行中...' : '重新分析当前区间'}
|
||||||
|
</button>
|
||||||
|
{maintenanceStatus?.updatedAt ? (
|
||||||
|
<span style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
最近一次执行: {maintenanceStatus.updatedAt}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{maintenanceStatus?.error ? (
|
||||||
|
<div style={{ fontSize: 11, color: '#991b1b', lineHeight: 1.7 }}>
|
||||||
|
执行失败: {maintenanceStatus.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{stats ? (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10 }}>
|
||||||
|
{[
|
||||||
|
['新闻总数', 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]) => (
|
||||||
|
<div key={label} style={{ border: '1px solid #111111', padding: 10 }}>
|
||||||
|
<div style={{ fontSize: 10, color: '#666666' }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700 }}>{value ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary ? (
|
||||||
|
<div style={{ border: '1px solid #111111', padding: 12, fontSize: 11, lineHeight: 1.8 }}>
|
||||||
|
{summary.upgraded_dates?.length ? (
|
||||||
|
<div><strong>升级日期:</strong> {summary.upgraded_dates.join(', ')}</div>
|
||||||
|
) : null}
|
||||||
|
{summary.remaining_local_titles?.length ? (
|
||||||
|
<div><strong>仍为规则分析:</strong> {summary.remaining_local_titles.join(' / ')}</div>
|
||||||
|
) : null}
|
||||||
|
{typeof summary.skipped_non_local_count === 'number' ? (
|
||||||
|
<div><strong>跳过非规则分析:</strong> {summary.skipped_non_local_count}</div>
|
||||||
|
) : null}
|
||||||
|
{typeof summary.skipped_missing_analysis_count === 'number' ? (
|
||||||
|
<div><strong>跳过无历史分析:</strong> {summary.skipped_missing_analysis_count}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{Array.isArray(maintenanceHistory) && maintenanceHistory.length > 0 ? (
|
||||||
|
<div style={{ border: '1px solid #111111', padding: 12, display: 'grid', gap: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700 }}>最近刷新历史</div>
|
||||||
|
{maintenanceHistory.slice(0, 5).map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={`${item.timestamp || 'history'}-${index}`}
|
||||||
|
style={{
|
||||||
|
borderTop: index === 0 ? 'none' : '1px solid #e5e7eb',
|
||||||
|
paddingTop: index === 0 ? 0 : 8,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>{item.startDate || '-'}</strong> ~ <strong>{item.endDate || '-'}</strong>
|
||||||
|
{' · '}
|
||||||
|
{item.onlyLocalToLlm ? '规则分析→LLM分析' : item.force ? '覆盖重跑' : '补缺失'}
|
||||||
|
{item.storyStatus ? ' · 主线叙事' : ''}
|
||||||
|
{item.similarStatus ? ' · 相似交易日' : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: item.error ? '#991b1b' : '#4b5563' }}>
|
||||||
|
{item.timestamp || '-'}
|
||||||
|
{item.error
|
||||||
|
? ` · 失败: ${item.error}`
|
||||||
|
: ` · 已分析 ${item.stats?.analyzed ?? 0},已升级 ${item.stats?.upgraded_local_to_llm_count ?? 0}`}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectHistory?.(item)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: '#ffffff',
|
||||||
|
color: '#111111',
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
回填到表单
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onReplayHistory?.(item)}
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: '#111111',
|
||||||
|
color: '#ffffff',
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
直接重跑
|
||||||
|
</button>
|
||||||
|
{!item.error ? (
|
||||||
|
<span style={{ marginLeft: 8, fontSize: 10, color: '#666666' }}>
|
||||||
|
{item.stats?.execution_summary?.upgraded_dates?.length
|
||||||
|
? `升级日 ${item.stats.execution_summary.upgraded_dates.join(', ')}`
|
||||||
|
: '无升级日期摘要'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
frontend/src/components/explain/ExplainMentionsSection.jsx
Normal file
77
frontend/src/components/explain/ExplainMentionsSection.jsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { formatDateTime } from '../../utils/formatters';
|
||||||
|
|
||||||
|
export default function ExplainMentionsSection({
|
||||||
|
recentMentions,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">讨论提及</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
从交易讨论和分析 feed 提取
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isOpen ? '#111111' : '#ffffff',
|
||||||
|
color: isOpen ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '收起讨论摘录' : `展开讨论摘录 ${recentMentions.length}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentMentions.length === 0 ? (
|
||||||
|
<div className="empty-state">最近没有在讨论里提到这只股票。</div>
|
||||||
|
) : !isOpen ? (
|
||||||
|
<div className="empty-state">讨论摘录默认收起,需要时再展开查看。</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
|
||||||
|
{recentMentions.map((message, index) => (
|
||||||
|
<div
|
||||||
|
key={`${message.feedId || message.id}-${index}`}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#fafafa',
|
||||||
|
padding: 14,
|
||||||
|
minHeight: 150
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, color: '#000000' }}>{message.agent || '未知角色'}</div>
|
||||||
|
<div style={{ fontSize: 10, color: '#666666' }}>
|
||||||
|
{message.conferenceTitle || (message.feedType === 'conference' ? '投资讨论' : '即时消息')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
|
||||||
|
{formatDateTime(message.timestamp)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
color: '#000000',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}>
|
||||||
|
{String(message.content || '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
308
frontend/src/components/explain/ExplainNewsSection.jsx
Normal file
308
frontend/src/components/explain/ExplainNewsSection.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">新闻面板</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
{newsSnapshot?.source ? `最近 ${visibleNewsByCategory.length} 条 · ${newsSnapshot.source}` : `最近 ${visibleNewsByCategory.length} 条真实新闻`}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isOpen ? '#111111' : '#ffffff',
|
||||||
|
color: isOpen ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '收起新闻面板' : '展开新闻面板'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOpen ? (
|
||||||
|
<div className="empty-state">新闻面板已收起,需要时再展开查看分类、情绪和新闻卡片。</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectNewsCategory('all')}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: activeNewsCategory === 'all' ? '#111111' : '#ffffff',
|
||||||
|
color: activeNewsCategory === 'all' ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
全部 {visibleNews.length}
|
||||||
|
</button>
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onSelectNewsCategory(key)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #2563eb',
|
||||||
|
background: isActive ? '#2563eb' : '#ffffff',
|
||||||
|
color: isActive ? '#ffffff' : '#2563eb',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categoryLabel(meta.label || key)} {meta.count}{pos || neg ? ` · +${pos}/-${neg}` : ''}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||||
|
{[
|
||||||
|
{ key: 'all', label: '全部情绪' },
|
||||||
|
{ key: 'positive', label: '利多' },
|
||||||
|
{ key: 'negative', label: '利空' },
|
||||||
|
{ key: 'neutral', label: '中性' }
|
||||||
|
].map((item) => {
|
||||||
|
const isActive = activeNewsSentiment === item.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => onSelectNewsSentiment(item.key)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isActive ? '#111111' : '#ffffff',
|
||||||
|
color: isActive ? '#ffffff' : '#111111',
|
||||||
|
padding: '6px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tickerNews.length === 0 ? (
|
||||||
|
<div className="empty-state">当前数据源没有返回相关新闻</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
|
||||||
|
{visibleNewsByCategory.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#ffffff',
|
||||||
|
padding: 14,
|
||||||
|
minHeight: 180
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const sentimentMeta = sentimentStyle(item.sentiment);
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
padding: '2px 6px',
|
||||||
|
border: `1px solid ${sentimentMeta.border}`,
|
||||||
|
background: sentimentMeta.background,
|
||||||
|
color: sentimentMeta.color,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700
|
||||||
|
}}>
|
||||||
|
{sentimentMeta.label}
|
||||||
|
</span>
|
||||||
|
{item.relevance ? (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
padding: '2px 6px',
|
||||||
|
border: '1px solid #111111',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700
|
||||||
|
}}>
|
||||||
|
{relevanceLabel(item.relevance)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{item.analysisSource ? (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
padding: '2px 6px',
|
||||||
|
border: '1px solid #6b7280',
|
||||||
|
color: '#4b5563',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700
|
||||||
|
}}>
|
||||||
|
{analysisSourceLabel(item.analysisSource)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{item.analysisModelLabel ? (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
padding: '2px 6px',
|
||||||
|
border: '1px solid #9ca3af',
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700
|
||||||
|
}}>
|
||||||
|
{item.analysisModelLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{typeof item.retT0 === 'number' ? (
|
||||||
|
<span style={{ fontSize: 10, color: item.retT0 >= 0 ? '#15803d' : '#b91c1c', fontWeight: 700 }}>
|
||||||
|
T0 {item.retT0 >= 0 ? '+' : ''}{(item.retT0 * 100).toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{item.category ? (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
padding: '2px 6px',
|
||||||
|
border: '1px solid #111111',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700
|
||||||
|
}}>
|
||||||
|
{categoryLabel(item.category)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<strong style={{ fontSize: 13 }}>{item.title}</strong>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
|
||||||
|
{formatDateTime(item.date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 10, color: '#666666', textTransform: 'uppercase', letterSpacing: 0.6 }}>
|
||||||
|
{item.source}
|
||||||
|
</span>
|
||||||
|
{item.related ? (
|
||||||
|
<span style={{ fontSize: 10, color: '#666666' }}>
|
||||||
|
关联: {item.related}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#000000', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||||
|
{item.summary || '该新闻没有可用摘要。'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.keyDiscussion ? (
|
||||||
|
<div style={{ marginTop: 10, fontSize: 11, lineHeight: 1.7, color: '#374151' }}>
|
||||||
|
<strong>核心讨论:</strong> {item.keyDiscussion}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{item.reasonGrowth ? (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#166534' }}>
|
||||||
|
<strong>利多逻辑:</strong> {item.reasonGrowth}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{item.reasonDecrease ? (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#991b1b' }}>
|
||||||
|
<strong>利空逻辑:</strong> {item.reasonDecrease}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{item.url ? (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ fontSize: 11, fontWeight: 700, color: '#111111', textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
查看原文
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
frontend/src/components/explain/ExplainPriceSection.jsx
Normal file
155
frontend/src/components/explain/ExplainPriceSection.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">价格与事件</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
{ohlcSeries.length > 1
|
||||||
|
? `最近 ${ohlcSeries.length} 根日线K线${selectedHistorySource ? ` · ${selectedHistorySource}` : ''}`
|
||||||
|
: `最近 ${priceSeries.length} 个价格点聚合为 ${chartModel.bucketCount || 0} 根简化K线`}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isOpen ? '#111111' : '#ffffff',
|
||||||
|
color: isOpen ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '收起价格区' : '展开价格区'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ohlcSeries.length === 0 && priceSeries.length === 0 ? (
|
||||||
|
<div className="empty-state">当前还没有可绘制的价格历史</div>
|
||||||
|
) : !isOpen ? (
|
||||||
|
<div className="empty-state">价格区已收起,需要时再展开查看图表和事件点。</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${chartModel.width} ${chartModel.height}`}
|
||||||
|
style={{ width: '100%', height: '220px', display: 'block', overflow: 'visible' }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="stockExplainFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(0,0,0,0.18)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(0,0,0,0.02)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect x="0" y="0" width={chartModel.width} height={chartModel.height} fill="#fafafa" />
|
||||||
|
<line
|
||||||
|
x1={chartModel.padding}
|
||||||
|
y1={chartModel.height - chartModel.padding}
|
||||||
|
x2={chartModel.width - chartModel.padding}
|
||||||
|
y2={chartModel.height - chartModel.padding}
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<g key={candle.id}>
|
||||||
|
<line
|
||||||
|
x1={candle.centerX}
|
||||||
|
y1={candle.highY}
|
||||||
|
x2={candle.centerX}
|
||||||
|
y2={candle.lowY}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth="1.4"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={candle.x}
|
||||||
|
y={candle.bodyY}
|
||||||
|
width={candle.width}
|
||||||
|
height={candle.bodyHeight}
|
||||||
|
fill={fill}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth="1.4"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}) : chartModel.path && (
|
||||||
|
<>
|
||||||
|
<path d={`${chartModel.path} L${chartModel.width - chartModel.padding},${chartModel.height - chartModel.padding} L${chartModel.padding},${chartModel.height - chartModel.padding} Z`} fill="url(#stockExplainFill)" />
|
||||||
|
<path d={chartModel.path} fill="none" stroke="#000000" strokeWidth="2.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chartModel.markers.map((marker) => {
|
||||||
|
const fill = marker.tone === 'positive'
|
||||||
|
? '#00C853'
|
||||||
|
: marker.tone === 'negative'
|
||||||
|
? '#FF1744'
|
||||||
|
: marker.tone === 'news'
|
||||||
|
? '#2563eb'
|
||||||
|
: '#000000';
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={marker.id}
|
||||||
|
onClick={() => onSelectEventDate(marker.dateKey)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<line x1={marker.x} y1={marker.y} x2={marker.x} y2={chartModel.height - chartModel.padding} stroke={fill} strokeDasharray="3 3" strokeWidth="1" />
|
||||||
|
<circle
|
||||||
|
cx={marker.x}
|
||||||
|
cy={marker.y}
|
||||||
|
r={marker.markerType === 'news'
|
||||||
|
? (marker.isSelected ? '5.5' : '4')
|
||||||
|
: (marker.isSelected ? '6' : '4.5')}
|
||||||
|
fill={fill}
|
||||||
|
stroke={marker.isSelected ? '#111111' : '#ffffff'}
|
||||||
|
strokeWidth={marker.isSelected ? '2.5' : '2'}
|
||||||
|
/>
|
||||||
|
<title>{`${marker.title} · ${marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<text x={chartModel.padding} y="14" fontSize="11" fill="#666666">
|
||||||
|
{chartModel.maxPrice != null ? `高点 $${formatTickerPrice(chartModel.maxPrice)}` : ''}
|
||||||
|
</text>
|
||||||
|
<text x={chartModel.padding} y={chartModel.height - 6} fontSize="11" fill="#666666">
|
||||||
|
{chartModel.minPrice != null ? `低点 $${formatTickerPrice(chartModel.minPrice)}` : ''}
|
||||||
|
</text>
|
||||||
|
<text x={chartModel.width - chartModel.padding} y="14" fontSize="11" fill="#666666" textAnchor="end">
|
||||||
|
{selectedTicker?.price != null ? `现价 $${formatTickerPrice(selectedTicker.price)}` : ''}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 12 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
图表说明:{ohlcSeries.length > 1 ? '历史日线K线' : '基于盘中价格点聚合的简化K线'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#2563eb' }}>蓝点:新闻日期</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>黑点:讨论提及</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#00C853' }}>绿点:偏多信号或做多成交</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#FF1744' }}>红点:偏空信号或做空成交</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
frontend/src/components/explain/ExplainRangeSection.jsx
Normal file
220
frontend/src/components/explain/ExplainRangeSection.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, gap: 12 }}>
|
||||||
|
<span style={{ color: '#4b5563' }}>{label}</span>
|
||||||
|
<strong style={{ color: valueColor, textAlign: 'right' }}>{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div style={{ fontSize: 12, lineHeight: 1.7, color: '#6b7280' }}>{emptyText}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={`${tone}-${index}-${item}`}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${colors.border}`,
|
||||||
|
background: colors.background,
|
||||||
|
color: colors.color,
|
||||||
|
padding: '6px 10px',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExplainRangeSection({
|
||||||
|
selectedRangeWindow,
|
||||||
|
selectedRangeExplain,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">区间涨跌分析</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
{selectedRangeWindow
|
||||||
|
? `${selectedRangeWindow.startDate} ~ ${selectedRangeWindow.endDate}`
|
||||||
|
: '先在图上选择一个事件日期'}
|
||||||
|
</div>
|
||||||
|
{selectedRangeExplain?.analysis?.analysis_source ? (
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
{selectedRangeExplain.analysis.analysis_source === 'llm'
|
||||||
|
? `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)} · ${selectedRangeExplain.analysis.analysis_model_label || 'LLM'}`
|
||||||
|
: `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)}`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isOpen ? '#111111' : '#ffffff',
|
||||||
|
color: isOpen ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '收起区间涨跌分析' : '展开区间涨跌分析'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedRangeWindow ? (
|
||||||
|
<div className="empty-state">选择图上的日期后,会自动生成最近 7 天的区间涨跌分析。</div>
|
||||||
|
) : !isOpen ? (
|
||||||
|
<div className="empty-state">区间涨跌分析已收起,需要时再展开查看摘要和快照。</div>
|
||||||
|
) : !selectedRangeExplain ? (
|
||||||
|
<div className="empty-state">正在生成区间涨跌分析...</div>
|
||||||
|
) : selectedRangeExplain.error ? (
|
||||||
|
<div className="empty-state">{selectedRangeExplain.error}</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 16 }}>
|
||||||
|
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
|
区间摘要
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, lineHeight: 1.8 }}>
|
||||||
|
{selectedRangeExplain.analysis?.summary || '暂无区间摘要'}
|
||||||
|
</div>
|
||||||
|
{selectedRangeExplain.analysis?.trend_analysis ? (
|
||||||
|
<div style={{ marginTop: 10, fontSize: 12, lineHeight: 1.7, color: '#4b5563' }}>
|
||||||
|
<strong>趋势拆解:</strong> {selectedRangeExplain.analysis.trend_analysis}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div style={{ marginTop: 14, display: 'grid', gap: 8 }}>
|
||||||
|
{(selectedRangeExplain.analysis?.key_events || []).slice(0, 6).map((event, index) => (
|
||||||
|
<div key={`${event.id || event.title}-${index}`} style={{ borderTop: index === 0 ? 'none' : '1px solid #e5e7eb', paddingTop: index === 0 ? 0 : 8 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666', marginBottom: 4 }}>
|
||||||
|
{event.date || '-'} {event.category ? `· ${renderCategoryLabel(event.category)}` : ''} {event.sentiment ? `· ${renderSentimentLabel(event.sentiment)}` : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 4 }}>{event.title}</div>
|
||||||
|
<div style={{ fontSize: 12, lineHeight: 1.6 }}>{event.summary || '暂无摘要'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
|
区间快照
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>事实概览</div>
|
||||||
|
<MetricRow
|
||||||
|
label="区间涨跌"
|
||||||
|
value={`${Number(selectedRangeExplain.price_change_pct) >= 0 ? '+' : ''}${Number(selectedRangeExplain.price_change_pct || 0).toFixed(2)}%`}
|
||||||
|
valueColor={Number(selectedRangeExplain.price_change_pct) >= 0 ? '#00C853' : '#FF1744'}
|
||||||
|
/>
|
||||||
|
<MetricRow label="关联新闻" value={selectedRangeExplain.news_count || 0} />
|
||||||
|
<MetricRow label="区间高点" value={`$${formatTickerPrice(selectedRangeExplain.high_price)}`} />
|
||||||
|
<MetricRow label="区间低点" value={`$${formatTickerPrice(selectedRangeExplain.low_price)}`} />
|
||||||
|
<MetricRow label="交易日数" value={selectedRangeExplain.trading_days || 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>主题分布</div>
|
||||||
|
{(selectedRangeExplain.dominant_categories || []).length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{selectedRangeExplain.dominant_categories.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.category}-${item.count}`}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151',
|
||||||
|
padding: '6px 10px',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderCategoryLabel(item.category)} · {item.count}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#6b7280' }}>
|
||||||
|
当前没有识别出明显的主题聚类。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>驱动因素</div>
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#166534' }}>利多因素</div>
|
||||||
|
<TagList
|
||||||
|
items={selectedRangeExplain.analysis?.bullish_factors || []}
|
||||||
|
tone="positive"
|
||||||
|
emptyText="当前区间内未提炼出明确的利多因素。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#991b1b' }}>利空因素</div>
|
||||||
|
<TagList
|
||||||
|
items={selectedRangeExplain.analysis?.bearish_factors || []}
|
||||||
|
tone="negative"
|
||||||
|
emptyText="当前区间内未提炼出明确的利空因素。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
frontend/src/components/explain/ExplainSignalsSection.jsx
Normal file
123
frontend/src/components/explain/ExplainSignalsSection.jsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function ExplainSignalsSection({
|
||||||
|
tickerSignals,
|
||||||
|
signalSummary,
|
||||||
|
latestSignal,
|
||||||
|
eventDateKey,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">分析师观点</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
最近 {tickerSignals.length} 条相关信号
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isOpen ? '#111111' : '#ffffff',
|
||||||
|
color: isOpen ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '收起分析师观点' : '展开分析师观点'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOpen ? (
|
||||||
|
<div className="empty-state">分析师观点已收起,需要时再展开查看信号统计和明细。</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="stats-grid" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card-label">看涨</div>
|
||||||
|
<div className="stat-card-value positive">{signalSummary.bullish}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card-label">看跌</div>
|
||||||
|
<div className="stat-card-value negative">{signalSummary.bearish}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card-label">中性</div>
|
||||||
|
<div className="stat-card-value">{signalSummary.neutral}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card-label">最新结论</div>
|
||||||
|
<div className="stat-card-value" style={{ fontSize: 22 }}>
|
||||||
|
{latestSignal
|
||||||
|
? latestSignal.normalizedDirection === 'bullish'
|
||||||
|
? '偏多'
|
||||||
|
: latestSignal.normalizedDirection === 'bearish'
|
||||||
|
? '偏空'
|
||||||
|
: '观望'
|
||||||
|
: '暂无'}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, color: '#666666' }}>
|
||||||
|
{latestSignal ? `${latestSignal.agentName} · ${latestSignal.date || eventDateKey(latestSignal.timestamp)}` : '还没有历史信号'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tickerSignals.length === 0 ? (
|
||||||
|
<div className="empty-state">该股票还没有分析师信号记录</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>日期</th>
|
||||||
|
<th>分析师</th>
|
||||||
|
<th>方向</th>
|
||||||
|
<th>实际收益</th>
|
||||||
|
<th>结果</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr key={signal.id || `${signal.agentId}-${signal.date}-${index}`}>
|
||||||
|
<td>{signal.date || eventDateKey(signal.timestamp) || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ fontWeight: 700 }}>{signal.agentName}</div>
|
||||||
|
<div style={{ fontSize: 10, color: '#666666' }}>{signal.role}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ color: directionColor, fontWeight: 700 }}>{directionText}</td>
|
||||||
|
<td>{realReturn}</td>
|
||||||
|
<td>{status}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/components/explain/ExplainSimilarDaysSection.jsx
Normal file
111
frontend/src/components/explain/ExplainSimilarDaysSection.jsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function ExplainSimilarDaysSection({
|
||||||
|
selectedSimilarDays,
|
||||||
|
selectedEventDate,
|
||||||
|
onSelectSimilarDate,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">历史相似交易日</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
{selectedEventDate || '先选择一个事件日期'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isOpen ? '#111111' : '#ffffff',
|
||||||
|
color: isOpen ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '收起相似交易日' : '展开相似交易日'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedEventDate ? (
|
||||||
|
<div className="empty-state">选择图上的日期后,会检索这只股票历史上的相似交易日。</div>
|
||||||
|
) : !isOpen ? (
|
||||||
|
<div className="empty-state">相似交易日默认收起,需要时再展开查看。</div>
|
||||||
|
) : !selectedSimilarDays ? (
|
||||||
|
<div className="empty-state">正在检索相似交易日...</div>
|
||||||
|
) : selectedSimilarDays.error ? (
|
||||||
|
<div className="empty-state">{selectedSimilarDays.error}</div>
|
||||||
|
) : !Array.isArray(selectedSimilarDays.items) || selectedSimilarDays.items.length === 0 ? (
|
||||||
|
<div className="empty-state">当前没有足够历史样本来计算相似交易日。</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 16 }}>
|
||||||
|
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
|
目标日快照
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
<div style={{ color: '#666666', marginBottom: 4 }}>新闻数量</div>
|
||||||
|
<strong>{selectedSimilarDays.target_features?.n_articles ?? 0}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
<div style={{ color: '#666666', marginBottom: 4 }}>情绪分数</div>
|
||||||
|
<strong>{Number(selectedSimilarDays.target_features?.sentiment_score ?? 0).toFixed(2)}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
<div style={{ color: '#666666', marginBottom: 4 }}>前一日涨跌</div>
|
||||||
|
<strong>{Number(selectedSimilarDays.target_features?.ret_1d ?? 0).toFixed(2)}%</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
<div style={{ color: '#666666', marginBottom: 4 }}>高相关新闻</div>
|
||||||
|
<strong>{selectedSimilarDays.target_features?.high_relevance_count ?? 0}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
|
||||||
|
{selectedSimilarDays.items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.date}
|
||||||
|
onClick={() => onSelectSimilarDate?.(item.date)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#ffffff',
|
||||||
|
padding: 14,
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, marginBottom: 8 }}>
|
||||||
|
<strong style={{ fontSize: 13 }}>{item.date}</strong>
|
||||||
|
<span style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
相似度 {(Number(item.score || 0) * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gap: 6, fontSize: 12, marginBottom: 10 }}>
|
||||||
|
<div>新闻数 {item.n_articles ?? 0}</div>
|
||||||
|
<div>情绪分数 {Number(item.sentiment_score ?? 0).toFixed(2)}</div>
|
||||||
|
<div>前一日涨跌 {Number(item.ret_1d ?? 0).toFixed(2)}%</div>
|
||||||
|
<div>次日表现 {item.ret_t1_after != null ? `${item.ret_t1_after >= 0 ? '+' : ''}${Number(item.ret_t1_after).toFixed(2)}%` : '-'}</div>
|
||||||
|
<div>三日表现 {item.ret_t3_after != null ? `${item.ret_t3_after >= 0 ? '+' : ''}${Number(item.ret_t3_after).toFixed(2)}%` : '-'}</div>
|
||||||
|
</div>
|
||||||
|
{(item.top_reasons || []).length > 0 ? (
|
||||||
|
<div style={{ fontSize: 11, lineHeight: 1.7, color: '#4b5563' }}>
|
||||||
|
<strong>主要线索:</strong> {item.top_reasons.join(' / ')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/components/explain/ExplainStorySection.jsx
Normal file
58
frontend/src/components/explain/ExplainStorySection.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">主线叙事</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
{selectedStory?.asOfDate || currentDate || '按当前解释窗口生成'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isOpen ? '#111111' : '#ffffff',
|
||||||
|
color: isOpen ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '收起主线叙事' : '展开主线叙事'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedSymbol ? (
|
||||||
|
<div className="empty-state">先选择一只股票</div>
|
||||||
|
) : !isOpen ? (
|
||||||
|
<div className="empty-state">主线叙事默认收起,需要时再展开查看完整叙事。</div>
|
||||||
|
) : !selectedStory?.story ? (
|
||||||
|
<div className="empty-state">正在生成主线叙事...</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 18 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
|
{selectedStory?.source ? `来源 · ${selectedStory.source}` : '自动生成'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, lineHeight: 1.8 }}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{selectedStory.story}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/components/explain/ExplainSummarySection.jsx
Normal file
87
frontend/src/components/explain/ExplainSummarySection.jsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function ExplainSummarySection({
|
||||||
|
explainSummary,
|
||||||
|
tickerSignals,
|
||||||
|
recentMentions,
|
||||||
|
tickerTrades,
|
||||||
|
tickerNews,
|
||||||
|
selectedSymbol,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">分析摘要</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
基于当前持仓、信号和讨论自动汇总
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isOpen ? '#111111' : '#ffffff',
|
||||||
|
color: isOpen ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '收起分析摘要' : '展开分析摘要'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOpen ? (
|
||||||
|
<div className="empty-state">分析摘要已收起,需要时再展开查看概览和密度信息。</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 16 }}>
|
||||||
|
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
|
当前解释
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{explainSummary.map((line, index) => (
|
||||||
|
<div key={`${selectedSymbol}-summary-${index}`} style={{ fontSize: 13, lineHeight: 1.7, color: '#000000' }}>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
|
信号密度
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||||
|
<span>分析师信号</span>
|
||||||
|
<strong>{tickerSignals.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||||
|
<span>讨论提及</span>
|
||||||
|
<strong>{recentMentions.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||||
|
<span>成交记录</span>
|
||||||
|
<strong>{tickerTrades.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||||
|
<span>新闻条目</span>
|
||||||
|
<strong>{tickerNews.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 1, background: '#e0e0e0', margin: '4px 0' }} />
|
||||||
|
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#666666' }}>
|
||||||
|
当前分析优先读取已落库的历史记录,缺失时再回退到本次运行中的实时事件。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
frontend/src/components/explain/ExplainTradesSection.jsx
Normal file
74
frontend/src/components/explain/ExplainTradesSection.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">成交记录</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||||
|
{tickerTrades.length} 笔与 {selectedSymbol} 相关的交易
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isOpen ? '#111111' : '#ffffff',
|
||||||
|
color: isOpen ? '#ffffff' : '#111111',
|
||||||
|
padding: '7px 10px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '收起成交记录' : `展开成交记录 ${tickerTrades.length}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tickerTrades.length === 0 ? (
|
||||||
|
<div className="empty-state">该股票暂无成交记录</div>
|
||||||
|
) : !isOpen ? (
|
||||||
|
<div className="empty-state">成交记录默认收起,需要时再展开查看。</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>方向</th>
|
||||||
|
<th>数量</th>
|
||||||
|
<th>价格</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tickerTrades.slice(0, 10).map((trade, index) => (
|
||||||
|
<tr key={trade.id || `${trade.ticker}-${trade.timestamp}-${index}`}>
|
||||||
|
<td>{formatDateTime(trade.timestamp)}</td>
|
||||||
|
<td style={{ fontWeight: 700, color: trade.side === 'LONG' ? '#00C853' : trade.side === 'SHORT' ? '#FF1744' : '#000000' }}>
|
||||||
|
{sideLabel(trade.side)}
|
||||||
|
</td>
|
||||||
|
<td>{trade.qty}</td>
|
||||||
|
<td>${Number(trade.price).toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
281
frontend/src/components/explain/explainUtils.js
Normal file
281
frontend/src/components/explain/explainUtils.js
Normal file
@@ -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' }
|
||||||
|
};
|
||||||
664
frontend/src/components/explain/useExplainModel.js
Normal file
664
frontend/src/components/explain/useExplainModel.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
156
frontend/src/components/explain/useExplainModel.test.jsx
Normal file
156
frontend/src/components/explain/useExplainModel.test.jsx
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,9 +8,11 @@ export default defineConfig({
|
|||||||
allowedHosts: ["localhost", "trading.evoagents.cn","www.evoagents.cn"]
|
allowedHosts: ["localhost", "trading.evoagents.cn","www.evoagents.cn"]
|
||||||
},
|
},
|
||||||
plugins: [react(), tsconfigPaths(),tailwindcss()],
|
plugins: [react(), tsconfigPaths(),tailwindcss()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom"
|
||||||
|
},
|
||||||
preview: {
|
preview: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 4173
|
port: 4173
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ evotraders = "backend.cli:app"
|
|||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["backend", "backend.agents", "backend.config",
|
packages = ["backend", "backend.agents", "backend.config",
|
||||||
"backend.data", "backend.llm",
|
"backend.data", "backend.llm",
|
||||||
"backend.tools", "backend.utils", "backend.services"]
|
"backend.tools", "backend.utils", "backend.services",
|
||||||
|
"backend.explain", "backend.enrich"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
@@ -77,4 +78,4 @@ testpaths = ["backend/tests"]
|
|||||||
python_files = ["test_*.py"]
|
python_files = ["test_*.py"]
|
||||||
python_classes = ["Test*"]
|
python_classes = ["Test*"]
|
||||||
python_functions = ["test_*"]
|
python_functions = ["test_*"]
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
---
|
---
|
||||||
tickers:
|
tickers:
|
||||||
- AAPL
|
- AAPL
|
||||||
- MSFT
|
- MSFT
|
||||||
- GOOGL
|
|
||||||
- NVDA
|
|
||||||
- TSLA
|
|
||||||
- META
|
|
||||||
- AMZN
|
|
||||||
initial_cash: 100000
|
initial_cash: 100000
|
||||||
margin_requirement: 0.0
|
margin_requirement: 0.0
|
||||||
enable_memory: false
|
enable_memory: false
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user