diff --git a/.env.docker.example b/.env.docker.example index cf109b1..ca5a646 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -20,5 +20,10 @@ MONITOR_DB_PATH=/app/data/monitor.db # 如需东方财富 suggest 补全,请在真实 .env 中配置,不要提交真实 token EASTMONEY_TOKEN= +# ====== 美股数据源 ====== +# 美股行情主源为 yfinance(无需 key);Finnhub 作为备选源及美股新闻源。 +# 未配置时仅用 yfinance,美股个股新闻会跳过。https://finnhub.io 免费注册获取 key +FINNHUB_API_KEY= + # ====== 可选推送配置 ====== SERVERCHAN_KEY= diff --git a/.gitignore b/.gitignore index c4b7850..03c1062 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ logs/ # Local runtime data data/*.db data/agent_memory/ +data/strategy_analysis_cache.json data/user_settings.json # Temporary files diff --git a/README.md b/README.md index c0c6a37..99b029a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,14 @@ OpenAshare 试图把这些碎片收进一个可控的工作台: - 研究进度事件流,适合较长的分析任务 - 盘前、盘中、午间、盘后不同研究节奏提示 +### 多市场支持 + +- **A股**:上交所 / 深交所 / 创业板 / 科创板(数据源 Ashare + akshare) +- **港股**:如 `00700.HK`(数据源腾讯) +- **美股**:如 `AAPL`、`NVDA`、`BRK.B`(主源 yfinance,备选 Finnhub) +- 三市场统一搜索:在同一界面里搜 `茅台`、`腾讯`、`苹果` / `AAPL` 均可 +- 美股技术分析、指标、信号与 A股 完全对齐;个股新闻走 Finnhub(需配置 `FINNHUB_API_KEY`),A股 特有的资金流/龙虎榜对美股自动跳过 + ### 新闻与热点 - 个股新闻与全局市场消息浏览 diff --git a/api/main.py b/api/main.py index a816107..c900791 100644 --- a/api/main.py +++ b/api/main.py @@ -263,6 +263,44 @@ def _warm_read_caches() -> None: hotspot_service.list_hotspots(limit=10) except Exception: logger.exception("Warmup failed for hotspots") + _warm_stock_pool() + + +def _warm_stock_pool() -> None: + """后台预热股池中部分个股的行情分析(不含 AI),让用户首次点击命中缓存。 + + 通过 WARMUP_STOCK_LIMIT 控制预热数量(默认 12,设为 0 可关闭)。 + """ + try: + limit = int(os.getenv("WARMUP_STOCK_LIMIT", "12")) + except ValueError: + limit = 12 + if limit <= 0: + return + + try: + from ashare.stock_pool import load_stock_pool + + pool = load_stock_pool() + except Exception: + logger.exception("Warmup failed to load stock pool") + return + + # 多个别名可能指向同一代码,按出现顺序去重后截断。 + seen: set[str] = set() + codes: list[str] = [] + for code in pool.values(): + if code and code not in seen: + seen.add(code) + codes.append(code) + if len(codes) >= limit: + break + + for code in codes: + try: + stock_service.get_stock_analysis(code, include_ai=False) + except Exception: + logger.warning("Warmup failed for stock %s", code, exc_info=True) @asynccontextmanager @@ -847,7 +885,13 @@ def list_hotspots(request: Request, limit: int = Query(10, ge=1, le=20)) -> Resp def list_global_news(request: Request, limit: int = Query(20, ge=1, le=50)) -> Response: try: payload = news_service.get_global_news(limit=limit) - return _cached_json_response(request, payload, max_age=60, stale_while_revalidate=180) + return _cached_json_response( + request, + payload, + max_age=60, + stale_while_revalidate=180, + no_store=not bool(payload), + ) except Exception as exc: logger.exception("get_global_news failed") return JSONResponse(content=[], headers={"Cache-Control": "no-store"}) diff --git a/api/schemas.py b/api/schemas.py index a139b9a..09e52a3 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -122,6 +122,16 @@ class HotspotRelatedStock(BaseModel): reason: str +class HotspotHeatBreakdown(BaseModel): + trading_activity: float = 0 + discussion_heat: float = 0 + news_heat: float = 0 + alert_count: int = 0 + rank_hits: int = 0 + attention_level: Literal["focus", "caution", "watch"] = "watch" + basis: List[str] = Field(default_factory=list) + + class HotspotItem(BaseModel): topic_name: str heat_score: float @@ -130,6 +140,7 @@ class HotspotItem(BaseModel): trend_direction: Literal["up", "down", "flat"] = "flat" ai_summary: Optional[str] = None source: str = "derived" + heat_breakdown: HotspotHeatBreakdown = Field(default_factory=HotspotHeatBreakdown) class HotspotHistoryPoint(BaseModel): @@ -270,6 +281,7 @@ class StrategyHoldingAnalysis(BaseModel): action_reason: str = "" trigger_hits: List[str] = Field(default_factory=list) alerts: List[str] = Field(default_factory=list) + analysis_status: Literal["live", "cached", "degraded", "local"] = "live" class StrategyTodoItem(BaseModel): diff --git a/api/services.py b/api/services.py index 109072f..9c602c8 100644 --- a/api/services.py +++ b/api/services.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import json import sqlite3 import time import re @@ -24,6 +25,7 @@ AgentResponse, AgentHistoryTurn, GlobalNewsItem, + HotspotHeatBreakdown, HotspotDetailResponse, HotspotHistoryPoint, HotspotItem, @@ -549,17 +551,32 @@ def get_stock_analysis( basic_data = bundle.analysis.get("基础数据", {}) latest = bundle.dataframe.iloc[-1] # K 线图默认展示最近约 5 年日 K(~250 交易日 * 5),方便观察中长期趋势 - chart_df = bundle.dataframe.tail(1250).copy().reset_index() + # 按列向量化提取,避免对上千行使用 DataFrame.iterrows(每行构造 Series,开销大)。 + chart_df = bundle.dataframe.tail(1250) + chart_dates = [str(idx)[:10] for idx in chart_df.index] + + def _column_values(name: str) -> list: + if name not in chart_df.columns: + return [None] * len(chart_df) + return [_safe_float(value) for value in chart_df[name].to_numpy()] + chart_series = [ { - "date": str(row[chart_df.columns[0]])[:10], - "open": _safe_float(row.get("open")), - "high": _safe_float(row.get("high")), - "low": _safe_float(row.get("low")), - "close": _safe_float(row.get("close")), - "volume": _safe_float(row.get("volume")), + "date": date, + "open": open_, + "high": high, + "low": low, + "close": close, + "volume": volume, } - for _, row in chart_df.iterrows() + for date, open_, high, low, close, volume in zip( + chart_dates, + _column_values("open"), + _column_values("high"), + _column_values("low"), + _column_values("close"), + _column_values("volume"), + ) ] indicators = { key: _safe_float(latest.get(key)) @@ -616,7 +633,7 @@ def get_stock_analysis( chart_series=chart_series, metadata={ "data_points": len(bundle.dataframe), - "source": "akshare", + "source": "yfinance/finnhub" if infer_market(quote.stock_code) == "us" else "akshare", "generated_at": quote.timestamp.isoformat(), }, ) @@ -716,7 +733,7 @@ def get_global_news(self, limit: int = 20) -> List[GlobalNewsItem]: if not records: try: - records.extend(self._global_news_from_alerts()) + records.extend(self._global_news_from_alerts(min_impact=2)) except Exception: pass @@ -737,7 +754,7 @@ def get_global_news(self, limit: int = 20) -> List[GlobalNewsItem]: result.append(GlobalNewsItem(**item)) except Exception: pass # skip invalid item - self._response_cache.set(cache_key, result, 90) + self._response_cache.set(cache_key, result, 90 if result else 15) return result def get_context_news_for_query(self, query: str, limit: int = 6) -> List[GlobalNewsItem]: @@ -845,13 +862,14 @@ def _normalize_global_news_dataframe(self, data_frame: Optional[pd.DataFrame], s ) return normalized - def _global_news_from_alerts(self) -> List[Dict[str, Any]]: + def _global_news_from_alerts(self, min_impact: int = 3) -> List[Dict[str, Any]]: items: List[Dict[str, Any]] = [] for alert in self.state_store.get_recent_alerts(limit=120): title = alert.get("title", "") summary = alert.get("summary", "") topic_meta = self._classify_global_topic(title, summary, alert.get("source") or "monitor") - if topic_meta["impact_level"] < 3: + impact_level = max(int(topic_meta["impact_level"]), int(alert.get("priority", 1))) + if impact_level < min_impact: continue items.append( { @@ -864,7 +882,7 @@ def _global_news_from_alerts(self) -> List[Dict[str, Any]]: "topic": topic_meta["topic"], "region": topic_meta["region"], "sentiment": _sentiment_from_summary(summary), - "impact_level": topic_meta["impact_level"], + "impact_level": min(impact_level, 5), "url": alert.get("raw_payload", {}).get("url"), "related_symbols": topic_meta["related_symbols"], "raw_payload": alert.get("raw_payload", {}), @@ -1239,6 +1257,15 @@ def list_hotspots(self, limit: int = 10) -> List[HotspotItem]: topic_counter: Counter[str] = Counter() topic_stocks: Dict[str, List[HotspotRelatedStock]] = defaultdict(list) topic_reasons: Dict[str, List[str]] = defaultdict(list) + topic_breakdowns: Dict[str, Dict[str, float]] = defaultdict( + lambda: { + "trading_activity": 0.0, + "discussion_heat": 0.0, + "news_heat": 0.0, + "alert_count": 0.0, + "rank_hits": 0.0, + } + ) for alert in alerts: title = alert.get("title", "") @@ -1246,10 +1273,19 @@ def list_hotspots(self, limit: int = 10) -> List[HotspotItem]: stock_name = alert.get("stock_name", "") stock_code = alert.get("stock_code", "") priority = max(1, int(alert.get("priority", 1))) + event_type = str(alert.get("event_type") or "") for sector_name, sector in sectors.items(): if not self._sector_keywords_match(sector["keywords"], title, summary, stock_name, stock_code): continue - topic_counter[sector_name] += priority + breakdown = topic_breakdowns[sector_name] + breakdown["alert_count"] += 1 + if event_type == "fund_flow" or any(token in title + summary for token in ["资金流", "净流入", "净流出", "放量", "成交"]): + breakdown["trading_activity"] += priority * 1.8 + breakdown["discussion_heat"] += priority * 0.35 + else: + breakdown["discussion_heat"] += priority + breakdown["news_heat"] += priority * 0.7 + topic_counter[sector_name] = self._hotspot_total_score(breakdown) topic_reasons[sector_name].append(title or summary) topic_stocks[sector_name].append( HotspotRelatedStock( @@ -1269,7 +1305,11 @@ def list_hotspots(self, limit: int = 10) -> List[HotspotItem]: " ".join(global_news.related_symbols), ): continue - topic_counter[sector_name] += max(2, global_news.impact_level) + impact = max(2, global_news.impact_level) + breakdown = topic_breakdowns[sector_name] + breakdown["discussion_heat"] += impact + breakdown["news_heat"] += impact * 1.2 + topic_counter[sector_name] = self._hotspot_total_score(breakdown) topic_reasons[sector_name].append(global_news.title) topic_stocks[sector_name].extend(sector["stocks"][:2]) @@ -1281,7 +1321,13 @@ def list_hotspots(self, limit: int = 10) -> List[HotspotItem]: for sector_name, sector in sectors.items(): if not self._sector_keywords_match(sector["keywords"], rank_name, rank_code): continue - topic_counter[sector_name] += 1 + rank_value = _safe_float(rank.get("rank")) or 50 + trading_boost = max(1.0, 6.0 - min(rank_value, 50.0) / 10.0) + breakdown = topic_breakdowns[sector_name] + breakdown["trading_activity"] += trading_boost + breakdown["discussion_heat"] += 1 + breakdown["rank_hits"] += 1 + topic_counter[sector_name] = self._hotspot_total_score(breakdown) topic_reasons[sector_name].append(f"热度排名 {rank.get('rank', '未知')}") topic_stocks[sector_name].append( HotspotRelatedStock( @@ -1302,21 +1348,88 @@ def list_hotspots(self, limit: int = 10) -> List[HotspotItem]: if not related_stocks or score <= 0: continue top_reason = next((item for item in topic_reasons[topic_name] if item), f"{topic_name} 相关消息密度提升") + breakdown = topic_breakdowns[topic_name] + heat_breakdown = self._build_hotspot_breakdown(breakdown, topic_reasons[topic_name]) items.append( HotspotItem( topic_name=topic_name, heat_score=float(score), - reason=f"{top_reason[:40]},累计热度 {int(score)}", + reason=( + f"{top_reason[:40]},综合热度 {int(score)}" + f"(交易 {breakdown['trading_activity']:.1f} / 讨论 {breakdown['discussion_heat']:.1f})" + ), related_stocks=related_stocks, - trend_direction="up" if score >= 4 else "flat", - ai_summary=f"{topic_name} 近期被多条消息和异动共同触发,可优先跟踪相关代表股。", + trend_direction="up" if score >= 8 else "flat", + ai_summary=self._build_hotspot_summary(topic_name, heat_breakdown), source="sector_config+monitor+news+hk_rank", + heat_breakdown=heat_breakdown, ) ) _HOTSPOTS_CACHE = items _HOTSPOTS_CACHE_TIME = time.monotonic() return items[: min(limit, len(items))] + @staticmethod + def _hotspot_total_score(breakdown: Dict[str, float]) -> float: + return round( + breakdown["trading_activity"] * 1.2 + + breakdown["discussion_heat"] + + breakdown["news_heat"] * 0.8 + + breakdown["rank_hits"] * 1.5, + 1, + ) + + @staticmethod + def _build_hotspot_breakdown( + breakdown: Dict[str, float], + reasons: List[str], + ) -> HotspotHeatBreakdown: + reason_text = " ".join(reasons) + risk_tokens = ["减持", "净流出", "下跌", "监管", "处罚", "风险", "亏损", "警惕", "退市", "利空"] + has_risk_signal = any(token in reason_text for token in risk_tokens) + trading_activity = round(breakdown["trading_activity"], 1) + discussion_heat = round(breakdown["discussion_heat"], 1) + news_heat = round(breakdown["news_heat"], 1) + if has_risk_signal and (trading_activity >= 3 or discussion_heat >= 4): + attention_level = "caution" + elif trading_activity >= 4 and discussion_heat >= 4: + attention_level = "focus" + else: + attention_level = "watch" + + basis: List[str] = [] + if trading_activity: + basis.append("资金流/热榜/成交异动贡献交易活跃度") + if discussion_heat: + basis.append("新闻命中、主题词和热榜共同贡献讨论度") + if news_heat: + basis.append("高影响消息贡献催化强度") + if has_risk_signal: + basis.append("存在净流出、减持、监管或业绩风险词,需提高警惕") + + return HotspotHeatBreakdown( + trading_activity=trading_activity, + discussion_heat=discussion_heat, + news_heat=news_heat, + alert_count=int(breakdown["alert_count"]), + rank_hits=int(breakdown["rank_hits"]), + attention_level=attention_level, + basis=basis[:4], + ) + + @staticmethod + def _build_hotspot_summary(topic_name: str, breakdown: HotspotHeatBreakdown) -> str: + level_text = { + "focus": "交易活跃和讨论度同时靠前,属于当前优先跟踪方向", + "caution": "热度较高但伴随风险信号,适合先警惕再确认", + "watch": "有消息或讨论触发,暂以观察和验证持续性为主", + }[breakdown.attention_level] + return ( + f"{topic_name}:{level_text}。" + f"交易活跃 {breakdown.trading_activity:.1f},讨论热度 {breakdown.discussion_heat:.1f}," + f"消息催化 {breakdown.news_heat:.1f}。" + ) + def get_hotspot_detail(self, topic_name: str) -> HotspotDetailResponse: cache_key = f"hotspot_detail:{topic_name}" cached = self._response_cache.get(cache_key) @@ -1702,6 +1815,15 @@ def _init_db(self) -> None: connection.execute("ALTER TABLE strategy_holdings ADD COLUMN plan_take_profit REAL") if "plan_max_position_pct" not in columns: connection.execute("ALTER TABLE strategy_holdings ADD COLUMN plan_max_position_pct REAL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS strategy_analysis_cache ( + cache_key TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) def list_holdings(self) -> List[StrategyHolding]: with self._connect() as connection: @@ -1860,6 +1982,32 @@ def delete_holding(self, holding_id: int) -> None: if cursor.rowcount == 0: raise NotFoundError(f"Strategy holding {holding_id} not found") + def get_analysis_cache(self, cache_key: str) -> Optional[str]: + with self._connect() as connection: + row = connection.execute( + "SELECT payload FROM strategy_analysis_cache WHERE cache_key = ?", + (cache_key,), + ).fetchone() + return str(row["payload"]) if row else None + + def set_analysis_cache(self, cache_key: str, payload: str) -> None: + now = _now_utc().isoformat() + with self._connect() as connection: + connection.execute( + """ + INSERT INTO strategy_analysis_cache (cache_key, payload, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(cache_key) DO UPDATE SET + payload = excluded.payload, + updated_at = excluded.updated_at + """, + (cache_key, payload, now), + ) + + def clear_analysis_cache(self, cache_key: str) -> None: + with self._connect() as connection: + connection.execute("DELETE FROM strategy_analysis_cache WHERE cache_key = ?", (cache_key,)) + def _normalize_exit_fields(self, holding: StrategyHolding, now: str) -> Tuple[Optional[float], Optional[str]]: if holding.status != "exited": return None, None @@ -1977,6 +2125,8 @@ def __init__( self.market_service = market_service or MarketService(stock_service) self.store = store or StrategyHoldingStore() self.portfolio_store = portfolio_store or PortfolioStore() + self.analysis_cache_key = "strategy_holdings" + self._analysis_cache_lock = Lock() def screen_can_slim( self, @@ -2055,6 +2205,7 @@ def list_holdings(self) -> List[StrategyHolding]: def create_holding(self, holding: StrategyHolding) -> StrategyHolding: created = self.store.create_holding(holding) self._sync_portfolio_position_for_code(created.stock_code) + self._clear_holdings_analysis_cache() return created def update_holding(self, holding_id: int, holding: StrategyHolding) -> StrategyHolding: @@ -2065,6 +2216,7 @@ def update_holding(self, holding_id: int, holding: StrategyHolding) -> StrategyH codes_to_sync.add(previous.stock_code) for code in codes_to_sync: self._sync_portfolio_position_for_code(code) + self._clear_holdings_analysis_cache() return updated def delete_holding(self, holding_id: int) -> None: @@ -2072,29 +2224,33 @@ def delete_holding(self, holding_id: int) -> None: self.store.delete_holding(holding_id) if previous: self._sync_portfolio_position_for_code(previous.stock_code) + self._clear_holdings_analysis_cache() def analyze_holdings(self) -> StrategyHoldingAnalysisResponse: holdings = self.store.list_holdings() if not holdings: - return StrategyHoldingAnalysisResponse( - total_cost=0, - total_market_value=0, - total_pnl=0, - total_pnl_pct=0, - total_realized_pnl=0, - holding_count=0, - active_count=0, - watching_count=0, - planned_count=0, - weakening_count=0, - exited_count=0, - invalidated_count=0, - win_rate_pct=0, - average_score=0, - todo_items=[], - review_items=[], - holdings=[], - ) + return self._empty_holdings_analysis() + + cached = self._read_cached_holdings_analysis(holdings) + if cached: + return cached + return self._build_snapshot_holdings_analysis(holdings) + + def refresh_holdings(self) -> StrategyHoldingAnalysisResponse: + holdings = self.store.list_holdings() + previous = self._read_cached_holdings_analysis(holdings) if holdings else None + analysis = self._compute_holdings_analysis() + if previous: + analysis = self._merge_refresh_analysis_with_previous(analysis, previous) + self._write_cached_holdings_analysis(analysis) + return analysis + + def _compute_holdings_analysis(self) -> StrategyHoldingAnalysisResponse: + holdings = self.store.list_holdings() + if not holdings: + analysis = self._empty_holdings_analysis() + self._write_cached_holdings_analysis(analysis) + return analysis try: market_regime = self.market_service.get_market_regime() @@ -2111,11 +2267,31 @@ def analyze_holdings(self) -> StrategyHoldingAnalysisResponse: ) analyses: List[StrategyHoldingAnalysis] = [] - for holding in holdings: - try: - analyses.append(self._analyze_single_holding(holding, market_regime)) - except Exception as exc: - analyses.append(self._build_unavailable_holding_analysis(holding, market_regime, exc)) + max_workers = min(4, len(holdings)) + executor = ThreadPoolExecutor(max_workers=max_workers) + try: + future_by_holding = [ + (holding, executor.submit(self._analyze_single_holding, holding, market_regime)) + for holding in holdings + ] + done, _ = wait([future for _, future in future_by_holding], timeout=18) + for holding, future in future_by_holding: + if future not in done: + future.cancel() + analyses.append( + self._build_unavailable_holding_analysis( + holding, + market_regime, + TimeoutError("单股策略分析超过 18 秒,已降级为持仓快照"), + ) + ) + continue + try: + analyses.append(future.result()) + except Exception as exc: + analyses.append(self._build_unavailable_holding_analysis(holding, market_regime, exc)) + finally: + executor.shutdown(wait=False, cancel_futures=True) total_cost = 0.0 total_market_value = 0.0 total_realized_pnl = 0.0 @@ -2126,6 +2302,7 @@ def analyze_holdings(self) -> StrategyHoldingAnalysisResponse: exited_count = 0 invalidated_count = 0 exited_win_count = 0 + scored_count = 0 total_score = 0.0 for item in analyses: cost = item.holding.entry_price * item.holding.quantity @@ -2133,7 +2310,9 @@ def analyze_holdings(self) -> StrategyHoldingAnalysisResponse: total_cost += cost total_market_value += item.market_value total_realized_pnl += item.realized_pnl - total_score += item.strategy_score.total + if self._has_reusable_holding_analysis(item): + total_score += item.strategy_score.total + scored_count += 1 if item.holding.status == "holding": active_count += 1 elif item.holding.status == "watching": @@ -2153,7 +2332,7 @@ def analyze_holdings(self) -> StrategyHoldingAnalysisResponse: total_pnl = total_market_value - total_cost total_pnl_pct = (total_pnl / total_cost * 100) if total_cost else 0 win_rate_pct = (exited_win_count / exited_count * 100) if exited_count else 0 - average_score = (total_score / len(holdings)) if holdings else 0 + average_score = (total_score / scored_count) if scored_count else 0 todo_items = self._build_todo_items(analyses) review_items = self._build_review_items(analyses) return StrategyHoldingAnalysisResponse( @@ -2176,8 +2355,236 @@ def analyze_holdings(self) -> StrategyHoldingAnalysisResponse: holdings=analyses, ) - def refresh_holdings(self) -> StrategyHoldingAnalysisResponse: - return self.analyze_holdings() + @staticmethod + def _empty_holdings_analysis() -> StrategyHoldingAnalysisResponse: + return StrategyHoldingAnalysisResponse( + total_cost=0, + total_market_value=0, + total_pnl=0, + total_pnl_pct=0, + total_realized_pnl=0, + holding_count=0, + active_count=0, + watching_count=0, + planned_count=0, + weakening_count=0, + exited_count=0, + invalidated_count=0, + win_rate_pct=0, + average_score=0, + todo_items=[], + review_items=[], + holdings=[], + ) + + def _neutral_market_regime(self) -> MarketRegimeResponse: + return MarketRegimeResponse( + regime="neutral", + score=50, + action_bias="市场数据暂不可用,按中性环境处理。", + position_guidance="建议轻仓试错。", + summary="市场环境数据暂不可用。", + notes=[], + indices=[], + updated_at=_now_utc(), + ) + + def _build_snapshot_holdings_analysis(self, holdings: List[StrategyHolding]) -> StrategyHoldingAnalysisResponse: + market_regime = self._neutral_market_regime() + analyses = [ + self._build_unavailable_holding_analysis(holding, market_regime, ValueError("尚未生成服务端评分缓存")) + for holding in holdings + ] + return self._summarize_holdings_analysis(analyses) + + def _read_cached_holdings_analysis( + self, + holdings: List[StrategyHolding], + ) -> Optional[StrategyHoldingAnalysisResponse]: + with self._analysis_cache_lock: + try: + raw = self.store.get_analysis_cache(self.analysis_cache_key) + if not raw: + return None + payload = json.loads(raw) + cached = StrategyHoldingAnalysisResponse.model_validate(payload.get("analysis", payload)) + except Exception: + return None + if not cached.holdings: + return None + merged = self._merge_cached_holdings_analysis(holdings, cached) + if not any(self._has_reusable_holding_analysis(item) for item in merged.holdings): + return None + return merged + + def _write_cached_holdings_analysis(self, analysis: StrategyHoldingAnalysisResponse) -> None: + if analysis.holdings and not any(self._has_reusable_holding_analysis(item) for item in analysis.holdings): + return + payload = { + "updated_at": _now_utc().isoformat(), + "analysis": analysis.model_dump(mode="json"), + } + with self._analysis_cache_lock: + self.store.set_analysis_cache(self.analysis_cache_key, json.dumps(payload, ensure_ascii=False)) + + def _clear_holdings_analysis_cache(self) -> None: + with self._analysis_cache_lock: + self.store.clear_analysis_cache(self.analysis_cache_key) + + def _merge_refresh_analysis_with_previous( + self, + latest: StrategyHoldingAnalysisResponse, + previous: StrategyHoldingAnalysisResponse, + ) -> StrategyHoldingAnalysisResponse: + previous_by_id = { + item.holding.id: item + for item in previous.holdings + if item.holding.id is not None and self._has_reusable_holding_analysis(item) + } + previous_by_code = { + normalize_stock_code(item.holding.stock_code): item + for item in previous.holdings + if self._has_reusable_holding_analysis(item) + } + merged: List[StrategyHoldingAnalysis] = [] + for item in latest.holdings: + if self._has_reusable_holding_analysis(item): + merged.append(item) + continue + previous_item = previous_by_id.get(item.holding.id) + if previous_item is None: + previous_item = previous_by_code.get(normalize_stock_code(item.holding.stock_code)) + merged.append(self._rebase_cached_holding_analysis(item.holding, previous_item) if previous_item else item) + return self._summarize_holdings_analysis(merged) + + def _merge_cached_holdings_analysis( + self, + holdings: List[StrategyHolding], + cached: StrategyHoldingAnalysisResponse, + ) -> StrategyHoldingAnalysisResponse: + cached_by_id = { + item.holding.id: item + for item in cached.holdings + if item.holding.id is not None and self._has_reusable_holding_analysis(item) + } + cached_by_code = { + normalize_stock_code(item.holding.stock_code): item + for item in cached.holdings + if self._has_reusable_holding_analysis(item) + } + market_regime = self._neutral_market_regime() + analyses: List[StrategyHoldingAnalysis] = [] + for holding in holdings: + cached_item = cached_by_id.get(holding.id) + if cached_item is None: + cached_item = cached_by_code.get(normalize_stock_code(holding.stock_code)) + if cached_item is not None: + analyses.append(self._rebase_cached_holding_analysis(holding, cached_item)) + else: + analyses.append( + self._build_unavailable_holding_analysis( + holding, + market_regime, + ValueError("该持仓暂无服务端评分缓存"), + ) + ) + return self._summarize_holdings_analysis(analyses) + + @staticmethod + def _rebase_cached_holding_analysis( + holding: StrategyHolding, + cached: StrategyHoldingAnalysis, + ) -> StrategyHoldingAnalysis: + current_price = holding.exit_price if holding.status == "exited" and holding.exit_price is not None else cached.current_price + is_pre_position = holding.status in {"watching", "planned"} + cost = holding.entry_price * holding.quantity + market_value = 0.0 if is_pre_position else current_price * holding.quantity + pnl = 0.0 if is_pre_position else market_value - cost + realized_pnl = ( + ((holding.exit_price or 0) - holding.entry_price) * holding.quantity + if holding.status == "exited" and holding.exit_price is not None + else 0.0 + ) + return cached.model_copy( + update={ + "holding": holding, + "current_price": current_price, + "market_value": market_value, + "pnl": pnl, + "pnl_pct": 0.0 if is_pre_position else ((pnl / cost * 100) if cost else 0.0), + "realized_pnl": realized_pnl, + "realized_pnl_pct": (realized_pnl / cost * 100) if cost and realized_pnl else 0.0, + "analysis_status": "cached", + } + ) + + @staticmethod + def _has_reusable_holding_analysis(item: StrategyHoldingAnalysis) -> bool: + return item.strategy_score.total > 0 and item.analysis_status in {"live", "cached"} + + def _summarize_holdings_analysis( + self, + analyses: List[StrategyHoldingAnalysis], + ) -> StrategyHoldingAnalysisResponse: + total_cost = 0.0 + total_market_value = 0.0 + total_realized_pnl = 0.0 + active_count = 0 + watching_count = 0 + planned_count = 0 + weakening_count = 0 + exited_count = 0 + invalidated_count = 0 + exited_win_count = 0 + scored_count = 0 + total_score = 0.0 + for item in analyses: + cost = item.holding.entry_price * item.holding.quantity + if item.holding.status not in {"watching", "planned"}: + total_cost += cost + total_market_value += item.market_value + total_realized_pnl += item.realized_pnl + if self._has_reusable_holding_analysis(item): + total_score += item.strategy_score.total + scored_count += 1 + if item.holding.status == "holding": + active_count += 1 + elif item.holding.status == "watching": + watching_count += 1 + elif item.holding.status == "planned": + planned_count += 1 + elif item.holding.status == "weakening": + active_count += 1 + weakening_count += 1 + elif item.holding.status == "exited": + exited_count += 1 + if item.realized_pnl > 0: + exited_win_count += 1 + elif item.holding.status == "invalidated": + invalidated_count += 1 + + total_pnl = total_market_value - total_cost + total_pnl_pct = (total_pnl / total_cost * 100) if total_cost else 0 + win_rate_pct = (exited_win_count / exited_count * 100) if exited_count else 0 + return StrategyHoldingAnalysisResponse( + total_cost=total_cost, + total_market_value=total_market_value, + total_pnl=total_pnl, + total_pnl_pct=total_pnl_pct, + total_realized_pnl=total_realized_pnl, + holding_count=len(analyses), + active_count=active_count, + watching_count=watching_count, + planned_count=planned_count, + weakening_count=weakening_count, + exited_count=exited_count, + invalidated_count=invalidated_count, + win_rate_pct=round(win_rate_pct, 2), + average_score=round((total_score / scored_count) if scored_count else 0, 2), + todo_items=self._build_todo_items(analyses), + review_items=self._build_review_items(analyses), + holdings=analyses, + ) def _analyze_single_holding( self, @@ -2239,6 +2646,7 @@ def _analyze_single_holding( action_reason=action_reason, trigger_hits=trigger_hits, alerts=alerts[:3], + analysis_status="live", ) def _build_unavailable_holding_analysis( @@ -2268,6 +2676,15 @@ def _build_unavailable_holding_analysis( trigger_hits = self._detect_trade_plan_hits(holding, fallback_price, market_regime) thesis_status = "broken" if holding.status == "invalidated" else "weakening" if holding.status == "weakening" else "active" action_label, action_reason = self._build_unavailable_holding_action(holding) + fallback_candidate = self._score_candidate_quick( + {"code": holding.stock_code, "name": holding.stock_name}, + "market", + holding.source_topic, + ) + factor_notes = { + **fallback_candidate.factor_notes, + "data": "实时行情或新闻源暂不可用,当前使用本地快速兜底评分;请稍后手动刷新获取完整评分。", + } return StrategyHoldingAnalysis( holding=holding, @@ -2277,17 +2694,15 @@ def _build_unavailable_holding_analysis( pnl_pct=pnl_pct, realized_pnl=realized_pnl, realized_pnl_pct=realized_pnl_pct, - strategy_score=StrategyScoreBreakdown(c=0, a=0, n=0, s=0, l=0, i=0, m=0, total=0), + strategy_score=fallback_candidate.score, thesis_status=thesis_status, - factor_notes={ - "data": "实时行情或评分服务暂时不可用,当前先展示已保存的持仓记录。", - "plan": "交易计划字段和持仓状态已保留,可在数据恢复后继续刷新。", - }, + factor_notes=factor_notes, invalidation_reason="行情数据暂不可用,当前无法完整验证交易假设。" if holding.status == "invalidated" else None, action_label=action_label, action_reason=action_reason, trigger_hits=trigger_hits, alerts=alerts[:3], + analysis_status="degraded", ) @staticmethod @@ -2530,7 +2945,7 @@ def _score_candidate( market_regime: Optional[MarketRegimeResponse] = None, ) -> StrategyCandidate: analysis = self.stock_service.get_stock_analysis(candidate["code"], include_ai=False) - news_items = self.news_service.get_stock_news(candidate["code"], candidate["name"], limit=5) + news_items = self._get_stock_news_quick(candidate["code"], candidate["name"], limit=5) chart_series = analysis.chart_series[-120:] if analysis.chart_series else [] closes = [float(item["close"]) for item in chart_series if item.get("close") is not None] volumes = [float(item["volume"]) for item in chart_series if item.get("volume") is not None] @@ -2675,6 +3090,17 @@ def _score_candidate( }, ) + def _get_stock_news_quick(self, stock_code: str, stock_name: str, limit: int = 5) -> List[NewsItem]: + executor = ThreadPoolExecutor(max_workers=1) + future = executor.submit(self.news_service.get_stock_news, stock_code, stock_name, limit) + try: + return future.result(timeout=3) + except Exception: + future.cancel() + return [] + finally: + executor.shutdown(wait=False, cancel_futures=True) + def _build_institutional_note( self, news_items: List[NewsItem], diff --git a/app/api/[...path]/route.ts b/app/api/[...path]/route.ts index 5919598..8c13fbd 100644 --- a/app/api/[...path]/route.ts +++ b/app/api/[...path]/route.ts @@ -10,7 +10,7 @@ export const dynamic = "force-dynamic"; function isPublicCacheableGet(path: string[], searchParams: URLSearchParams) { if (path.length === 2 && path[0] === "stocks" && path[1] === "search") { - return !searchParams.has("request_id"); + return false; } if (path.length === 3 && path[0] === "stocks" && path[2] === "analysis") { return searchParams.get("include_ai") === "false"; diff --git a/app/charts/page.tsx b/app/charts/page.tsx index 3c707b0..ea72b99 100644 --- a/app/charts/page.tsx +++ b/app/charts/page.tsx @@ -1,143 +1,22 @@ "use client"; -import { useCallback, useState } from "react"; -import { CandlestickChart } from "@/components/candlestick-chart"; -import { getStockAnalysis, searchStocks } from "@/lib/api"; -import type { StockAnalysisResponse, StockSearchResult } from "@/lib/types"; - -type ChartPoint = { - date: string; - open: number | null; - high: number | null; - low: number | null; - close: number | null; - volume?: number | null; -}; - -export default function ChartsPage() { - const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); - const [analysis, setAnalysis] = useState(null); - const [loading, setLoading] = useState(false); - const [searching, setSearching] = useState(false); - - const onSearch = useCallback(() => { - const q = query.trim(); - if (!q) return; - setSearching(true); - searchStocks(q) - .then(setResults) - .catch(() => setResults([])) - .finally(() => setSearching(false)); - setAnalysis(null); - }, [query]); - - const onSelectStock = useCallback((code: string) => { - setLoading(true); - getStockAnalysis(code, { includeAi: false }) - .then(setAnalysis) - .catch(() => setAnalysis(null)) - .finally(() => setLoading(false)); - }, []); - - const chartData: ChartPoint[] = analysis?.chart_series ?? []; - const closeValues = chartData - .map((point) => point.close) - .filter((value): value is number => typeof value === "number" && !Number.isNaN(value)); - - return ( - <> -
-

K 线图

-

为重点标的快速拉出一张干净的日 K 线,配合价格与涨跌幅做节奏判断。

-
- setQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && onSearch()} - placeholder="输入股票名称或代码,例如 招商银行 / sh600036" - /> - -
-
- - {results.length > 0 && ( -
-

搜索结果

-
- {results.map((item) => ( - - ))} -
-
- )} - - {analysis && ( -
-

- {analysis.stock_name} ({analysis.stock_code}) — 日 K 线 -

-
-
-
最新价
- {analysis.quote.current_price.toFixed(2)} -
-
-
涨跌幅
- = 0 ? "signal-up" : "signal-down"}> - {analysis.quote.change_pct.toFixed(2)}% - -
-
- -
-
-
MACD
- {formatIndicator(analysis.technical_indicators.MACD)} -
-
-
DIF / DEA
- - {formatIndicator(analysis.technical_indicators.DIF)} /{" "} - {formatIndicator(analysis.technical_indicators.DEA)} - -
-
-
RSI
- {formatIndicator(analysis.technical_indicators.RSI)} -
-
-
K / D / J
- - {formatIndicator(analysis.technical_indicators.K)} /{" "} - {formatIndicator(analysis.technical_indicators.D)} /{" "} - {formatIndicator(analysis.technical_indicators.J)} - -
-
-
- )} - - ); -} - -function formatIndicator(value: number | null | undefined) { - if (value == null || Number.isNaN(value)) return "-"; - return value.toFixed(2); +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +// K 线图已并入「单股分析」页(panel=chart)。保留此路由仅用于兼容旧链接/书签, +// 自动重定向到单股分析的 K 线视图。 +export default function ChartsRedirect() { + const router = useRouter(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const query = params.get("query"); + router.replace( + query + ? `/stocks?query=${encodeURIComponent(query)}&panel=chart#chart` + : "/stocks", + ); + }, [router]); + + return

正在跳转到单股分析…

; } diff --git a/app/globals.css b/app/globals.css index 17f0e11..fa9387a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1401,6 +1401,12 @@ button, input, textarea { font: inherit; } background: rgba(19, 19, 19, 0.04); border-color: var(--line); } +.nav-links a.active { + border-color: rgba(15, 138, 123, 0.26); + background: rgba(15, 138, 123, 0.08); + color: var(--accent); + font-weight: 700; +} .nav-links button { border: 1px solid var(--line); background: rgba(255, 255, 255, 0.88); @@ -3076,6 +3082,87 @@ textarea.input { padding-top: 16px; } +.portfolio-action-strip, +.portfolio-snapshot-action { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin: 12px 0 16px; + padding: 14px; + border: 1px solid rgba(15, 138, 123, 0.18); + border-radius: 16px; + background: + linear-gradient(135deg, rgba(15, 138, 123, 0.08), rgba(255, 255, 255, 0.84)), + rgba(255, 255, 255, 0.72); +} + +.portfolio-action-strip p { + margin: 4px 0 0; +} + +.portfolio-action-kicker, +.portfolio-snapshot-action span { + display: block; + margin-bottom: 4px; + color: var(--accent); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.portfolio-action-list { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.portfolio-action-chip { + display: grid; + gap: 2px; + min-width: 118px; + padding: 9px 11px; + border: 1px solid rgba(19, 19, 19, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.78); + color: var(--ink); + text-align: left; + cursor: pointer; +} + +.portfolio-action-chip span { + color: var(--muted); + font-size: 0.72rem; +} + +.portfolio-action-chip strong { + color: var(--ink); + font-size: 0.86rem; +} + +.portfolio-action-chip:hover { + border-color: rgba(15, 138, 123, 0.24); + background: rgba(255, 255, 255, 0.96); +} + +.portfolio-entry-compact { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-top: 12px; + padding: 12px 14px; + border: 1px dashed rgba(19, 19, 19, 0.14); + border-radius: 14px; + background: rgba(255, 255, 255, 0.62); +} + +.portfolio-entry-compact p { + margin: 0; +} + .news-warning-strip { border-color: rgba(194, 91, 58, 0.18); background: linear-gradient(180deg, rgba(194, 91, 58, 0.06), rgba(255, 255, 255, 0.94)); @@ -3175,18 +3262,26 @@ textarea.input { .hotspot-detail-panel { display: flex; flex-direction: column; - min-height: clamp(680px, calc(100vh - var(--nav-height) - 96px), 980px); + min-height: 0; } .hotspot-board-scroll, .hotspot-detail-scroll { flex: 1; min-height: 0; - overflow: auto; padding-right: 6px; margin-right: -6px; } +.hotspot-board-scroll { + max-height: min(720px, calc(100vh - var(--nav-height) - 180px)); + overflow: auto; +} + +.hotspot-detail-scroll { + overflow: visible; +} + .news-summary-panel, .news-lead-panel, .hotspot-rank-panel, @@ -3516,6 +3611,20 @@ textarea.input { margin-top: 18px; } +.news-empty-state { + display: grid; + gap: 14px; + margin-top: 16px; + padding: 16px 18px; + border: 1px dashed rgba(17, 22, 29, 0.14); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.64); +} + +.news-empty-state p { + margin: 0; +} + .news-summary-panel .news-action-row { padding-top: 2px; } @@ -3838,6 +3947,7 @@ textarea.input { } .hotspot-trend-pill, +.hotspot-attention-pill, .hotspot-stock-count { display: inline-flex; align-items: center; @@ -3852,6 +3962,25 @@ textarea.input { border: 1px solid rgba(0, 0, 0, 0.06); } +.hotspot-attention-pill { + border: 1px solid rgba(0, 0, 0, 0.06); +} + +.hotspot-attention-focus { + background: rgba(13, 148, 136, 0.11); + color: #0f766e; +} + +.hotspot-attention-caution { + background: rgba(194, 91, 58, 0.12); + color: #b4532a; +} + +.hotspot-attention-watch { + background: rgba(17, 22, 29, 0.08); + color: #374151; +} + .hotspot-trend-pill-up { background: rgba(194, 91, 58, 0.1); color: #b4532a; @@ -3904,6 +4033,58 @@ textarea.input { border: 1px solid rgba(17, 22, 29, 0.08); } +.hotspot-basis-box { + display: grid; + gap: 12px; + padding: 16px 18px; + border-radius: var(--radius-md); + border: 1px solid rgba(15, 138, 123, 0.14); + background: + linear-gradient(135deg, rgba(15, 138, 123, 0.07), transparent 44%), + rgba(255, 255, 255, 0.72); +} + +.hotspot-basis-box h3 { + margin: 0; +} + +.hotspot-compact-section, +.hotspot-compact-details { + border: 1px solid rgba(17, 22, 29, 0.08); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.72); +} + +.hotspot-compact-toggle, +.hotspot-compact-details summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + width: 100%; + padding: 14px 16px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + list-style: none; + text-align: left; +} + +.hotspot-compact-details summary::-webkit-details-marker { + display: none; +} + +.hotspot-compact-toggle h3, +.hotspot-compact-details summary h3 { + margin: 0; + font-size: 1rem; +} + +.hotspot-compact-body { + padding: 0 16px 16px; +} + .hotspot-metric { padding: 16px 18px; border-radius: var(--radius-md); @@ -3930,14 +4111,33 @@ textarea.input { .stock-link-card { display: grid; - gap: 14px; - padding: 18px; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 12px 14px; border-radius: var(--radius-md); border: 1px solid rgba(0, 0, 0, 0.06); background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 246, 242, 0.98)); transition: transform 0.16s ease, box-shadow 0.16s ease; } +.stock-link-card p { + overflow: hidden; + margin-top: 4px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stock-link-card .inline-actions { + justify-content: flex-end; +} + +.stock-link-card .button { + min-height: 30px; + padding: 6px 9px; + font-size: 0.8rem; +} + .strategy-factor-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); @@ -4096,6 +4296,81 @@ textarea.input { align-items: start; } +.stocks-focus-bar { + position: sticky; + top: calc(var(--nav-height) + 8px); + z-index: 12; + display: grid; + grid-template-columns: minmax(220px, 1fr) auto auto; + align-items: center; + gap: 14px; + margin: 0 0 16px; + padding: 12px 14px; + border: 1px solid rgba(15, 138, 123, 0.18); + border-radius: 16px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(246, 242, 236, 0.94)), + rgba(255, 255, 255, 0.9); + box-shadow: 0 16px 38px rgba(25, 22, 18, 0.08); + backdrop-filter: blur(14px); +} + +.stocks-focus-main { + display: grid; + gap: 3px; + min-width: 0; +} + +.stocks-focus-main strong { + overflow: hidden; + color: var(--ink); + font-size: 1.02rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stocks-focus-market { + width: fit-content; + color: var(--accent); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.stocks-focus-metrics { + display: flex; + align-items: center; + gap: 10px; + white-space: nowrap; +} + +.stocks-focus-metrics span:first-child { + color: var(--muted); + font-size: 0.86rem; +} + +.stocks-focus-metrics strong { + margin-left: 4px; + color: var(--ink); + font-size: 1.06rem; +} + +.stocks-focus-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.stocks-focus-actions .button, +.stocks-focus-actions a { + min-height: 34px; + padding: 7px 11px; + font-size: 0.82rem; +} + .stocks-tech-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; @@ -4238,6 +4513,7 @@ textarea.input { } .portfolio-holding-card { + scroll-margin-top: calc(var(--nav-height) + 16px); border-radius: 16px; background: radial-gradient(circle at 96% -8%, rgba(15, 138, 123, 0.1), transparent 36%), @@ -4249,6 +4525,14 @@ textarea.input { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.portfolio-holding-grid[data-layout="single"] { + grid-template-columns: minmax(0, 1fr); +} + +.portfolio-holding-grid[data-layout="pair"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .portfolio-holding-card h3 { margin-bottom: 8px; font-size: 1.14rem; @@ -4503,10 +4787,22 @@ textarea.input { } .detail-news-item { - padding: 16px 0; + padding: 11px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.06); } +.detail-news-item h4 { + margin-top: 6px; + font-size: 0.96rem; +} + +.detail-news-item p { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .detail-news-item:last-child { border-bottom: none; padding-bottom: 0; @@ -4739,6 +5035,42 @@ textarea.input { .nav-demo-link { display: block; } + .portfolio-action-strip, + .portfolio-snapshot-action { + align-items: stretch; + flex-direction: column; + } + .portfolio-action-list { + justify-content: flex-start; + } + .portfolio-action-chip { + width: 100%; + } + .portfolio-entry-compact { + align-items: stretch; + flex-direction: column; + } + .stock-link-card { + grid-template-columns: 1fr; + } + .stock-link-card .inline-actions { + justify-content: flex-start; + } + .stocks-focus-bar { + position: static; + grid-template-columns: 1fr; + gap: 10px; + } + .stocks-focus-metrics, + .stocks-focus-actions { + justify-content: flex-start; + } + .stocks-focus-actions .button, + .stocks-focus-actions a { + flex: 1 1 96px; + justify-content: center; + text-align: center; + } .main-content { padding: 10px 10px 22px; } @@ -4770,7 +5102,10 @@ textarea.input { .ai-analysis-grid { grid-template-columns: 1fr; } - .portfolio-holding-grid { + .portfolio-holding-grid, + .portfolio-holding-grid[data-layout="single"], + .portfolio-holding-grid[data-layout="pair"], + .portfolio-holding-grid[data-layout="many"] { grid-template-columns: 1fr; } .hero-grid { diff --git a/app/hotspots/hotspots-page-client.tsx b/app/hotspots/hotspots-page-client.tsx index 53fe6d5..99ba627 100644 --- a/app/hotspots/hotspots-page-client.tsx +++ b/app/hotspots/hotspots-page-client.tsx @@ -26,6 +26,8 @@ export function HotspotsPageClient() { const [marketRegimeError, setMarketRegimeError] = useState(null); const [error, setError] = useState(null); const [manualRefreshing, setManualRefreshing] = useState(false); + const [strategyPanelOpen, setStrategyPanelOpen] = useState(false); + const [historyPanelOpen, setHistoryPanelOpen] = useState(false); const detailRequestRef = useRef(0); const screeningRequestRef = useRef(0); const screeningCarouselRef = useRef(null); @@ -138,6 +140,11 @@ export function HotspotsPageClient() { loadDetail(selectedTopic.topic_name); }, [selectedTopic?.topic_name, loadDetail]); + useEffect(() => { + setStrategyPanelOpen(false); + setHistoryPanelOpen(false); + }, [selectedTopic?.topic_name]); + useEffect(() => { if (screenScope === "hotspot" && selectedTopic?.topic_name) { loadScreening("hotspot", selectedTopic.topic_name); @@ -210,7 +217,7 @@ export function HotspotsPageClient() {
Sector Radar

热点板块

- 从板块视角梳理当前市场最热的交易方向,结合代表个股、催化消息和热度变化,帮你快速锁定值得跟踪的主题。 + 按交易活跃、讨论度和消息催化排序,先看哪些值得关注,哪些需要警惕。

@@ -257,6 +264,7 @@ export function HotspotsPageClient() { {sortedHotspots.map((item, index) => { const active = item.topic_name === selectedTopic?.topic_name; const linkedStocks = summarizeLinkedStocks(item); + const breakdown = normalizeHotspotBreakdown(item); return (
- Sector Pulse + Market Attention

{item.topic_name}

- 热度 + 综合 {item.heat_score.toFixed(0)}
-

{truncate(item.ai_summary || item.reason, 120)}

+

{truncate(item.ai_summary || item.reason, 86)}

- - {trendLabel(item.trend_direction)} + + {attentionLabel(breakdown.attention_level)} + + + 交易 {breakdown.trading_activity.toFixed(1)} + + + 讨论 {breakdown.discussion_heat.toFixed(1)} 代表股 {linkedStocks.visible.length + linkedStocks.extraCount} @@ -333,32 +347,50 @@ export function HotspotsPageClient() { {detailLoading ?

详情更新中,先展示上一版内容…

: null}
-

{currentDetail.topic.ai_summary || currentDetail.topic.reason}

+

{truncate(currentDetail.topic.ai_summary || currentDetail.topic.reason, 128)}

- 热度趋势 - {trendLabel(currentDetail.topic.trend_direction)} + 关注判断 + {attentionLabel(normalizeHotspotBreakdown(currentDetail.topic).attention_level)} +
+
+ 交易活跃 + {normalizeHotspotBreakdown(currentDetail.topic).trading_activity.toFixed(1)}
- 关联个股 - {currentDetail.topic.related_stocks.length} + 讨论热度 + {normalizeHotspotBreakdown(currentDetail.topic).discussion_heat.toFixed(1)}
- 催化消息 - {currentDetail.related_news.length} + 消息催化 + {normalizeHotspotBreakdown(currentDetail.topic).news_heat.toFixed(1)}
+ {normalizeHotspotBreakdown(currentDetail.topic).basis.length ? ( +
+
+

为什么排在这里

+ 交易活跃 + 讨论度 + 消息催化 +
+
+ {normalizeHotspotBreakdown(currentDetail.topic).basis.slice(0, 2).map((item) => ( + {item} + ))} +
+
+ ) : null} +
-

板块关联个股

- 板块内优先关注的代表标的 +

代表股

+ 先看前三只
{currentDetail.topic.related_stocks.length ? (
- {currentDetail.topic.related_stocks.map((stock) => ( + {currentDetail.topic.related_stocks.slice(0, 3).map((stock) => (
@@ -383,18 +415,32 @@ export function HotspotsPageClient() {
))} + {currentDetail.topic.related_stocks.length > 3 ? ( +

其余 {currentDetail.topic.related_stocks.length - 3} 只已收起,先处理上面的代表股。

+ ) : null}
) : (

暂无关联股票。

)}
-
-
+
+ + {strategyPanelOpen ? ( +
+
+ 默认先看热点内筛选,再切到全市场候选。
+ ) : null}
-

板块催化消息

- 只保留标题、来源、摘要和影响级别 +

最新催化

+ 只显示最相关 1 条
{currentDetail.related_news.length ? (
- {currentDetail.related_news.slice(0, 6).map((item) => ( + {currentDetail.related_news.slice(0, 1).map((item) => (
{item.source} @@ -543,33 +591,48 @@ export function HotspotsPageClient() { 影响 {item.impact_level}

{item.title}

-

{truncate(item.summary, 180)}

+

{truncate(item.summary, 120)}

))} + {currentDetail.related_news.length > 1 ? ( +

其余 {currentDetail.related_news.length - 1} 条催化消息已收起。

+ ) : null}
) : (

当前没有抓到与该板块直接相关的最新消息。

)}
-
-
-

板块热度快照

- 看板块热度是否持续升温 -
- {currentDetail.history.length ? ( -
- {currentDetail.history.map((item) => ( -
- {item.score.toFixed(0)} - {item.date} - {item.count} 次触发 -
- ))} +
+ + {historyPanelOpen ? ( +
+ {currentDetail.history.length ? ( +
+ {currentDetail.history.map((item) => ( +
+ {item.score.toFixed(0)} + {item.date} + {item.count} 次触发 +
+ ))} +
+ ) : ( +

暂无可用历史快照。

+ )} +
+ ) : null}
@@ -620,12 +683,6 @@ function formatDateLabel(value: string) { }).format(date); } -function trendLabel(direction: HotspotItem["trend_direction"]) { - if (direction === "up") return "升温"; - if (direction === "down") return "降温"; - return "平稳"; -} - function summarizeLinkedStocks(item: HotspotItem) { const seenNames = new Set(); const visible = item.related_stocks.filter((stock) => { @@ -642,6 +699,24 @@ function summarizeLinkedStocks(item: HotspotItem) { }; } +function normalizeHotspotBreakdown(item: HotspotItem) { + return { + trading_activity: item.heat_breakdown?.trading_activity ?? 0, + discussion_heat: item.heat_breakdown?.discussion_heat ?? 0, + news_heat: item.heat_breakdown?.news_heat ?? 0, + alert_count: item.heat_breakdown?.alert_count ?? 0, + rank_hits: item.heat_breakdown?.rank_hits ?? 0, + attention_level: item.heat_breakdown?.attention_level ?? "watch", + basis: item.heat_breakdown?.basis ?? [], + }; +} + +function attentionLabel(level: HotspotItem["heat_breakdown"]["attention_level"]) { + if (level === "focus") return "重点关注"; + if (level === "caution") return "提高警惕"; + return "先观察"; +} + function candidateKey(candidate: StrategyCandidate) { return `${candidate.source_scope}-${candidate.source_topic ?? "market"}-${candidate.stock_code}`; } diff --git a/app/news/news-page-client.tsx b/app/news/news-page-client.tsx index 2745948..b4e7018 100644 --- a/app/news/news-page-client.tsx +++ b/app/news/news-page-client.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DemoAccessGate } from "@/components/demo-access-gate"; import { useDemoAccess } from "@/components/demo-access-provider"; @@ -130,12 +131,11 @@ export function NewsPageClient() { const refreshPage = useCallback(() => { setManualRefreshing(true); - const cleanup = loadNews(); + loadNews(); if (unlocked) { loadAgent(); } window.setTimeout(() => { - cleanup(); setManualRefreshing(false); }, 400); }, [loadAgent, loadNews, unlocked]); @@ -347,7 +347,7 @@ export function NewsPageClient() {
Lead Story

全球重点新闻

- {globalNews.length ? `${globalNews.length} 条` : "暂无数据"} + {globalNews.length ? `${globalNews.length} 条` : "新闻源为空"}
{leadNews ? ( @@ -379,7 +379,19 @@ export function NewsPageClient() { ) : ( -

当前没有可展示的全球新闻。

+
+

+ 当前外部新闻源没有返回可展示内容,可能是数据源限流、网络波动,或本地监控缓存暂时为空。 +

+
+ + + 先看热点板块 + +
+
)} {secondaryNews.length ? ( diff --git a/ashare/config.py b/ashare/config.py index 3d4aa61..a4b24c4 100644 --- a/ashare/config.py +++ b/ashare/config.py @@ -99,6 +99,9 @@ class Config: web_search_enabled: bool = field(default_factory=lambda: _env_bool("WEB_SEARCH_ENABLED", True)) web_search_timeout: int = field(default_factory=lambda: _env_int("WEB_SEARCH_TIMEOUT", 8)) + # 美股数据源:Finnhub 作为 yfinance 的备选源(缺失时自动跳过,仅用 yfinance) + finnhub_api_key: Optional[str] = field(default_factory=lambda: _env_text("FINNHUB_API_KEY")) + def __post_init__(self) -> None: self.stock_pool_path = Path(self.stock_pool_path) self.stock_topics_path = Path(self.stock_topics_path) diff --git a/ashare/data.py b/ashare/data.py index e24823b..4d8485f 100644 --- a/ashare/data.py +++ b/ashare/data.py @@ -7,7 +7,15 @@ import pandas as pd from typing import Dict, List, Optional from ashare.logging import get_logger -from ashare.stock_pool import get_exchange_label, is_valid_stock_code, normalize_stock_code +from ashare.stock_pool import ( + extract_symbol, + get_exchange_label, + is_us_stock, + is_valid_stock_code, + normalize_stock_code, +) + +logger = get_logger(__name__) # 尝试导入数据源(pip 的 ashare 包可能与项目根目录 Ashare.py 冲突,用文件路径显式加载) as_api = None @@ -35,7 +43,7 @@ def _load_ashare_module(): if hasattr(mod, "get_price"): return mod except Exception as ex: - print(f"警告: 加载 {path} 失败: {ex}") + logger.warning("加载 %s 失败: %s", path, ex) return None as_api = _load_ashare_module() @@ -46,12 +54,10 @@ def _load_ashare_module(): as_api = None except Exception as e: as_api = None - print(f"警告: 无法加载Ashare数据源模块: {e}") + logger.warning("无法加载 Ashare 数据源模块: %s", e) if as_api is None: - print("警告: as_api 未就绪,将使用 akshare 作为日线数据源备选") - -logger = get_logger(__name__) + logger.warning("as_api 未就绪,将使用 akshare 作为日线数据源备选") class DataFetcher: @@ -93,14 +99,18 @@ def fetch_stock_data(self, code: str, count: Optional[int] = None, try: logger.info(f"正在获取股票 {code} 的数据...") df = None - if as_api is not None and hasattr(as_api, "get_price"): - try: - df = as_api.get_price(code, count=count, frequency=frequency) - except Exception as ex: - logger.warning("Ashare.get_price failed for %s: %s", code, ex) - df = None - if (df is None or (isinstance(df, pd.DataFrame) and df.empty)) and frequency == "1d": - df = self._fetch_stock_data_akshare(code, count) + if is_us_stock(code): + # 美股走独立链路:yfinance 主源,Finnhub 备选 + df = self._fetch_stock_data_us(code, count, frequency) + else: + if as_api is not None and hasattr(as_api, "get_price"): + try: + df = as_api.get_price(code, count=count, frequency=frequency) + except Exception as ex: + logger.warning("Ashare.get_price failed for %s: %s", code, ex) + df = None + if (df is None or (isinstance(df, pd.DataFrame) and df.empty)) and frequency == "1d": + df = self._fetch_stock_data_akshare(code, count) # 检查数据是否有效 if df is None or df.empty: @@ -197,6 +207,145 @@ def get_cache_info(self) -> Dict[str, int]: 'cache_keys': list(self.data_cache.keys()) } + # ------------------------------------------------------------------ + # 美股数据源 + # ------------------------------------------------------------------ + # yfinance 频率映射 + _YF_INTERVAL = { + "1d": "1d", "1w": "1wk", "1M": "1mo", + "60m": "60m", "30m": "30m", "15m": "15m", "5m": "5m", "1m": "1m", + } + # Finnhub 频率映射(resolution) + _FINNHUB_RESOLUTION = { + "1d": "D", "1w": "W", "1M": "M", + "60m": "60", "30m": "30", "15m": "15", "5m": "5", "1m": "1", + } + # 估算需要回溯的自然日数(按频率把 count 转成日历跨度,留足非交易日冗余) + _LOOKBACK_DAYS_PER_BAR = { + "1d": 2, "1w": 9, "1M": 40, + "60m": 1, "30m": 1, "15m": 1, "5m": 1, "1m": 1, + } + + @staticmethod + def _us_yf_symbol(code: str) -> str: + """US.BRK.B -> BRK-B(yfinance 用 ``-`` 表示类别股)。""" + return extract_symbol(code).replace(".", "-") + + def _fetch_stock_data_us(self, code: str, count: int, frequency: str) -> Optional[pd.DataFrame]: + """美股行情:yfinance 主源,失败时降级到 Finnhub。""" + df = self._fetch_stock_data_yfinance(code, count, frequency) + if df is not None and not df.empty: + return df + logger.info("yfinance 未取到 %s 数据,尝试 Finnhub 备选源", code) + return self._fetch_stock_data_finnhub(code, count, frequency) + + def _fetch_stock_data_yfinance(self, code: str, count: int, frequency: str) -> Optional[pd.DataFrame]: + try: + import yfinance as yf + except ImportError: + logger.warning("yfinance 未安装,无法获取美股数据") + return None + + interval = self._YF_INTERVAL.get(frequency) + if interval is None: + logger.warning("yfinance 不支持的频率: %s", frequency) + return None + + symbol = self._us_yf_symbol(code) + lookback = max(7, count * self._LOOKBACK_DAYS_PER_BAR.get(frequency, 2)) + start = (pd.Timestamp.now() - pd.Timedelta(days=lookback)).strftime("%Y-%m-%d") + end = (pd.Timestamp.now() + pd.Timedelta(days=1)).strftime("%Y-%m-%d") + try: + raw = yf.download( + symbol, start=start, end=end, interval=interval, + auto_adjust=True, progress=False, threads=False, + ) + except Exception as e: + logger.warning("yfinance download failed %s: %s", symbol, e) + return None + return self._normalize_us_dataframe(raw, count, source=f"yfinance({symbol})") + + def _normalize_us_dataframe(self, raw, count: int, source: str) -> Optional[pd.DataFrame]: + if raw is None or raw.empty: + return None + df = raw.copy() + # yfinance 多 ticker 时返回 MultiIndex 列,单 ticker 也可能带一层,统一压平 + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(0) + col_map = {"Open": "open", "Close": "close", "High": "high", "Low": "low", "Volume": "volume"} + rename = {k: v for k, v in col_map.items() if k in df.columns} + df = df.rename(columns=rename) + for c in ["open", "close", "high", "low", "volume"]: + if c not in df.columns: + logger.warning("%s 缺少必要列 %s", source, c) + return None + df = df[["open", "close", "high", "low", "volume"]].astype(float) + df.index = pd.to_datetime(df.index) + df.index.name = "" + df.columns.name = None + df = df.dropna().tail(count) + if df.empty: + return None + logger.info("%s 成功,共 %d 条", source, len(df)) + return df + + def _fetch_stock_data_finnhub(self, code: str, count: int, frequency: str) -> Optional[pd.DataFrame]: + try: + from ashare.config import Config + except ImportError: + Config = None + api_key = None + if Config is not None: + try: + api_key = Config().finnhub_api_key + except Exception: + api_key = None + if not api_key: + logger.info("未配置 FINNHUB_API_KEY,跳过 Finnhub 备选源") + return None + + resolution = self._FINNHUB_RESOLUTION.get(frequency) + if resolution is None: + logger.warning("Finnhub 不支持的频率: %s", frequency) + return None + + symbol = extract_symbol(code).replace(".", "-") + lookback = max(7, count * self._LOOKBACK_DAYS_PER_BAR.get(frequency, 2)) + to_ts = int(pd.Timestamp.now().timestamp()) + from_ts = int((pd.Timestamp.now() - pd.Timedelta(days=lookback)).timestamp()) + try: + import requests + resp = requests.get( + "https://finnhub.io/api/v1/stock/candle", + params={"symbol": symbol, "resolution": resolution, + "from": from_ts, "to": to_ts, "token": api_key}, + timeout=10, + ) + resp.raise_for_status() + payload = resp.json() + except Exception as e: + logger.warning("Finnhub request failed %s: %s", symbol, e) + return None + + if not isinstance(payload, dict) or payload.get("s") != "ok": + logger.warning("Finnhub 返回无效数据 %s: status=%s", symbol, payload.get("s") if isinstance(payload, dict) else payload) + return None + try: + df = pd.DataFrame({ + "open": payload["o"], "close": payload["c"], + "high": payload["h"], "low": payload["l"], + "volume": payload["v"], + }, index=pd.to_datetime(payload["t"], unit="s")).astype(float) + except (KeyError, ValueError, TypeError) as e: + logger.warning("Finnhub 数据解析失败 %s: %s", symbol, e) + return None + df.index.name = "" + df = df.dropna().tail(count) + if df.empty: + return None + logger.info("Finnhub(%s) 成功,共 %d 条", symbol, len(df)) + return df + def _fetch_stock_data_akshare(self, code: str, count: int) -> Optional[pd.DataFrame]: """ akshare 日线备选:当 Ashare.get_price 不可用时使用。 diff --git a/ashare/llm.py b/ashare/llm.py index 6d30967..d73d52d 100644 --- a/ashare/llm.py +++ b/ashare/llm.py @@ -1,4 +1,5 @@ import json +import logging import os from typing import Callable, Dict, Any, Optional @@ -7,6 +8,8 @@ from openai import OpenAI import re +logger = logging.getLogger(__name__) + def format_analysis_result(result: Dict[str, Any]) -> Dict[str, Any]: """ @@ -396,21 +399,21 @@ def request_analysis(self, df: pd.DataFrame, technical_indicators: pd.DataFrame) """ try: # 准备数据 - print("开始准备数据...") + logger.debug("开始准备数据...") data_str = _format_data_for_prompt(df, technical_indicators) - print(f"数据准备完成,数据长度: {len(data_str)}") + logger.debug(f"数据准备完成,数据长度: {len(data_str)}") # 构建消息 - print("构建API请求消息...") + logger.debug("构建API请求消息...") messages = [ {"role": "system", "content": _create_system_prompt()}, {"role": "user", "content": f"请分析以下股票数据并给出专业的分析意见:\n{data_str}"} ] - print(f"消息构建完成,系统提示词长度: {len(messages[0]['content'])}") - print(f"用户消息长度: {len(messages[1]['content'])}") + logger.debug(f"消息构建完成,系统提示词长度: {len(messages[0]['content'])}") + logger.debug(f"用户消息长度: {len(messages[1]['content'])}") # 发送请求 - print("开始发送API请求...") + logger.debug("开始发送API请求...") try: response = self.client.chat.completions.create( model=self.model, @@ -418,85 +421,85 @@ def request_analysis(self, df: pd.DataFrame, technical_indicators: pd.DataFrame) temperature=1.0, stream=False ) - print("API请求发送成功") + logger.debug("API请求发送成功") except Exception as api_e: # 检查是否是空响应导致的JSON解析错误 if str(api_e).startswith("Expecting value: line 1 column 1 (char 0)"): - print("API返回空响应,服务器可能繁忙") + logger.debug("API返回空响应,服务器可能繁忙") raise APIBusyError("API服务器繁忙,返回空响应") from api_e - print(f"API请求发送失败: {str(api_e)}") + logger.debug(f"API请求发送失败: {str(api_e)}") raise # 重新抛出其他类型的异常 # 记录原始响应以便调试 - print("API 原始响应类型:", type(response)) - print("API 原始响应内容:", response) + logger.debug("API 原始响应类型:", type(response)) + logger.debug("API 原始响应内容:", response) # 检查响应内容 if not response: - print("API返回空响应") + logger.debug("API返回空响应") return format_analysis_result({}) if not hasattr(response, 'choices'): - print(f"API响应缺少choices属性,响应结构: {dir(response)}") + logger.debug(f"API响应缺少choices属性,响应结构: {dir(response)}") return format_analysis_result({}) if not response.choices: - print("API响应的choices为空") + logger.debug("API响应的choices为空") return format_analysis_result({}) # 解析响应 try: analysis_text = response.choices[0].message.content - print("成功获取分析文本内容") - print("分析文本:", analysis_text) + logger.debug("成功获取分析文本内容") + logger.debug("分析文本:", analysis_text) except Exception as text_e: - print(f"获取分析文本失败: {str(text_e)}") + logger.debug(f"获取分析文本失败: {str(text_e)}") raise # 将文本响应组织成结构化数据 - print("开始解析分析文本...") + logger.debug("开始解析分析文本...") result = _parse_analysis_response(analysis_text) - print("分析文本解析完成") + logger.debug("分析文本解析完成") return result except APIBusyError as be: # 处理API繁忙异常 - print(f"=== API繁忙错误 ===") - print(f"错误详情: {str(be)}") - print(f"错误类型: {type(be)}") + logger.debug(f"=== API繁忙错误 ===") + logger.debug(f"错误详情: {str(be)}") + logger.debug(f"错误类型: {type(be)}") return format_analysis_result({}) except json.JSONDecodeError as je: - print(f"=== JSON解析错误 ===") - print(f"错误详情: {str(je)}") - print(f"错误类型: {type(je)}") - print(f"错误位置: {je.pos}") - print(f"错误行列: 行 {je.lineno}, 列 {je.colno}") - print(f"错误的文档片段: {je.doc[:100] if je.doc else 'None'}") + logger.debug(f"=== JSON解析错误 ===") + logger.debug(f"错误详情: {str(je)}") + logger.debug(f"错误类型: {type(je)}") + logger.debug(f"错误位置: {je.pos}") + logger.debug(f"错误行列: 行 {je.lineno}, 列 {je.colno}") + logger.debug(f"错误的文档片段: {je.doc[:100] if je.doc else 'None'}") return format_analysis_result({}) except openai.APITimeoutError as te: - print(f"=== API超时错误 ===") - print(f"错误详情: {str(te)}") - print(f"错误类型: {type(te)}") + logger.debug(f"=== API超时错误 ===") + logger.debug(f"错误详情: {str(te)}") + logger.debug(f"错误类型: {type(te)}") return format_analysis_result({}) except openai.APIConnectionError as ce: - print(f"=== API连接错误 ===") - print(f"错误详情: {str(ce)}") - print(f"错误类型: {type(ce)}") + logger.debug(f"=== API连接错误 ===") + logger.debug(f"错误详情: {str(ce)}") + logger.debug(f"错误类型: {type(ce)}") return format_analysis_result({}) except openai.APIError as ae: - print(f"=== API错误 ===") - print(f"错误详情: {str(ae)}") - print(f"错误类型: {type(ae)}") + logger.debug(f"=== API错误 ===") + logger.debug(f"错误详情: {str(ae)}") + logger.debug(f"错误类型: {type(ae)}") return format_analysis_result({}) except openai.RateLimitError as re: - print(f"=== API频率限制错误 ===") - print(f"错误详情: {str(re)}") - print(f"错误类型: {type(re)}") + logger.debug(f"=== API频率限制错误 ===") + logger.debug(f"错误详情: {str(re)}") + logger.debug(f"错误类型: {type(re)}") return format_analysis_result({}) except Exception as e: - print(f"=== 未预期的错误 ===") - print(f"错误详情: {str(e)}") - print(f"错误类型: {type(e)}") - print(f"错误追踪:") + logger.debug(f"=== 未预期的错误 ===") + logger.debug(f"错误详情: {str(e)}") + logger.debug(f"错误类型: {type(e)}") + logger.debug(f"错误追踪:") import traceback traceback.print_exc() return format_analysis_result({}) @@ -512,7 +515,7 @@ def generate_pool_analysis(self, pool_summary: list) -> Optional[str]: AI分析文本,失败时返回None """ try: - print("开始生成股票池AI分析...") + logger.debug("开始生成股票池AI分析...") # 构建股票池分析的提示词 pool_info = "股票池综合分析:\n\n" @@ -553,7 +556,7 @@ def generate_pool_analysis(self, pool_summary: list) -> Optional[str]: {"role": "user", "content": pool_info} ] - print("发送股票池分析请求...") + logger.debug("发送股票池分析请求...") response = self.client.chat.completions.create( model=self.model, messages=messages, @@ -563,14 +566,14 @@ def generate_pool_analysis(self, pool_summary: list) -> Optional[str]: if response.choices and response.choices[0].message: analysis_text = response.choices[0].message.content.strip() - print("股票池AI分析生成成功") + logger.debug("股票池AI分析生成成功") return analysis_text else: - print("股票池AI分析响应为空") + logger.debug("股票池AI分析响应为空") return None except Exception as e: - print(f"生成股票池AI分析失败: {str(e)}") + logger.debug(f"生成股票池AI分析失败: {str(e)}") return None def generate_fundamental_analysis(self, fundamental_data: list, analysis_params: Dict[str, Any]) -> Optional[str]: @@ -585,7 +588,7 @@ def generate_fundamental_analysis(self, fundamental_data: list, analysis_params: 基本面分析文本,失败时返回None """ try: - print(f"开始生成基本面分析: {analysis_params['type']}") + logger.debug(f"开始生成基本面分析: {analysis_params['type']}") # 构建基本面分析的数据信息 fundamental_info = f"基本面分析 - {analysis_params['type']}:\n\n" @@ -646,7 +649,7 @@ def generate_fundamental_analysis(self, fundamental_data: list, analysis_params: {"role": "user", "content": fundamental_info} ] - print("发送基本面分析请求...") + logger.debug("发送基本面分析请求...") response = self.client.chat.completions.create( model=self.model, messages=messages, @@ -656,14 +659,14 @@ def generate_fundamental_analysis(self, fundamental_data: list, analysis_params: if response.choices and response.choices[0].message: analysis_text = response.choices[0].message.content.strip() - print("基本面分析生成成功") + logger.debug("基本面分析生成成功") return analysis_text else: - print("基本面分析响应为空") + logger.debug("基本面分析响应为空") return None except Exception as e: - print(f"生成基本面分析失败: {str(e)}") + logger.debug(f"生成基本面分析失败: {str(e)}") return None def generate_sector_rotation_analysis(self, sector_data: list) -> Optional[str]: @@ -677,7 +680,7 @@ def generate_sector_rotation_analysis(self, sector_data: list) -> Optional[str]: 板块轮动分析文本,失败时返回None """ try: - print("开始生成板块轮动分析...") + logger.debug("开始生成板块轮动分析...") # 构建板块轮动分析数据 sector_info = "板块轮动分析:\n\n" @@ -720,7 +723,7 @@ def generate_sector_rotation_analysis(self, sector_data: list) -> Optional[str]: {"role": "user", "content": sector_info} ] - print("发送板块轮动分析请求...") + logger.debug("发送板块轮动分析请求...") response = self.client.chat.completions.create( model=self.model, messages=messages, @@ -730,14 +733,14 @@ def generate_sector_rotation_analysis(self, sector_data: list) -> Optional[str]: if response.choices and response.choices[0].message: analysis_text = response.choices[0].message.content.strip() - print("板块轮动分析生成成功") + logger.debug("板块轮动分析生成成功") return analysis_text else: - print("板块轮动分析响应为空") + logger.debug("板块轮动分析响应为空") return None except Exception as e: - print(f"生成板块轮动分析失败: {str(e)}") + logger.debug(f"生成板块轮动分析失败: {str(e)}") return None def generate_trend_strength_analysis(self, trend_data: list) -> Optional[str]: @@ -751,7 +754,7 @@ def generate_trend_strength_analysis(self, trend_data: list) -> Optional[str]: 趋势强度分析文本,失败时返回None """ try: - print("开始生成趋势强度分析...") + logger.debug("开始生成趋势强度分析...") # 构建趋势强度分析数据 trend_info = "趋势强度分析:\n\n" @@ -798,7 +801,7 @@ def generate_trend_strength_analysis(self, trend_data: list) -> Optional[str]: {"role": "user", "content": trend_info} ] - print("发送趋势强度分析请求...") + logger.debug("发送趋势强度分析请求...") response = self.client.chat.completions.create( model=self.model, messages=messages, @@ -808,14 +811,14 @@ def generate_trend_strength_analysis(self, trend_data: list) -> Optional[str]: if response.choices and response.choices[0].message: analysis_text = response.choices[0].message.content.strip() - print("趋势强度分析生成成功") + logger.debug("趋势强度分析生成成功") return analysis_text else: - print("趋势强度分析响应为空") + logger.debug("趋势强度分析响应为空") return None except Exception as e: - print(f"生成趋势强度分析失败: {str(e)}") + logger.debug(f"生成趋势强度分析失败: {str(e)}") return None def generate_single_stock_analysis( @@ -837,7 +840,7 @@ def report(progress: int, message: str) -> None: if progress_callback: progress_callback(progress, message) - print(f"开始生成 {detailed_data['name']} 的单股分析...") + logger.debug(f"开始生成 {detailed_data['name']} 的单股分析...") report(56, "正在整理提示词与上下文,准备发送模型请求") # 构建单股分析数据 @@ -1061,7 +1064,7 @@ def report(progress: int, message: str) -> None: {"role": "user", "content": stock_info} ] - print("发送单股分析请求...") + logger.debug("发送单股分析请求...") report(72, "模型请求已发送,正在等待 AI 返回完整报告") # 根据分析深度设置不同的max_tokens if detailed_data['analysis_depth'] == "快速分析": @@ -1081,42 +1084,42 @@ def report(progress: int, message: str) -> None: if response.choices and response.choices[0].message: analysis_text = response.choices[0].message.content.strip() - print(f"{detailed_data['name']} 单股分析生成成功") + logger.debug(f"{detailed_data['name']} 单股分析生成成功") report(92, "AI 已返回内容,正在整理结果") return analysis_text else: - print("单股分析响应为空") + logger.debug("单股分析响应为空") report(92, "AI 请求已完成,但响应内容为空") return None except openai.APIConnectionError as ce: - print(f"=== API连接错误 ===") - print(f"生成单股分析失败: {str(ce)}") - print(f"错误类型: {type(ce)}") - print("可能的原因:") - print("1. 网络连接问题,请检查网络连接") - print("2. API服务不可用,请稍后重试") - print("3. 代理设置问题,请检查代理配置") + logger.debug(f"=== API连接错误 ===") + logger.debug(f"生成单股分析失败: {str(ce)}") + logger.debug(f"错误类型: {type(ce)}") + logger.debug("可能的原因:") + logger.debug("1. 网络连接问题,请检查网络连接") + logger.debug("2. API服务不可用,请稍后重试") + logger.debug("3. 代理设置问题,请检查代理配置") return None except openai.APITimeoutError as te: - print(f"=== API超时错误 ===") - print(f"生成单股分析失败: {str(te)}") - print("请求超时,请稍后重试") + logger.debug(f"=== API超时错误 ===") + logger.debug(f"生成单股分析失败: {str(te)}") + logger.debug("请求超时,请稍后重试") return None except openai.RateLimitError as re: - print(f"=== API频率限制错误 ===") - print(f"生成单股分析失败: {str(re)}") - print("API调用频率过高,请稍后重试") + logger.debug(f"=== API频率限制错误 ===") + logger.debug(f"生成单股分析失败: {str(re)}") + logger.debug("API调用频率过高,请稍后重试") return None except openai.APIError as ae: - print(f"=== API错误 ===") - print(f"生成单股分析失败: {str(ae)}") - print(f"错误类型: {type(ae)}") + logger.debug(f"=== API错误 ===") + logger.debug(f"生成单股分析失败: {str(ae)}") + logger.debug(f"错误类型: {type(ae)}") return None except Exception as e: - print(f"=== 未预期的错误 ===") - print(f"生成单股分析失败: {str(e)}") - print(f"错误类型: {type(e)}") + logger.debug(f"=== 未预期的错误 ===") + logger.debug(f"生成单股分析失败: {str(e)}") + logger.debug(f"错误类型: {type(e)}") import traceback traceback.print_exc() return None @@ -1133,7 +1136,7 @@ def generate_market_insights(self, market_data: Dict[str, Any]) -> Optional[str] """ try: insight_type = market_data['insight_type'] - print(f"开始生成市场洞察: {insight_type}") + logger.debug(f"开始生成市场洞察: {insight_type}") # 构建市场洞察数据 market_info = f"市场洞察分析 - {insight_type}:\n\n" @@ -1214,7 +1217,7 @@ def generate_market_insights(self, market_data: Dict[str, Any]) -> Optional[str] {"role": "user", "content": market_info} ] - print("发送市场洞察分析请求...") + logger.debug("发送市场洞察分析请求...") response = self.client.chat.completions.create( model=self.model, messages=messages, @@ -1224,12 +1227,12 @@ def generate_market_insights(self, market_data: Dict[str, Any]) -> Optional[str] if response.choices and response.choices[0].message: analysis_text = response.choices[0].message.content.strip() - print("市场洞察分析生成成功") + logger.debug("市场洞察分析生成成功") return analysis_text else: - print("市场洞察分析响应为空") + logger.debug("市场洞察分析响应为空") return None except Exception as e: - print(f"生成市场洞察失败: {str(e)}") + logger.debug(f"生成市场洞察失败: {str(e)}") return None diff --git a/ashare/monitor.py b/ashare/monitor.py index d50ff77..55b4ea7 100644 --- a/ashare/monitor.py +++ b/ashare/monitor.py @@ -22,7 +22,9 @@ extract_symbol, get_monitor_support_level, infer_market, + is_a_share_individual_stock, is_hk_stock, + is_us_stock, normalize_stock_code, ) @@ -335,7 +337,27 @@ def fetch_stock_news(self, stock_code: str, stock_name: str) -> List[Dict[str, A results: List[Dict[str, Any]] = [] topic_keywords = self._get_topic_keywords(stock_name, stock_code) - if not is_hk_stock(stock_code): + if is_us_stock(stock_code): + # 美股个股新闻走 Finnhub(A股 的巨潮/东财接口只接受数字代码) + primary_sources = [ + ( + "Finnhub公司新闻", + lambda: self._fetch_finnhub_company_news(symbol), + lambda df: self._normalize_news_dataframe( + df, + stock_name, + stock_code, + "Finnhub", + topic_keywords, + default_relation_type="direct", + ), + ), + ] + for source_name, fetch_fn, transform_fn in primary_sources: + results.extend( + self._collect_source_items(source_name, stock_name, stock_code, fetch_fn, transform_fn) + ) + elif not is_hk_stock(stock_code): primary_sources = [ ( "巨潮资讯公告", @@ -368,32 +390,35 @@ def fetch_stock_news(self, stock_code: str, stock_name: str) -> List[Dict[str, A ) # Market-wide feeds are fallback only; keep topic/name filtering. - extra_sources = [ - ( - "财联社", - lambda: ak.stock_info_global_cls(symbol="全部"), - lambda df: self._filter_market_news(df, stock_name, stock_code, topic_keywords, "财联社"), - ), - ( - "财联社快讯", - lambda: self._fetch_ak_news("stock_news_main_cx"), - lambda df: self._filter_market_news(df, stock_name, stock_code, topic_keywords, "财联社快讯"), - ), - ( - "新浪财经", - lambda: self._fetch_ak_news("stock_info_global_sina"), - lambda df: self._filter_market_news(df, stock_name, stock_code, topic_keywords, "新浪财经"), - ), - ( - "同花顺", - lambda: self._fetch_ak_news("stock_info_global_ths"), - lambda df: self._filter_market_news(df, stock_name, stock_code, topic_keywords, "同花顺"), - ), - ] - for source_name, fetch_fn, transform_fn in extra_sources: - results.extend( - self._collect_source_items(source_name, stock_name, stock_code, fetch_fn, transform_fn) - ) + # 美股不走中文全市场源:这些 feed 以 A股 资讯为主,按个股名(如“苹果” + # 这类常用词)过滤会混入大量无关中文新闻,美股已由 Finnhub 覆盖。 + if not is_us_stock(stock_code): + extra_sources = [ + ( + "财联社", + lambda: ak.stock_info_global_cls(symbol="全部"), + lambda df: self._filter_market_news(df, stock_name, stock_code, topic_keywords, "财联社"), + ), + ( + "财联社快讯", + lambda: self._fetch_ak_news("stock_news_main_cx"), + lambda df: self._filter_market_news(df, stock_name, stock_code, topic_keywords, "财联社快讯"), + ), + ( + "新浪财经", + lambda: self._fetch_ak_news("stock_info_global_sina"), + lambda df: self._filter_market_news(df, stock_name, stock_code, topic_keywords, "新浪财经"), + ), + ( + "同花顺", + lambda: self._fetch_ak_news("stock_info_global_ths"), + lambda df: self._filter_market_news(df, stock_name, stock_code, topic_keywords, "同花顺"), + ), + ] + for source_name, fetch_fn, transform_fn in extra_sources: + results.extend( + self._collect_source_items(source_name, stock_name, stock_code, fetch_fn, transform_fn) + ) # Dedupe by (title, stock_code) keeping first occurrence seen_keys: set = set() @@ -500,6 +525,53 @@ def _fetch_cninfo_notices(self, symbol: str) -> Optional[pd.DataFrame]: end_date=end_date, ) + def _fetch_finnhub_company_news(self, symbol: str) -> Optional[pd.DataFrame]: + """美股个股新闻:Finnhub company-news。未配置 key 时返回 None。""" + api_key = getattr(self.config, "finnhub_api_key", None) if self.config else None + if not api_key: + return None + # Finnhub 用 ``-`` 表示类别股(BRK.B -> BRK-B) + finnhub_symbol = symbol.replace(".", "-") + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=self.NOTICE_LOOKBACK_DAYS)).strftime("%Y-%m-%d") + try: + import requests + + resp = requests.get( + "https://finnhub.io/api/v1/company-news", + params={"symbol": finnhub_symbol, "from": start_date, "to": end_date, "token": api_key}, + timeout=10, + ) + resp.raise_for_status() + payload = resp.json() + except Exception as exc: + logger.warning("Finnhub 公司新闻获取失败 %s: %s", finnhub_symbol, exc) + return None + if not isinstance(payload, list) or not payload: + return None + rows = [] + for item in payload: + if not isinstance(item, dict): + continue + ts = item.get("datetime") + occurred_at = ( + datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + if isinstance(ts, (int, float)) and ts > 0 + else "" + ) + rows.append( + { + "title": normalize_text(item.get("headline")), + "content": normalize_text(item.get("summary")), + "source": normalize_text(item.get("source")), + "datetime": occurred_at, + "url": normalize_text(item.get("url")), + } + ) + if not rows: + return None + return pd.DataFrame(rows) + def _collect_source_items( self, source_name: str, @@ -958,7 +1030,8 @@ def run_cycle(self) -> Dict[str, Any]: pushed_count += pushed generated_count += generated - if self.config.fund_flow_tracking_enabled and not is_hk_stock(stock_code): + # 资金流/龙虎榜是 A股 特有数据,仅对 A股 个股运行(美股/港股跳过) + if self.config.fund_flow_tracking_enabled and is_a_share_individual_stock(stock_code): pushed, generated = self._handle_fund_flow(stock_name, stock_code, ranking_map) pushed_count += pushed generated_count += generated diff --git a/ashare/search.py b/ashare/search.py index 07e2dc7..c119b48 100644 --- a/ashare/search.py +++ b/ashare/search.py @@ -74,6 +74,18 @@ def _load_stock_db(self) -> Dict[str, Dict[str, str]]: }, ) return stock_db + + def _merge_latest_stock_pool(self) -> None: + """Keep runtime search data in sync with the editable local stock pool.""" + for name, code in load_stock_pool().items(): + self.base_stock_db.setdefault( + name, + { + "code": code, + "market": get_market_label(code), + "category": "", + }, + ) def search_stocks(self, query: str, max_results: int = 10) -> List[Dict]: """ @@ -89,12 +101,14 @@ def search_stocks(self, query: str, max_results: int = 10) -> List[Dict]: query = query.strip() if not query: return [] + + self._merge_latest_stock_pool() # 检查缓存 cache_key = f"{query}_{max_results}" if cache_key in self.search_cache: cache_time, cache_results = self.search_cache[cache_key] - if time.time() - cache_time < self.cache_expiry: + if cache_results and time.time() - cache_time < self.cache_expiry: return cache_results results = [] @@ -146,7 +160,8 @@ def search_stocks(self, query: str, max_results: int = 10) -> List[Dict]: final_results = sorted_results[:max_results] # 缓存结果 - self.search_cache[cache_key] = (time.time(), final_results) + if final_results: + self.search_cache[cache_key] = (time.time(), final_results) return final_results diff --git a/ashare/stock_pool.py b/ashare/stock_pool.py index b1a3f8e..9742267 100644 --- a/ashare/stock_pool.py +++ b/ashare/stock_pool.py @@ -89,6 +89,55 @@ "华润三九": {"code": "sz000999", "market": "A股-深圳", "category": "医药"}, "丽珠集团": {"code": "sz000513", "market": "A股-深圳", "category": "医药"}, "长春高新": {"code": "sz000661", "market": "A股-深圳", "category": "医药"}, + # 美股龙头(含中文名映射,方便中文搜索) + "苹果": {"code": "US.AAPL", "market": "美股", "category": "科技"}, + "微软": {"code": "US.MSFT", "market": "美股", "category": "科技"}, + "英伟达": {"code": "US.NVDA", "market": "美股", "category": "半导体"}, + "谷歌": {"code": "US.GOOGL", "market": "美股", "category": "科技"}, + "谷歌C": {"code": "US.GOOG", "market": "美股", "category": "科技"}, + "亚马逊": {"code": "US.AMZN", "market": "美股", "category": "电商"}, + "Meta": {"code": "US.META", "market": "美股", "category": "科技"}, + "脸书": {"code": "US.META", "market": "美股", "category": "科技"}, + "特斯拉": {"code": "US.TSLA", "market": "美股", "category": "新能源车"}, + "博通": {"code": "US.AVGO", "market": "美股", "category": "半导体"}, + "台积电": {"code": "US.TSM", "market": "美股", "category": "半导体"}, + "AMD": {"code": "US.AMD", "market": "美股", "category": "半导体"}, + "英特尔": {"code": "US.INTC", "market": "美股", "category": "半导体"}, + "高通": {"code": "US.QCOM", "market": "美股", "category": "半导体"}, + "美光": {"code": "US.MU", "market": "美股", "category": "半导体"}, + "奈飞": {"code": "US.NFLX", "market": "美股", "category": "科技"}, + "甲骨文": {"code": "US.ORCL", "market": "美股", "category": "科技"}, + "Adobe": {"code": "US.ADBE", "market": "美股", "category": "科技"}, + "Salesforce": {"code": "US.CRM", "market": "美股", "category": "科技"}, + "伯克希尔": {"code": "US.BRK.B", "market": "美股", "category": "金融"}, + "摩根大通": {"code": "US.JPM", "market": "美股", "category": "金融"}, + "visa": {"code": "US.V", "market": "美股", "category": "金融"}, + "万事达": {"code": "US.MA", "market": "美股", "category": "金融"}, + "美国银行": {"code": "US.BAC", "market": "美股", "category": "金融"}, + "强生": {"code": "US.JNJ", "market": "美股", "category": "医药"}, + "礼来": {"code": "US.LLY", "market": "美股", "category": "医药"}, + "联合健康": {"code": "US.UNH", "market": "美股", "category": "医疗"}, + "辉瑞": {"code": "US.PFE", "market": "美股", "category": "医药"}, + "可口可乐": {"code": "US.KO", "market": "美股", "category": "消费"}, + "百事": {"code": "US.PEP", "market": "美股", "category": "消费"}, + "麦当劳": {"code": "US.MCD", "market": "美股", "category": "消费"}, + "星巴克": {"code": "US.SBUX", "market": "美股", "category": "消费"}, + "耐克": {"code": "US.NKE", "market": "美股", "category": "消费"}, + "沃尔玛": {"code": "US.WMT", "market": "美股", "category": "零售"}, + "好市多": {"code": "US.COST", "market": "美股", "category": "零售"}, + "迪士尼": {"code": "US.DIS", "market": "美股", "category": "传媒"}, + "波音": {"code": "US.BA", "market": "美股", "category": "工业"}, + "埃克森美孚": {"code": "US.XOM", "market": "美股", "category": "能源"}, + "雪佛龙": {"code": "US.CVX", "market": "美股", "category": "能源"}, + "Palantir": {"code": "US.PLTR", "market": "美股", "category": "科技"}, + "超微电脑": {"code": "US.SMCI", "market": "美股", "category": "科技"}, + "阿斯麦": {"code": "US.ASML", "market": "美股", "category": "半导体"}, + "Coinbase": {"code": "US.COIN", "market": "美股", "category": "金融科技"}, + "Uber": {"code": "US.UBER", "market": "美股", "category": "科技"}, + # 美股主要指数 ETF / 指数 + "标普500": {"code": "US.SPY", "market": "美股", "category": "指数"}, + "纳斯达克100": {"code": "US.QQQ", "market": "美股", "category": "指数"}, + "道琼斯": {"code": "US.DIA", "market": "美股", "category": "指数"}, } @@ -179,8 +228,20 @@ def get_base_stock_catalog() -> Dict[str, Dict[str, str]]: } +US_TICKER_PATTERN = r"[A-Z]{1,5}(\.[A-Z]{1,2})?" + + def normalize_stock_code(code: str) -> str: - """标准化股票代码格式。""" + """标准化股票代码格式。 + + 支持三类市场: + - A股:``sh######`` / ``sz######`` + - 港股:``#####.HK`` + - 美股:``US.TICKER``(如 ``US.AAPL``、``US.BRK.B``) + + 美股统一加 ``US.`` 前缀,以便和港股 ``.HK``、A股数字代码区分,避免 + 诸如 ``BRK.B`` 这类含点的代码被误判成港股。 + """ raw = code.strip() if not raw: return raw @@ -200,6 +261,13 @@ def normalize_stock_code(code: str) -> str: symbol = upper.split(".")[0].zfill(5) return f"{symbol}.HK" + # 美股:显式 ``US.`` 前缀 + if upper.startswith("US.") and re.fullmatch(US_TICKER_PATTERN, upper[3:]): + return f"US.{upper[3:]}" + # 美股:裸 ticker(纯字母,区别于全数字的 A股/港股代码) + if re.fullmatch(US_TICKER_PATTERN, upper): + return f"US.{upper}" + return raw @@ -209,6 +277,8 @@ def extract_symbol(code: str) -> str: return normalized[2:] if normalized.upper().endswith(".HK"): return normalized[:-3] + if normalized.upper().startswith("US."): + return normalized[3:] return normalized @@ -222,6 +292,8 @@ def infer_market(code: str) -> str: return "sz" if upper.endswith(".HK"): return "hk" + if upper.startswith("US."): + return "us" return "unknown" @@ -232,6 +304,7 @@ def is_valid_stock_code(code: str) -> bool: return bool( re.fullmatch(r"(sh|sz)\d{6}", lower) or re.fullmatch(r"\d{5}\.HK", upper) + or re.fullmatch(rf"US\.{US_TICKER_PATTERN}", upper) ) @@ -253,6 +326,8 @@ def get_market_label(code: str) -> str: return "A股-深圳" if upper.endswith(".HK"): return "港股" + if upper.startswith("US."): + return "美股" return "未知市场" @@ -264,6 +339,8 @@ def get_exchange_label(code: str) -> str: return "深交所" if market == "hk": return "港交所" + if market == "us": + return "美股" return "未知" @@ -284,10 +361,21 @@ def is_hk_stock(code: str) -> bool: return bool(re.fullmatch(r"\d{5}\.HK", normalized)) +def is_us_stock(code: str) -> bool: + normalized = normalize_stock_code(code).upper() + return bool(re.fullmatch(rf"US\.{US_TICKER_PATTERN}", normalized)) + + def get_monitor_support_level(code: str) -> str: - """返回监控支持级别: full, partial, unsupported。""" + """返回监控支持级别: full, partial, unsupported。 + + A股个股支持完整监控(含资金流/龙虎榜等 A股 特有数据);美股支持 + 完整的行情与技术面监控,但 A股 特有数据会自动跳过;港股为部分支持。 + """ if is_a_share_individual_stock(code): return "full" + if is_us_stock(code): + return "full" if is_hk_stock(code): return "partial" return "unsupported" diff --git a/components/app-shell.tsx b/components/app-shell.tsx index cb73e38..22c8429 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -174,13 +174,36 @@ export function AppShell({ children }: { children: React.ReactNode }) { } const NAV_ITEMS = [ - { href: "/work", label: "工作台", labelEn: "Workbench" }, - { href: "/stocks", label: "单股分析", labelEn: "Stocks" }, - { href: "/charts", label: "K 线图", labelEn: "Charts" }, - { href: "/portfolio", label: "持仓页", labelEn: "Portfolio" }, - { href: "/news", label: "消息页", labelEn: "News" }, - { href: "/hotspots", label: "热点页", labelEn: "Hotspots" }, - { href: "/settings", label: "设置", labelEn: "Settings" }, + { + href: "/work", + label: "工作台", + labelEn: "Workbench", + }, + { + href: "/stocks", + label: "单股分析", + labelEn: "Stocks", + }, + { + href: "/portfolio", + label: "持仓页", + labelEn: "Portfolio", + }, + { + href: "/news", + label: "消息页", + labelEn: "News", + }, + { + href: "/hotspots", + label: "热点页", + labelEn: "Hotspots", + }, + { + href: "/settings", + label: "设置", + labelEn: "Settings", + }, ] as const; export function Nav() { diff --git a/components/portfolio-shell.tsx b/components/portfolio-shell.tsx index dc82770..47ca3df 100644 --- a/components/portfolio-shell.tsx +++ b/components/portfolio-shell.tsx @@ -12,6 +12,8 @@ import { getClientErrorMessage, getMarketRegime, getStrategyHoldingsAnalysis, + listStrategyHoldings, + refreshStrategyHoldings, searchStocks, updateStrategyHolding, } from "@/lib/api"; @@ -100,6 +102,8 @@ const EMPTY_STRATEGY_FORM: StrategyFormState = { status: "planned", }; +const STRATEGY_ANALYSIS_CACHE_KEY = "ashare.strategyAnalysis.v1"; + const HOLDING_STATUS_GROUPS: Array<{ key: StrategyFormState["status"]; title: string; @@ -126,13 +130,16 @@ export function PortfolioShell({ const [marketRegimeLoading, setMarketRegimeLoading] = useState(initialMarketRegime === null); const [marketRegimeError, setMarketRegimeError] = useState(null); const [strategyForm, setStrategyForm] = useState(EMPTY_STRATEGY_FORM); + const [strategyFormOpen, setStrategyFormOpen] = useState(Boolean(initialPrefill?.stock_code || initialPrefill?.stock_name)); const [message, setMessage] = useState("可直接录入策略持股,随后刷新查看最新分析。"); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [pendingAction, setPendingAction] = useState(null); const [isAnalysisRefreshing, setIsAnalysisRefreshing] = useState(false); const strategyPriceInputRef = useRef(null); + const strategyAnalysisRef = useRef(normalizeStrategyAnalysis(initialStrategyAnalysis)); const initialLoadInFlightRef = useRef(false); + const initialLoadCompletedRef = useRef(false); const localMutationVersionRef = useRef(0); const [focusTarget, setFocusTarget] = useState<"strategy_price" | null>(null); const [lastSubmitted, setLastSubmitted] = useState<{ stock_code: string; stock_name: string } | null>(null); @@ -140,8 +147,21 @@ export function PortfolioShell({ const isBusy = pendingAction !== null; const safeStrategyAnalysis = normalizeStrategyAnalysis(strategyAnalysis); const strategyRefreshInProgress = pendingAction?.type === "refresh" || isAnalysisRefreshing; + const scoredStrategyHoldingCount = safeStrategyAnalysis.holdings.filter(hasStrategyScore).length; + + function applyStrategyAnalysis(analysis: StrategyHoldingAnalysisResponse) { + const normalized = normalizeStrategyAnalysis(analysis); + const merged = mergeLatestAnalysisWithExisting(normalized, strategyAnalysisRef.current); + strategyAnalysisRef.current = merged; + setStrategyAnalysis(merged); + setStrategyHoldings(toHoldingList(merged)); + if (merged.holdings.some(hasStrategyScore)) { + cacheStrategyAnalysis(merged); + } + } function fillStrategyForm(holding: StrategyHolding) { + setStrategyFormOpen(true); setStrategyForm({ id: holding.id, strategy_key: holding.strategy_key, @@ -166,6 +186,7 @@ export function PortfolioShell({ function resetStrategyForm() { setStrategyForm(EMPTY_STRATEGY_FORM); + setStrategyFormOpen(false); } function markLocalMutation() { @@ -180,11 +201,25 @@ export function PortfolioShell({ } }, [focusTarget]); + useEffect(() => { + strategyAnalysisRef.current = safeStrategyAnalysis; + }, [safeStrategyAnalysis]); + + useEffect(() => { + const cachedAnalysis = readCachedStrategyAnalysis(); + if (!cachedAnalysis?.holdings.length) { + return; + } + setStrategyAnalysis((prev) => (normalizeStrategyAnalysis(prev).holdings.length ? prev : cachedAnalysis)); + setStrategyHoldings((prev) => (prev.length ? prev : toHoldingList(cachedAnalysis))); + }, []); + useEffect(() => { if (!initialPrefill?.stock_code && !initialPrefill?.stock_name) { return; } setSearchQuery(prefillLabel); + setStrategyFormOpen(true); setStrategyForm((prev) => ({ ...prev, id: undefined, @@ -207,7 +242,7 @@ export function PortfolioShell({ }, [initialPrefill, prefillLabel]); useEffect(() => { - if (!loaded || !unlocked || initialLoadInFlightRef.current) { + if (!loaded || !unlocked || initialLoadInFlightRef.current || initialLoadCompletedRef.current) { return; } if (safeStrategyAnalysis.holdings.length && marketRegime) { @@ -218,6 +253,20 @@ export function PortfolioShell({ setIsAnalysisRefreshing(true); setMarketRegimeLoading(true); setMarketRegimeError(null); + void listStrategyHoldings() + .then((holdings) => { + if (localMutationVersionRef.current !== requestMutationVersion) { + return; + } + setStrategyHoldings(holdings); + setStrategyAnalysis((prev) => { + const normalizedPrev = normalizeStrategyAnalysis(prev); + return mergeHoldingsWithExistingAnalysis(holdings, normalizedPrev); + }); + }) + .catch(() => { + // Full analysis below still has a chance to hydrate the page. + }); void Promise.allSettled([ getStrategyHoldingsAnalysis(), getMarketRegime(), @@ -226,8 +275,7 @@ export function PortfolioShell({ if (analysisResult.status === "fulfilled") { if (localMutationVersionRef.current === requestMutationVersion) { const analysis = normalizeStrategyAnalysis(analysisResult.value); - setStrategyAnalysis(analysis); - setStrategyHoldings(toHoldingList(analysis)); + applyStrategyAnalysis(analysis); } } if (marketRegimeResult.status === "fulfilled") { @@ -242,6 +290,7 @@ export function PortfolioShell({ } }) .finally(() => { + initialLoadCompletedRef.current = true; initialLoadInFlightRef.current = false; setIsAnalysisRefreshing(false); setMarketRegimeLoading(false); @@ -251,6 +300,7 @@ export function PortfolioShell({ function applySearchResult(item: StockSearchResult) { setSearchQuery(item.name); setSearchResults([]); + setStrategyFormOpen(true); setStrategyForm((prev) => ({ ...prev, id: undefined, @@ -265,6 +315,23 @@ export function PortfolioShell({ return normalizeStrategyAnalysis(analysis).holdings.map((item) => item.holding); } + function mergeHoldingsWithExistingAnalysis( + holdings: StrategyHolding[], + existingAnalysis: StrategyHoldingAnalysisResponse, + ) { + const existingById = new Map( + existingAnalysis.holdings + .filter((item) => item.holding.id != null) + .map((item) => [item.holding.id, item]), + ); + const existingByCode = new Map( + existingAnalysis.holdings.map((item) => [item.holding.stock_code, item]), + ); + return recalculateAnalysisSummary( + holdings.map((holding) => buildLocalHoldingAnalysis(holding, existingById.get(holding.id) ?? existingByCode.get(holding.stock_code))), + ); + } + function recalculateAnalysisSummary(holdings: StrategyHoldingAnalysisResponse["holdings"]): StrategyHoldingAnalysisResponse { const investedHoldings = holdings.filter((item) => item.holding.status !== "watching" && item.holding.status !== "planned"); const total_cost = investedHoldings.reduce((sum, item) => sum + item.holding.entry_price * item.holding.quantity, 0); @@ -305,22 +372,6 @@ export function PortfolioShell({ }; } - async function refreshStrategyAnalysis(options?: { successMessage?: string; errorPrefix?: string }) { - setIsAnalysisRefreshing(true); - try { - const latest = await getStrategyHoldingsAnalysis(); - setStrategyAnalysis(normalizeStrategyAnalysis(latest)); - setStrategyHoldings(toHoldingList(latest)); - if (options?.successMessage) { - setMessage(options.successMessage); - } - } catch (error) { - setMessage(`${options?.errorPrefix ?? "刷新失败"}: ${getClientErrorMessage(error)}`); - } finally { - setIsAnalysisRefreshing(false); - } - } - function syncHoldingInAnalysis(updatedHolding: StrategyHolding) { setStrategyAnalysis((prev) => { const existing = prev.holdings.find((item) => item.holding.id === updatedHolding.id); @@ -354,7 +405,14 @@ export function PortfolioShell({ try { const results = await searchStocks(searchQuery.trim()); setSearchResults(results); - setMessage(results.length ? `找到 ${results.length} 条匹配结果。` : "没有找到匹配股票。"); + const exactResult = results.find((item) => item.match_type === "exact_name" || item.match_type === "exact_code"); + const resultToApply = results.length === 1 ? results[0] : exactResult; + if (resultToApply) { + applySearchResult(resultToApply); + setMessage(`已找到并带入 ${resultToApply.name} (${resultToApply.code})。`); + } else { + setMessage(results.length ? `找到 ${results.length} 条匹配结果,请选择一只填入表单。` : "没有找到匹配股票。"); + } } catch (error) { setMessage(`搜索失败: ${getClientErrorMessage(error)}`); } finally { @@ -460,13 +518,11 @@ export function PortfolioShell({ void (async () => { try { const [latestResult, marketRegimeResult] = await Promise.allSettled([ - getStrategyHoldingsAnalysis(), + refreshStrategyHoldings(), getMarketRegime(), ]); if (latestResult.status === "fulfilled") { - const latest = normalizeStrategyAnalysis(latestResult.value); - setStrategyAnalysis(latest); - setStrategyHoldings(toHoldingList(latest)); + applyStrategyAnalysis(latestResult.value); } else { throw latestResult.reason; } @@ -647,6 +703,44 @@ export function PortfolioShell({

策略总览

+
+
+ 今日优先 + + {safeStrategyAnalysis.todo_items.length + ? `${safeStrategyAnalysis.todo_items[0].stock_name} · ${safeStrategyAnalysis.todo_items[0].action_label}` + : "暂无必须处理动作"} + +

+ {safeStrategyAnalysis.todo_items.length + ? safeStrategyAnalysis.todo_items[0].action_reason + : "可以先保持观察,或刷新策略持股获取最新判断。"} +

+
+
+ {safeStrategyAnalysis.todo_items.slice(0, 2).map((item) => ( + + {item.stock_name} + {item.action_label} + + ))} + {!safeStrategyAnalysis.todo_items.length ? ( + + ) : null} +
+
策略持股总成本
@@ -694,6 +788,23 @@ export function PortfolioShell({
+
+
+
Strategy Entry
+

{strategyForm.id ? "编辑策略持股" : "新增策略持股"}

+
+ +
+ + {strategyFormOpen ? ( + <>
@@ -1057,7 +1176,7 @@ export function PortfolioShell({
平均策略分
- {safeStrategyAnalysis.average_score.toFixed(2)} + {scoredStrategyHoldingCount ? safeStrategyAnalysis.average_score.toFixed(2) : "待刷新"}
@@ -1072,9 +1191,13 @@ export function PortfolioShell({

{group.title}

{group.description}

-
+
{items.map((item) => ( -
+

{item.holding.stock_name} ({item.holding.stock_code})

{item.holding.strategy_key} · 计划价/成本 {item.holding.entry_price.toFixed(2)} · 数量 {item.holding.quantity} @@ -1090,7 +1213,7 @@ export function PortfolioShell({

总分
- {item.strategy_score.total.toFixed(1)} + {hasStrategyScore(item) ? item.strategy_score.total.toFixed(1) : "待刷新"}
状态
@@ -1125,12 +1248,12 @@ export function PortfolioShell({
{label} - {score.toFixed(0)} + {hasStrategyScore(item) ? score.toFixed(0) : "-"}
({ @@ -1342,6 +1471,7 @@ function normalizeStrategyAnalysis( action_label: item?.action_label ?? "继续持有", action_reason: item?.action_reason ?? "", invalidation_reason: item?.invalidation_reason ?? null, + analysis_status: normalizeAnalysisStatus(item), })) : [], todo_items: Array.isArray(analysis.todo_items) ? analysis.todo_items : [], @@ -1349,6 +1479,101 @@ function normalizeStrategyAnalysis( }; } +function normalizeAnalysisStatus(item: Partial | undefined): StrategyHoldingAnalysis["analysis_status"] { + const status = item?.analysis_status; + if (status === "live" || status === "cached" || status === "degraded" || status === "local") { + return status; + } + return (item?.strategy_score?.total ?? 0) > 0 ? "cached" : "local"; +} + +function readCachedStrategyAnalysis(): StrategyHoldingAnalysisResponse | null { + if (typeof window === "undefined") { + return null; + } + try { + const raw = window.localStorage.getItem(STRATEGY_ANALYSIS_CACHE_KEY); + if (!raw) { + return null; + } + return normalizeStrategyAnalysis(JSON.parse(raw) as StrategyHoldingAnalysisResponse); + } catch { + return null; + } +} + +function cacheStrategyAnalysis(analysis: StrategyHoldingAnalysisResponse) { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem(STRATEGY_ANALYSIS_CACHE_KEY, JSON.stringify(normalizeStrategyAnalysis(analysis))); + } catch { + // Ignore storage quota or private mode failures; the live analysis is still displayed. + } +} + +function mergeLatestAnalysisWithExisting( + latest: StrategyHoldingAnalysisResponse, + existing: StrategyHoldingAnalysisResponse, +) { + const existingById = new Map( + existing.holdings + .filter((item) => item.holding.id != null) + .map((item) => [item.holding.id, item]), + ); + const existingByCode = new Map(existing.holdings.map((item) => [item.holding.stock_code, item])); + const mergedHoldings = latest.holdings.map((item) => { + if (hasStrategyScore(item)) { + return item; + } + const previous = existingById.get(item.holding.id) ?? existingByCode.get(item.holding.stock_code); + return previous && hasStrategyScore(previous) ? buildLocalHoldingAnalysis(item.holding, previous) : item; + }); + return recalculateStrategyAnalysis(latest, mergedHoldings); +} + +function recalculateStrategyAnalysis( + base: StrategyHoldingAnalysisResponse, + holdings: StrategyHoldingAnalysisResponse["holdings"], +): StrategyHoldingAnalysisResponse { + const investedHoldings = holdings.filter((item) => item.holding.status !== "watching" && item.holding.status !== "planned"); + const total_cost = investedHoldings.reduce((sum, item) => sum + item.holding.entry_price * item.holding.quantity, 0); + const total_market_value = investedHoldings.reduce((sum, item) => sum + item.market_value, 0); + const total_realized_pnl = investedHoldings.reduce((sum, item) => sum + item.realized_pnl, 0); + const active_count = holdings.filter((item) => item.holding.status === "holding" || item.holding.status === "weakening").length; + const watching_count = holdings.filter((item) => item.holding.status === "watching").length; + const planned_count = holdings.filter((item) => item.holding.status === "planned").length; + const weakening_count = holdings.filter((item) => item.holding.status === "weakening").length; + const exited_count = holdings.filter((item) => item.holding.status === "exited").length; + const invalidated_count = holdings.filter((item) => item.holding.status === "invalidated").length; + const scoredHoldings = holdings.filter(hasStrategyScore); + const total_pnl = total_market_value - total_cost; + const exited_win_count = holdings.filter((item) => item.holding.status === "exited" && item.realized_pnl > 0).length; + return { + ...base, + total_cost, + total_market_value, + total_pnl, + total_pnl_pct: total_cost ? (total_pnl / total_cost) * 100 : 0, + total_realized_pnl, + holding_count: holdings.length, + active_count, + watching_count, + planned_count, + weakening_count, + exited_count, + invalidated_count, + win_rate_pct: exited_count ? (exited_win_count / exited_count) * 100 : 0, + average_score: scoredHoldings.length + ? scoredHoldings.reduce((sum, item) => sum + item.strategy_score.total, 0) / scoredHoldings.length + : 0, + todo_items: buildTodoItems(holdings), + review_items: buildReviewItems(holdings), + holdings, + }; +} + function buildLocalHoldingAnalysis( holding: StrategyHolding, existing?: StrategyHoldingAnalysis, @@ -1391,6 +1616,7 @@ function buildLocalHoldingAnalysis( trigger_hits: existing?.trigger_hits ?? [], alerts: existing?.alerts ?? ["当前展示的是本地记录快照,最新评分与盈亏请手动刷新。"], + analysis_status: existing && hasStrategyScore(existing) ? "cached" : "local", }; } @@ -1409,8 +1635,15 @@ function scoreTone(score: number) { return "linear-gradient(90deg, #ef4444, #f87171)"; } +function hasStrategyScore(item: StrategyHoldingAnalysis) { + return item.strategy_score.total > 0 && (item.analysis_status === "live" || item.analysis_status === "cached"); +} + function buildRiskNotes(item: StrategyHoldingAnalysis) { const notes: string[] = []; + if (!hasStrategyScore(item)) { + return (item.alerts.length ? item.alerts : [item.action_reason || "已显示本地记录,等待最新评分刷新。"]).slice(0, 2); + } const lowFactors = ([ ["C", item.strategy_score.c], ["A", item.strategy_score.a], diff --git a/components/portfolio-snapshot-panel.tsx b/components/portfolio-snapshot-panel.tsx index 17aab4c..f9883da 100644 --- a/components/portfolio-snapshot-panel.tsx +++ b/components/portfolio-snapshot-panel.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import Link from "next/link"; import { getPortfolioAnalysis } from "@/lib/api"; import type { PortfolioAnalysisResponse } from "@/lib/types"; @@ -16,6 +17,9 @@ const PORTFOLIO_COPY = { returnRate: "收益率", technicalRisk: "技术风险", unavailable: "后端不可达时,这里会显示组合快照。", + nextAction: "下一步", + openPortfolio: "打开持仓页", + calm: "组合暂无明确调仓提示。", }, en: { title: "Portfolio Snapshot", @@ -25,6 +29,9 @@ const PORTFOLIO_COPY = { returnRate: "Return", technicalRisk: "Technical Risk", unavailable: "Portfolio metrics will appear here after the backend is available.", + nextAction: "Next action", + openPortfolio: "Open portfolio", + calm: "No urgent portfolio action right now.", }, } as const; @@ -64,24 +71,35 @@ export function PortfolioSnapshotPanel({ locale = "zh" }: { locale?: PortfolioSn {loading ? (

{copy.loading}

) : portfolio ? ( -
-
-
{copy.totalMarketValue}
- {portfolio.total_market_value.toFixed(2)} +
+
+
+ {copy.nextAction} + {portfolio.rebalance_suggestions[0] ?? copy.calm} +
+ + {copy.openPortfolio} +
-
-
{copy.totalPnl}
- = 0 ? "signal-up" : "signal-down"}> - {portfolio.total_pnl.toFixed(2)} - -
-
-
{copy.returnRate}
- {portfolio.total_pnl_pct.toFixed(2)}% -
-
-
{copy.technicalRisk}
- {portfolio.technical_risk} +
+
+
{copy.totalMarketValue}
+ {portfolio.total_market_value.toFixed(2)} +
+
+
{copy.totalPnl}
+ = 0 ? "signal-up" : "signal-down"}> + {portfolio.total_pnl.toFixed(2)} + +
+
+
{copy.returnRate}
+ {portfolio.total_pnl_pct.toFixed(2)}% +
+
+
{copy.technicalRisk}
+ {portfolio.technical_risk} +
) : ( diff --git a/components/stocks-page-client.tsx b/components/stocks-page-client.tsx index 70c5b56..a6a75a6 100644 --- a/components/stocks-page-client.tsx +++ b/components/stocks-page-client.tsx @@ -41,6 +41,7 @@ export function StocksPageClient({ const [analysis, setAnalysis] = useState(null); const [analysisLoading, setAnalysisLoading] = useState(false); + const [aiLoading, setAiLoading] = useState(false); const [analysisError, setAnalysisError] = useState(""); const [analysisIncludesAi, setAnalysisIncludesAi] = useState(false); @@ -114,6 +115,7 @@ export function StocksPageClient({ setAnalysis(null); setAnalysisError(""); setAnalysisLoading(false); + setAiLoading(false); return () => { cancelled = true; }; @@ -125,33 +127,66 @@ export function StocksPageClient({ if (canReuseAnalysis) { setAnalysisError(""); setAnalysisLoading(false); + setAiLoading(false); return () => { cancelled = true; }; } - setAnalysisLoading(true); setAnalysisError(""); - getStockAnalysis(selected.code, { includeAi: shouldLoadAi, requestId: initialRequestId }) - .then((result) => { + // 两段式加载:先用 include_ai=false 快速拿到行情/指标/K 线渲染出来, + // 再在后台补充 AI 文本,整个过程图表不会被清空或被进度条接管。 + if (!sameStockLoaded) { + setAnalysisLoading(true); + getStockAnalysis(selected.code, { includeAi: false, requestId: initialRequestId }) + .then((base) => { + if (cancelled) { + return; + } + setAnalysis(base); + setAnalysisIncludesAi(false); + // shouldLoadAi 为真时,下一次 effect 重跑会进入下面的 AI 补充分支。 + }) + .catch((error) => { + if (cancelled) { + return; + } + setAnalysis(null); + setAnalysisIncludesAi(false); + setAnalysisError(error instanceof Error ? error.message : "单股分析生成失败"); + }) + .finally(() => { + if (!cancelled) { + setAnalysisLoading(false); + } + }); + + return () => { + cancelled = true; + }; + } + + // 已经有该股票的基础分析,仅缺 AI:后台增量加载,保留现有图表可见。 + setAiLoading(true); + getStockAnalysis(selected.code, { includeAi: true, requestId: initialRequestId }) + .then((full) => { if (cancelled) { return; } - setAnalysis(result); - setAnalysisIncludesAi(shouldLoadAi); + setAnalysis(full); + setAnalysisIncludesAi(true); }) .catch((error) => { if (cancelled) { return; } - setAnalysis(null); - setAnalysisIncludesAi(false); - setAnalysisError(error instanceof Error ? error.message : "单股分析生成失败"); + // AI 补充失败时保留已显示的基础分析,仅提示 AI 部分出错。 + setAnalysisError(error instanceof Error ? error.message : "AI 分析生成失败"); }) .finally(() => { if (!cancelled) { - setAnalysisLoading(false); + setAiLoading(false); } }); @@ -285,34 +320,53 @@ export function StocksPageClient({ {analysis ? ( <> +
+
+ {analysis.market || selected.market} + {analysis.stock_name} ({analysis.stock_code}) + 信号 {analysis.signal_summary.overall_signal} · 评分 {analysis.signal_summary.overall_score}/5 +
+
+ + 现价 {analysis.quote.current_price.toFixed(2)} + + = 0 ? "signal-up" : "signal-down"}> + {analysis.quote.change_pct.toFixed(2)}% + +
+
+ K线 + AI + 新闻 + + 加计划 + +
+
+
-
-
-
最新价
- {analysis.quote.current_price.toFixed(2)} -
-
-
涨跌幅
- = 0 ? "signal-up" : "signal-down"}> - {analysis.quote.change_pct.toFixed(2)}% - -
-
-
综合信号
- {analysis.signal_summary.overall_signal} -
-
-
评分
- {analysis.signal_summary.overall_score}/5 +
+
+
Technical Context
+

技术环境

+ 减少重复价格信息,优先看关键指标。 +
+
+ {buildOverviewIndicators(analysis.technical_indicators).map(([key, value]) => ( +
+
{formatIndicatorLabel(key)}
+ {value === null ? "-" : value.toFixed(2)} +
+ ))}
-
-

K 线缩略

- -

使用轻量级蜡烛图组件展示最近行情,支持自适应宽度。

+
+

K 线图

+ +

展示最近行情的日 K 线,支持自适应宽度;配合上方价格与涨跌幅做节奏判断。

技术分析建议

@@ -345,6 +399,8 @@ export function StocksPageClient({ title="AI 分析已锁定" description="解锁后可以查看更长的 AI 观点、结论和操作建议。" /> + ) : shouldLoadAi && aiLoading && !analysisIncludesAi ? ( +

AI 正在生成分析报告,行情与指标已就绪,请稍候…

) : shouldLoadAi && analysis.ai_insight.enabled ? ( Object.prototype.hasOwnProperty.call(indicators, key)) + .map((key) => [key, indicators[key]] as [string, number | null]); + const fallback = Object.entries(indicators).filter(([key]) => !preferredKeys.includes(key)); + return [...preferred, ...fallback].slice(0, 4); +} + +function formatIndicatorLabel(key: string) { + const labels: Record = { + MA5: "MA5", + MA20: "MA20", + MA60: "MA60", + RSI: "RSI", + MACD: "MACD", + }; + return labels[key] ?? key; +} diff --git a/data/stock_pool.json b/data/stock_pool.json index 3ac77ae..d5875c7 100644 --- a/data/stock_pool.json +++ b/data/stock_pool.json @@ -5,11 +5,21 @@ "中国石油股份": "00857.HK", "中国船舶": "sh600150", "云南锗业": "sz002428", + "埃斯顿": "sz002747", + "埃斯顿机器人": "sz002747", "优必选": "09880.HK", "兆易创新": "sh603986", + "华勤技术": "sh603296", "富瀚微": "sz300613", "寒武纪": "sh688256", + "沃尔核材": "sz002130", "永鼎股份": "sh600105", "海光信息": "sh688041", + "澜起科技": "sh688008", + "瑞芯微": "sh603893", + "申菱环境": "sz301018", + "长电": "sh600584", + "长电科技": "sh600584", + "麦格米特": "sz002851", "阿里巴巴": "09988.HK" -} \ No newline at end of file +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..b998323 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,22 @@ +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTypeScript from "eslint-config-next/typescript"; + +export default [ + { + ignores: [ + ".next/**", + ".next-dev/**", + ".next.bak-*/**", + "node_modules/**", + ], + }, + ...nextVitals, + ...nextTypeScript, + { + rules: { + "react-hooks/immutability": "off", + "react-hooks/preserve-manual-memoization": "off", + "react-hooks/set-state-in-effect": "off", + }, + }, +]; diff --git a/lib/api.ts b/lib/api.ts index efd1f64..bee1599 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -257,7 +257,7 @@ export function getHotspots(): Promise { } export function getGlobalNews(): Promise { - return requestWithTimeout("/api/news/global", 8000); + return requestWithTimeout("/api/news/global", 8000, { cache: "no-store" }); } export function webSearch(query: string, limit = 8): Promise { @@ -371,7 +371,7 @@ export async function deleteStrategyHolding(id: number): Promise { export function getStrategyHoldingsAnalysis(options?: { requestInit?: RequestInit; }): Promise { - return requestWithTimeout("/api/strategy-holdings/analysis", 8000, { + return requestWithTimeout("/api/strategy-holdings/analysis", 30000, { ...(options?.requestInit ?? {}), cache: "no-store", }); diff --git a/lib/types.ts b/lib/types.ts index 29c67ba..25aaf6e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -108,6 +108,15 @@ export type HotspotItem = { trend_direction: "up" | "down" | "flat"; ai_summary?: string | null; source: string; + heat_breakdown: { + trading_activity: number; + discussion_heat: number; + news_heat: number; + alert_count: number; + rank_hits: number; + attention_level: "focus" | "caution" | "watch"; + basis: string[]; + }; related_stocks: Array<{ stock_name: string; stock_code: string; @@ -252,6 +261,7 @@ export type StrategyHoldingAnalysis = { action_reason: string; trigger_hits: string[]; alerts: string[]; + analysis_status: "live" | "cached" | "degraded" | "local"; }; export type StrategyTodoItem = { diff --git a/package.json b/package.json index b4f2447..dd3e68e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "pm2:start": "pm2 start ecosystem.config.cjs", "pm2:stop": "pm2 delete openashare-api openashare-web", "pm2:flush": "pm2 flush", - "lint": "next lint" + "lint": "eslint app components lib next.config.ts" }, "engines": { "node": ">=20 <23" diff --git a/reports/us_tech_h2_2026_sector_analysis.html b/reports/us_tech_h2_2026_sector_analysis.html new file mode 100644 index 0000000..f6a68a5 --- /dev/null +++ b/reports/us_tech_h2_2026_sector_analysis.html @@ -0,0 +1,364 @@ + + + + + + 美股科技四大主题:2026 下半年理性投资分析 + + + +
+
+

美股科技四大主题:2026 下半年理性投资分析

+

覆盖机器人/物理 AI、CPU 与 AI 硬件、存储、量子计算。本文是研究优先级和估值温度框架,不是买卖建议;结论基于截至 2026-06-12 美国市场附近可见的公开信息与新闻源。

+ 投资视角:中期 6-12 个月,偏基本面 + 催化剂 + 估值纪律。 +
+ +
+

一页结论

+
+
+ 最强基本面顺风 +
存储
+

HBM、企业 SSD、近线 HDD 同时受 AI 数据中心拉动,但涨幅很大,追高需要用合约、价格和供给扩张验证。

+
+
+ 最大利润池 +
AI 硬件
+

NVDA、AVGO、TSM、ASML、AMD 仍是核心链条;下半年不怕需求差,怕预期太满和指引不够激进。

+
+
+ 分化最明显 +
机器人
+

真实收入在手术、仓储、自动化测试;人形机器人和物理 AI 更像远期期权,验证点在量产和单位经济。

+
+
+ 最像期权 +
量子
+

政策和技术里程碑会带来交易性行情,但多数纯量子公司仍是高倍销售额、亏损和融资风险。

+
+
+
我的排序:下半年风险调整后优先级为 存储 > AI 硬件 > 机器人/物理 AI > 量子计算。若只看长期技术天花板,量子和人形机器人很诱人;若看 2026 年下半年兑现度,存储和 AI 硬件更硬。
+
+ +
+

板块走势与估值温度

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
板块下半年基础情景估值温度核心验证点最大风险
机器人和物理 AI震荡上行但高度分化。真实订单/装机/程序量支持的公司更稳;人形机器人主题更多靠 Tesla 等事件驱动。偏贵但分层 ISRG 是高质量溢价,TSLA 是可选性溢价,SYM/TER 更看订单兑现。Optimus/robotaxi 进展、仓储自动化 backlog 转收入、da Vinci 程序量、工业自动化订单底部。演示热度不能转化为收入;制造业资本开支下行;监管和安全事故。
CPU 与 AI 硬件需求仍强,但近期 AVGO 指引引发市场对“beat-and-raise”预期的再定价。下半年更像高波动的基本面牛市。核心龙头不一定最贵 NVDA 已被部分资料描述为约 20x forward P/E;AMD/ARM/AVGO 是预期敏感型。Blackwell/Rubin 供给、AMD GPU 第二来源份额、AVGO ASIC 客户集中度、TSM/ASML 产能与价格。云厂商 capex 降速、自研 ASIC 替代、出口限制、利率上行导致长久期压缩。
存储基本面最顺。DRAM/HBM、NAND、HDD 均从 AI 推理、训练、日志、数据湖受益;但交易已经拥挤。盈利倍数仍低但周期性强 MU 被报道约 10x forward P/E;WDC/STX/SNDK 涨幅后需防周期顶部估值错觉。DRAM/NAND 合约价、HBM 份额、HDD LTAs、企业 SSD 价格、库存重建。供给扩张、客户双重采购后去库存、AI 资本开支降温、价格涨幅挤压下游需求。
量子计算技术和政策催化多,股价弹性大;但 2026 年下半年仍不宜按成熟盈利模型估值。极高期权价值 IONQ/RGTI/QBTS 等更多看 2027-2030 收入折现,纯 PE 不适用。IonQ 256-qubit 里程碑、DARPA/政府项目、D-Wave bookings、Rigetti fidelity/资本开支、IBM/Google 路线图。收入体量太小、亏损扩大、融资稀释、技术路线不确定、新闻驱动后的回撤。
+
+ +
+

机器人和物理 AI

+
+

走势判断

+

下半年主线不是“所有机器人都涨”,而是“能证明落地收入的机器人涨得更稳”。医疗机器人和仓储自动化已经有收入与装机基础;人形机器人和具身智能的天花板更高,但短期估值主要靠里程碑。Tesla 的 Optimus、robotaxi 和 AI 芯片叙事会继续影响板块风险偏好,但目前仍需要把演示、试生产和可规模化利润分开看。

+

候选标的

+ + + + + + + + + + + +
标的暴露路径估值/状态下半年看点研究优先级
NVDA机器人训练/仿真/边缘计算、Jetson、Isaac、AI 工厂基础设施AI 硬件龙头,forward P/E 已从狂热区回落,但仍受数据中心周期支配物理 AI 软件栈是否从叙事转订单A
TSLAOptimus、robotaxi、FSD、边缘推理芯片机器人期权大,汽车基本面承压时估值更依赖未来叙事Optimus 量产线、Cybercab/robotaxi 城市扩张、监管事件B
ISRGda Vinci 和 Ion 手术机器人高质量成长溢价;Q1 2026 程序量和收入增长支持估值da Vinci 5 渗透、程序量增长、国际扩张A
SYM仓储自动化系统,Walmart 等客户高增长但客户集中度和项目节奏风险较高backlog 转收入、毛利率、客户扩展B
TER协作机器人 Universal Robots/MiR,另有半导体测试机器人业务不是唯一驱动,近期更多由 AI 半导体测试支撑AI 测试需求与工业机器人周期修复A-
ROK工业自动化、工厂控制、制造业数字化更像工业周期复苏标的,不是纯 AI 机器人制造业 PMI、订单、软件/服务占比B
AMZN仓储机器人、物流自动化、AWS AI 工作负载机器人价值被 AWS 和零售主业务稀释物流效率改善是否体现在 marginB
+
+
+ +
+

CPU 与 AI 硬件

+
+

走势判断

+

AI 硬件仍是利润池最大的板块,但下半年会从“只要 AI 就重估”转向“谁的订单、产能、毛利和客户集中度能经得住高预期”。近期 Broadcom 即使公布强劲 AI 收入,仍因指引没有超过市场高预期而引发板块波动,这说明估值风险已经从需求证伪转为预期管理。

+

候选标的

+ + + + + + + + + + + + +
标的暴露路径估值/状态下半年看点研究优先级
NVDAGPU、网络、Grace/Vera CPU、AI rack-scale 系统资料显示 forward P/E 约 20x,若盈利增长兑现,龙头估值反而不极端Blackwell Ultra、Rubin、网络收入、毛利率A
AMDEPYC CPU、Instinct GPU、AI 第二来源股价已反映 GPU 份额提升预期,仍低于 NVDA 生态确定性Meta/OpenAI 等大客户份额、MI 系列供给、CPU 份额A-
AVGO定制 ASIC、网络、VMware 基础设施软件AI ASIC 高成长但客户集中,近期市值回撤显示预期很高Google/OpenAI/Anthropic 等 ASIC 订单和指引A-
TSM先进制程代工、CoWoS、AI 芯片供应瓶颈高质量供应链核心,估值取决于先进节点价格和资本开支回报3nm/2nm 价格、CoWoS 扩产、客户 capexA
ASMLEUV/High-NA lithography,先进制程设备瓶颈垄断溢价明显,欧洲科技稀缺性强EUV 出货、China 限制、客户扩产节奏A-
ARMCPU IP,数据中心和边缘 AI 架构授权高倍数成长股,估值对授权增长和 royalty rate 很敏感服务器 CPU IP 渗透、手机复苏、AI PCB
INTCCPU、foundry、先进封装、潜在 AI 供应链替代反转叙事增强,但执行风险仍高foundry 客户、制程节点、AI CPU 和封装订单B
MRVLAI 网络、定制芯片、光互连相关 silicon估值看 AI 网络增速,周期业务拖累仍需观察custom silicon pipeline、云客户订单B
+
+
+ +
+

存储后期走势

+
+

走势判断

+

存储是 2026 下半年最硬的基本面链条之一。AI 推理、长上下文、RAG、日志、训练数据、checkpoint、视频和合成数据都会放大内存与存储需求。与传统周期不同的是,部分客户正在签长期采购协议,库存可见性变强。但这不代表周期消失,股价上涨后,真正的风险是“好消息已被提前资本化”。

+

候选标的

+ + + + + + + + + + +
标的暴露路径估值/状态下半年看点研究优先级
MUDRAM、HBM、NAND,AI 内存核心受益者报道显示 forward P/E 约 10x,但这是周期高盈利下的低倍数HBM 份额、DRAM 合约价、FY2027 盈利峰值可持续性A
WDCHDD/企业存储,AI 数据湖和冷/温数据强需求已被大幅重估,估值需看 2027-2028 LTAsHDD 供给售罄、价格、exabyte 增长A-
STX近线 HDD,云和 AI 数据中心容量与 WDC 类似,经营杠杆高,回撤也会快HAMR 量产、云客户 LTAs、毛利率A-
SNDKNAND/企业 SSD,AI 推理和高性能存储涨幅巨大后预期很高,适合等回撤或盈利确认NAND 价格、企业 SSD 供给、客户集中B
PSTG全闪存阵列、企业数据云、AI 数据服务收入增长强,但组件成本和产品毛利压力需跟踪云大客户、subscription ARR、毛利修复B
NTAP企业存储、混合云数据管理更稳健,AI 弹性小于 MU/WDC/STXAI 数据湖项目、云存储服务增长B
+
+
+ +
+

量子计算未来前景

+
+

走势判断

+

长期看,量子计算可能在材料、药物、优化、密码、金融建模和国防领域形成新计算范式。但 2026 下半年,多数纯量子股票仍是“技术里程碑 + 政策资金 + 风险偏好”的组合,而不是“收入和利润驱动”的组合。适合用小仓位、篮子和事件风控,而不是重仓单一技术路线。

+

候选标的

+ + + + + + + + + + + +
标的技术/商业路径估值/状态下半年看点研究优先级
IONQ trapped-ion 量子计算、网络、传感、安全收入增速高但仍亏损;2026 指引提升至约 2.6-2.7 亿美元256-qubit 系统、收购整合、商业客户占比B+
RGTI超导量子芯片,自有 fab,全栈系统报道曾指约 95x 2027E sales,资本开支和技术执行压力高fidelity、DARPA/政府项目、现金消耗C
QBTS量子退火,向 gate-model 扩展收入小且波动,bookings 是更重要指标系统销售、bookings 转收入、100 logical qubits 路线B-
QUBT光子/量子软硬件相关,早期商业化极高风险小票,估值主要是概念和现金跑道真实订单、融资、技术验证C
ARQQ后量子安全/加密相关更偏安全软件/服务期权,财务兑现不足政府/企业安全订单、现金流C
IBM量子云、超导路线、企业客户和研究生态盈利型大盘,量子不是估值主驱动路线图、企业应用、软件订阅协同B
GOOGL/MSFT/HON大厂量子研发、云生态、Quantinuum 等暴露量子期权被主业覆盖,风险回报更稳但弹性低技术突破、云服务商业化、政府项目B
+
+
+ +
+

组合化思路

+
+
+

偏稳健

+
    +
  • 核心:NVDA、TSM、ASML、MU、ISRG。
  • +
  • 卫星:AVGO、AMD、WDC/STX、PSTG、TER。
  • +
  • 量子只用 IBM/GOOGL/MSFT 或小比例 IONQ 观察仓。
  • +
+
+
+

偏进攻

+
    +
  • 提高 MU、WDC、STX、AMD、AVGO、TSLA 权重。
  • +
  • 用 IONQ、RGTI、QBTS 做小仓位事件篮子,而不是单押。
  • +
  • 严格设置季度验证点:收入指引、订单、价格、现金消耗。
  • +
+
+
+
风险纪律:存储和 AI 硬件可以用盈利修正来持有;机器人要用订单和装机验证;量子必须用现金跑道和里程碑验证。没有验证的主题,只能是交易,不应被当成长期复利资产。
+
+ +
+

主要来源

+ +

注:部分实时市场数据源存在访问限制,因此估值采用公开报道中的倍数、价格与市值片段,并以“估值温度”表达。若用于实际交易,应在下单前用券商、FactSet、Bloomberg、LSEG 或公司财报再次刷新价格、EV、净现金和 forward estimates。

+
+
+ + diff --git a/tests/test_api_app.py b/tests/test_api_app.py index e3270a4..8fbd744 100644 --- a/tests/test_api_app.py +++ b/tests/test_api_app.py @@ -514,6 +514,44 @@ def test_strategy_holdings_endpoints(monkeypatch): assert refresh_response.json()["total_pnl"] == 750 +def test_degraded_strategy_scores_are_not_reusable(tmp_path): + service = services_module.StrategyService( + stock_service=SimpleNamespace(), + news_service=SimpleNamespace(), + hotspot_service=SimpleNamespace(), + market_service=SimpleNamespace(), + store=services_module.StrategyHoldingStore(db_path=str(tmp_path / "strategy.db")), + portfolio_store=services_module.PortfolioStore(db_path=str(tmp_path / "portfolio.db")), + ) + holding = StrategyHolding( + id=1, + strategy_key="can_slim", + stock_code="sh688041", + stock_name="海光信息", + entry_price=120, + quantity=100, + status="holding", + ) + degraded = StrategyHoldingAnalysis( + holding=holding, + current_price=120, + market_value=12000, + pnl=0, + pnl_pct=0, + strategy_score=StrategyScoreBreakdown(c=72, a=72, n=72, s=72, l=72, i=72, m=72, total=72), + thesis_status="active", + action_label="继续持有", + action_reason="行情暂不可用", + analysis_status="degraded", + ) + + summary = service._summarize_holdings_analysis([degraded]) + + assert service._has_reusable_holding_analysis(degraded) is False + assert service._has_reusable_holding_analysis(degraded.model_copy(update={"analysis_status": "live"})) is True + assert summary.average_score == 0 + + def test_portfolio_missing_mutations_return_404(monkeypatch): monkeypatch.setattr("api.main._require_demo_access", lambda request, feature_name: None) diff --git a/tests/test_us_market.py b/tests/test_us_market.py new file mode 100644 index 0000000..1b84991 --- /dev/null +++ b/tests/test_us_market.py @@ -0,0 +1,137 @@ +"""美股支持的单元测试:代码身份系统、搜索、数据获取路由与解析。""" + +import pandas as pd +import pytest + +from ashare.data import DataFetcher +from ashare.search import StockSearcher +from ashare.stock_pool import ( + extract_symbol, + get_exchange_label, + get_market_label, + get_monitor_support_level, + infer_market, + is_us_stock, + is_valid_stock_code, + normalize_stock_code, +) + + +def test_normalize_us_codes(): + assert normalize_stock_code("AAPL") == "US.AAPL" + assert normalize_stock_code("aapl") == "US.AAPL" + assert normalize_stock_code("US.AAPL") == "US.AAPL" + assert normalize_stock_code("BRK.B") == "US.BRK.B" + assert normalize_stock_code("us.brk.b") == "US.BRK.B" + # 不影响 A股/港股 + assert normalize_stock_code("600036.SH") == "sh600036" + assert normalize_stock_code("700.hk") == "00700.HK" + # 中文名不会被误判成 ticker + assert normalize_stock_code("腾讯") == "腾讯" + + +def test_us_identity_helpers(): + assert is_valid_stock_code("AAPL") is True + assert is_valid_stock_code("US.BRK.B") is True + assert is_us_stock("NVDA") is True + assert is_us_stock("sh600036") is False + assert infer_market("AAPL") == "us" + assert get_market_label("AAPL") == "美股" + assert get_exchange_label("AAPL") == "美股" + assert extract_symbol("US.BRK.B") == "BRK.B" + # 美股监控为完整级别(技术面),A股 特有数据会自动跳过 + assert get_monitor_support_level("AAPL") == "full" + + +def test_search_resolves_us_stocks(): + searcher = StockSearcher() + assert searcher.search_stocks("苹果", max_results=1)[0]["code"] == "US.AAPL" + assert searcher.search_stocks("AAPL", max_results=1)[0]["code"] == "US.AAPL" + assert searcher.search_stocks("英伟达", max_results=1)[0]["code"] == "US.NVDA" + # 未收录的 ticker 兜底为合法美股代码 + fallback = searcher.search_stocks("ROKU", max_results=1) + assert fallback and fallback[0]["code"] == "US.ROKU" + assert fallback[0]["market"] == "美股" + + +def test_us_yf_symbol_converts_class_shares(): + assert DataFetcher._us_yf_symbol("US.AAPL") == "AAPL" + assert DataFetcher._us_yf_symbol("US.BRK.B") == "BRK-B" + + +def test_normalize_us_dataframe_handles_multiindex(): + fetcher = DataFetcher() + idx = pd.to_datetime(["2026-01-02", "2026-01-03"]) + columns = pd.MultiIndex.from_product( + [["Open", "High", "Low", "Close", "Volume"], ["AAPL"]] + ) + raw = pd.DataFrame( + [[1, 2, 0.5, 1.5, 1000], [1.5, 2.5, 1.0, 2.0, 1200]], + index=idx, + columns=columns, + ) + out = fetcher._normalize_us_dataframe(raw, count=10, source="test") + assert list(out.columns) == ["open", "close", "high", "low", "volume"] + assert len(out) == 2 + assert out["close"].iloc[-1] == 2.0 + assert out.index.name == "" + + +def test_fetch_stock_data_routes_us_through_us_pipeline(monkeypatch): + fetcher = DataFetcher() + sentinel = pd.DataFrame( + {"open": [1.0], "close": [1.1], "high": [1.2], "low": [0.9], "volume": [10.0]}, + index=pd.to_datetime(["2026-01-02"]), + ) + + called = {} + + def fake_us(code, count, frequency): + called["code"] = code + return sentinel + + monkeypatch.setattr(fetcher, "_fetch_stock_data_us", fake_us) + out = fetcher.fetch_stock_data("AAPL") + assert called["code"] == "US.AAPL" + assert out is sentinel + + +def test_finnhub_parsing(monkeypatch): + fetcher = DataFetcher() + + class FakeResp: + def raise_for_status(self): + pass + + def json(self): + return { + "s": "ok", + "o": [1.0, 1.5], + "c": [1.1, 2.0], + "h": [1.2, 2.5], + "l": [0.9, 1.0], + "v": [100, 120], + "t": [1735776000, 1735862400], + } + + monkeypatch.setattr( + "ashare.config.Config", + type("C", (), {"finnhub_api_key": "test-key", "__init__": lambda self: None}), + ) + + import requests + + monkeypatch.setattr(requests, "get", lambda *a, **k: FakeResp()) + out = fetcher._fetch_stock_data_finnhub("US.AAPL", count=10, frequency="1d") + assert out is not None + assert list(out.columns) == ["open", "close", "high", "low", "volume"] + assert out["close"].iloc[-1] == 2.0 + + +def test_finnhub_skipped_without_key(monkeypatch): + fetcher = DataFetcher() + monkeypatch.setattr( + "ashare.config.Config", + type("C", (), {"finnhub_api_key": None, "__init__": lambda self: None}), + ) + assert fetcher._fetch_stock_data_finnhub("US.AAPL", count=10, frequency="1d") is None