diff --git a/.cursor/rules/long-term-trading-discipline.mdc b/.cursor/rules/long-term-trading-discipline.mdc new file mode 100644 index 0000000..25c8214 --- /dev/null +++ b/.cursor/rules/long-term-trading-discipline.mdc @@ -0,0 +1,16 @@ +--- +description: 长线持仓、核心仓和卫星仓交易建议铁律 +globs: "src/**/*.py,frontend/**/*.ts,frontend/**/*.tsx,prompts/**/*.txt,docs/**/*.md" +alwaysApply: false +--- + +# 长线交易铁律 + +- 长线不是短线止损的慢版本。标的设置为长线后,默认按长期逻辑、仓位计划和分批加仓纪律判断,不因单日波动或单一技术死叉直接建议清仓。 +- 越跌越买必须有边界。任何长线加仓建议都必须检查最大仓位、可用资金、已触发档位和今日交易流水。 +- 核心仓与卫星仓必须分开。核心仓服务长期逻辑,卫星仓可做波段增强;减仓/止盈建议默认只动卫星仓,除非长期逻辑失效。 +- 今日交易流水优先级最高。今日已买入则不重复建议加仓,今日已卖出则不重复建议减仓/清仓。 +- 长线核心仓只有在长期逻辑失效、重大基本面恶化、仓位超过上限,或趋势级别破坏且无修复迹象时,才可建议减仓。 +- 达到最大仓位后,继续下跌只能建议观察、暂停加仓或复核长期逻辑,不得继续机械补仓。 +- AI Prompt 和结构化上下文必须同时携带长线计划信息;不要只用“长线”二字让模型自由发挥。 +- 所有交易建议只作为辅助决策,不得实现真实自动下单。 diff --git a/.cursor/rules/no-read-dependencies.mdc b/.cursor/rules/no-read-dependencies.mdc new file mode 100644 index 0000000..5f15d9a --- /dev/null +++ b/.cursor/rules/no-read-dependencies.mdc @@ -0,0 +1,29 @@ +--- +description: 铁律:默认禁止读取依赖与构建产物目录 +alwaysApply: true +--- + +# 依赖目录读取铁律 + +**默认禁止**读取、搜索、遍历以下目录及其子文件。违反本规则会浪费 token、拖慢响应,且对完成任务毫无帮助。 + +## 禁止默认访问的目录 + +- Python 虚拟环境:`.venv/`、`venv/`、`env/` +- Node 依赖:`node_modules/`、`frontend/node_modules/`、`pnpm-store/` +- 构建产物:`dist/`、`build/`、`static/`、`frontend/dist/`、`frontend/.vite/`、`*.egg-info/` +- 缓存:`__pycache__/`、`.pytest_cache/`、`.mypy_cache/`、`.ruff_cache/` +- 运行时数据:`data/`(数据库、缓存、日志等) + +## 必须遵守 + +1. **不要**对以上目录使用 Read、Grep、Glob、SemanticSearch 或等价工具。 +2. **不要**把依赖源码当作项目代码来阅读、分析或修改。 +3. 需要了解依赖版本或 API 时,只读项目内的声明文件:`requirements.txt`、`pyproject.toml`、`package.json`、`pnpm-lock.yaml`;或查官方文档 / Context7,**不要**钻进 `node_modules` 或 `.venv`。 +4. 只有用户**明确要求**排查某个依赖包内部问题时,才可定向读取该包的单个文件,且范围尽量小。 + +## 正确做法示例 + +- 查 FastAPI 用法 → 读 `requirements.txt` + 官方文档,不读 `.venv/lib/...` +- 查前端组件库 API → 读 `frontend/package.json` + 文档,不读 `node_modules/` +- 理解项目逻辑 → 只读 `src/`、`frontend/src/`、`tests/`、`prompts/` 等自有代码 diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..ac597fa --- /dev/null +++ b/.cursorignore @@ -0,0 +1,23 @@ +# 依赖与虚拟环境 +.venv/ +venv/ +env/ +node_modules/ +**/node_modules/ + +# 构建产物 +dist/ +build/ +static/ +frontend/dist/ +frontend/.vite/ +*.egg-info/ + +# 缓存 +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# 运行时数据(体积大、非源码) +data/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acf36c6..45967fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: release on: push: tags: - # PanWatch tags are like 0.2.3 (no "v" prefix) + # AlphaMind tags are like 0.2.3 (no "v" prefix) - '*.*.*' workflow_dispatch: inputs: @@ -103,7 +103,7 @@ jobs: type=raw,value=${{ steps.vars.outputs.version_no_v }},enable=${{ steps.vars.outputs.version_no_v != steps.vars.outputs.version }} type=raw,value=latest,enable=${{ steps.vars.outputs.push_latest == 'true' }} labels: | - org.opencontainers.image.title=PanWatch + org.opencontainers.image.title=AlphaMind org.opencontainers.image.version=${{ steps.vars.outputs.version }} org.opencontainers.image.source=${{ github.repositoryUrl }} @@ -196,7 +196,7 @@ jobs: COMMITS_ENCODED=$(echo "$COMMITS_ESCAPED" | sed 's/$/%0A/g' | tr -d '\n') # 构建消息 - MESSAGE="📊 *PanWatch ${TAG}* 发布%0A%0A" + MESSAGE="📊 *AlphaMind ${TAG}* 发布%0A%0A" MESSAGE="${MESSAGE}🔗 [查看完整说明](${RELEASE_URL})%0A%0A" MESSAGE="${MESSAGE}*变更内容*:%0A${COMMITS_ENCODED}" diff --git a/.gitignore b/.gitignore index 92feb99..a165df9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist/ build/ static/ data/ +reports/ # Frontend node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 34fc7b9..bdc3947 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,13 @@ # Repository Guidelines +## 铁律:禁止默认读取依赖目录 + +**默认不要**读取、搜索或遍历依赖与构建产物目录。这些目录体积巨大,对理解项目代码没有帮助。 + +禁止默认访问:`.venv/`、`venv/`、`node_modules/`、`dist/`、`build/`、`static/`、`__pycache__/`、`data/` 等。 + +需要依赖信息时,只读 `requirements.txt`、`pyproject.toml`、`package.json` 等声明文件,或查官方文档;**不要**钻进 `node_modules` 或 `.venv`。仅当用户明确要求排查某个依赖包内部问题时,才可定向读取单个文件。 + ## Project Structure & Module Organization - `src/agents/` — Agent implementations (business logic). Add new agents here. - `src/collectors/` — Data collectors (quotes, kline, news, etc.). @@ -32,6 +40,13 @@ - Coverage: target meaningful coverage for new modules (no strict threshold yet, but include happy-path and error cases). - Fixtures: use factory helpers for DB models; avoid network calls (mock collectors and AI clients). +## Trading Discipline +- Long-term position architecture lives in `docs/long-term-position-architecture.md`; follow it when changing trading logic, prompts, portfolio context, or related UI. +- A long-term holding is not a slower short-term stop-loss flow. Respect the user's thesis, target weight, max weight, staged add plan, and recent trade history. +- Core and satellite positions must be treated separately: protect core positions for the long-term thesis; use satellite positions for swing adjustments. +- Never suggest unlimited averaging down. Any add suggestion must check max allocation, available cash, triggered levels, and today's trades. +- Today's trade records take priority: do not repeat add suggestions after a same-day buy, and do not repeat reduce/sell suggestions after a same-day sell. + ## Commit & Pull Request Guidelines - Commit format: `: ` where type ∈ `{feat, fix, docs, refactor, style, test}`. Example: `feat: add intraday monitor agent`. diff --git a/CLAUDE.md b/CLAUDE.md index 9c5ca54..1d74ea3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## What is PanWatch +## 铁律:禁止默认读取依赖目录 -PanWatch (盯盘侠) is a self-hosted AI stock assistant with real-time market monitoring, technical analysis, multi-account portfolio management, and paper trading. It supports A-shares (CN), Hong Kong (HK), and US markets. +默认不要读取、搜索或遍历 `.venv/`、`node_modules/`、`dist/`、`build/`、`static/`、`__pycache__/`、`data/` 等依赖与构建产物目录。需要依赖信息时读 `requirements.txt` / `pyproject.toml` / `package.json` 或查官方文档,不要钻进依赖源码树。仅当用户明确要求排查某个依赖包时,才可定向读取单个文件。 + +## What is AlphaMind + +AlphaMind (智盘 Alpha) is a self-hosted AI stock assistant with real-time market monitoring, technical analysis, multi-account portfolio management, and paper trading. It supports A-shares (CN), Hong Kong (HK), and US markets. ## Build & Run Commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59e8b76..bf6ea06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # 贡献指南 -感谢你对 PanWatch 的兴趣!本文档将指导你如何贡献代码,特别是如何编写 Agent 和数据源。 +感谢你对 AlphaMind 的兴趣!本文档将指导你如何贡献代码,特别是如何编写 Agent 和数据源。 ## 目录 @@ -15,7 +15,7 @@ ## 项目结构 ``` -PanWatch/ +AlphaMind/ ├── src/ │ ├── agents/ # Agent 实现 │ │ ├── base.py # 基类和数据结构 @@ -55,7 +55,7 @@ pnpm dev ## 编写 Agent -Agent 是 PanWatch 的核心分析单元,负责采集数据、调用 AI 分析、发送通知。 +Agent 是 AlphaMind 的核心分析单元,负责采集数据、调用 AI 分析、发送通知。 ### 1. 创建 Agent 文件 diff --git a/Dockerfile b/Dockerfile index db655e8..6b43c24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# PanWatch Dockerfile +# AlphaMind Dockerfile # 多阶段构建,减小最终镜像大小 # ===== Stage 1: 前端构建 ===== diff --git a/Makefile b/Makefile index 15c5999..31e5cab 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ # - 前端::5183(与 BeeCount-Cloud 的 :5173 错开避免冲突) help: - @echo "PanWatch 开发命令:" + @echo "AlphaMind 开发命令:" @echo " make setup-backend 创建 venv 并安装后端依赖" @echo " make dev-api 启动后端(:8000,自动 setup-backend)" @echo " make dev-web 启动前端(:5183,自动 pnpm install)" diff --git a/README.md b/README.md index 471ec0f..cb42737 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 盯盘侠 PanWatch +# 智盘 Alpha · AlphaMind **自托管 AI 盯盘助手 · 集成 [TradingAgents](https://github.com/TauricResearch/TradingAgents) 多 Agent 投资决策** — A 股 / 港股 / 美股实时监控、持仓管理、智能分析、全渠道推送 @@ -8,7 +8,7 @@ [![Last commit](https://img.shields.io/github/last-commit/TNT-Likely/PanWatch)](https://github.com/TNT-Likely/PanWatch/commits/main) [![PWA](https://img.shields.io/badge/PWA-installable-5A0FC8?logo=pwa&logoColor=white)](https://github.com/TNT-Likely/PanWatch) -![盯盘侠 PanWatch · TradingAgents 深度分析演示](docs/screenshots/tradingagents-demo.gif) +![智盘 Alpha · AlphaMind · TradingAgents 深度分析演示](docs/screenshots/tradingagents-demo.gif) > 🧠 **持仓页点一下 → TradingAgents 9-Agent 投研团队接力分析 → 看多看空辩论 → 风控审查 → PM 决策书,3-5 分钟一条完整推理链,结论直推到你的 IM。** @@ -31,7 +31,7 @@ -> 💡 如果盯盘侠对你有帮助,点右上角 ⭐ **Star** 支持一下 —— 这是对开源项目最好的鼓励,也能让更多人发现它。 +> 💡 如果智盘 Alpha 对你有帮助,点右上角 ⭐ **Star** 支持一下 —— 这是对开源项目最好的鼓励,也能让更多人发现它。 ## 🧠 深度分析:TradingAgents 多 Agent 决策 @@ -41,7 +41,7 @@ - 3-5 分钟输出完整推理链,结论同步推送到 Telegram / 微信 / 钉钉 - 默认 deepseek-chat,单次 ~$0.05,月度预算可控 -## 为什么选择盯盘侠? +## 为什么选择智盘 Alpha? - **数据私有** — 自托管部署,持仓数据不经过任何第三方 - **AI 原生** — 不是简单的指标堆砌,而是让 AI 理解你的持仓、风格和目标 @@ -216,7 +216,7 @@ cd frontend && pnpm install && pnpm dev # 前端 :5183 ## 捐赠支持 -如果你觉得 PanWatch 有帮助,欢迎请作者喝杯咖啡: +如果你觉得 AlphaMind 有帮助,欢迎请作者喝杯咖啡: | 微信赞赏 | 支付宝 | |:---:|:---:| diff --git a/build.sh b/build.sh index 204326b..334033a 100755 --- a/build.sh +++ b/build.sh @@ -11,7 +11,7 @@ NC='\033[0m' # No Color VERSION=${1:-"latest"} IMAGE_NAME="sunxiao0721/panwatch" -echo -e "${GREEN}🚀 PanWatch 构建脚本${NC}" +echo -e "${GREEN}🚀 AlphaMind 构建脚本${NC}" echo -e "版本: ${YELLOW}${VERSION}${NC}" echo "" diff --git a/config/lmd_industry_chains.yaml b/config/lmd_industry_chains.yaml new file mode 100644 index 0000000..23d35b6 --- /dev/null +++ b/config/lmd_industry_chains.yaml @@ -0,0 +1,285 @@ +# 老马投资研究 · AI 行情轮动分类框架 +# 沿 AI 物理连接顺序投资(以 GPU 为起点),非传统产业链上中下游分层 +# +# 主轮动:AI算力 → 云服务和大模型 → 软件应用 → 物理AI +# A股建议跳过「云&大模型」「软件应用」环节(美股例外) +# +# AI算力细分(轮动次序): +# GPU → CPO → HBM → PCB → 液冷 → 半导体&设备 → 服务器 → IDC → 电力 +chains: + AI: + display_name: 人工智能 + sector_keywords: + - 人工智能 + - AIGC + - 大模型 + - 算力 + - 智算 + - 机器学习 + - 深度学习 + - 生成式人工智能 + - 多模态 + - 智能体 + - AGI + - AI应用 + - 人工智能应用 + - 算力租赁 + - 东数西算 + - AI芯片 + - 算力芯片 + - AI算力 + - 智算中心 + - 英伟达概念 + - 华为昇腾 + - 昇腾 + - ChatGPT + - GPT + - Sora概念 + - 文心一言 + - 液冷服务器 + - AI服务器 + - 人形机器人 + - 物理AI + layers: + gpu: + label: GPU + order: 1 + description: AI算力起点,GPU与算力芯片 + keywords: + - GPU + - AI芯片 + - 算力芯片 + - 英伟达概念 + - 华为昇腾 + - 昇腾 + symbols: + CN: + - "688256" # 寒武纪 + - "688041" # 海光信息 + - "300474" # 景嘉微 + HK: [] + US: + - NVDA + - AMD + - INTC + cpo: + label: CPO + order: 2 + description: 光模块与光电共封装,算力互联扩容 + keywords: + - CPO + - 光模块 + - 光通信 + - 高速互联 + - 共封装光学 + symbols: + CN: + - "300308" # 中际旭创 + - "300502" # 新易盛 + - "002281" # 光迅科技 + HK: [] + US: + - AVGO + - COHR + - LITE + - MRVL + - ANET + hbm: + label: HBM + order: 3 + description: 高带宽存储,GPU显存配套 + keywords: + - HBM + - 存储芯片 + - 存储器 + - 高带宽内存 + symbols: + CN: + - "603986" # 兆易创新 + HK: [] + US: + - MU + pcb: + label: PCB + order: 4 + description: AI服务器高速印制电路板 + keywords: + - PCB + - 印制电路板 + - 高速PCB + symbols: + CN: + - "002463" # 沪电股份 + - "002916" # 深南电路 + - "002436" # 兴森科技 + HK: [] + US: [] + liquid_cooling: + label: 液冷 + order: 5 + description: 高功率密度散热,液冷基础设施 + keywords: + - 液冷 + - 液冷服务器 + - 散热 + - 温控 + symbols: + CN: [] + HK: [] + US: [] + semi_pcb_equip: + label: 材料&设备 + order: 6 + description: 半导体制造与PCB上游材料设备(当前轮动热点) + keywords: + - 半导体设备 + - 刻蚀 + - 光刻 + - 封测 + - 晶圆 + - 电子化学品 + - 覆铜板 + symbols: + CN: + - "002371" # 北方华创 + - "688981" # 中芯国际 + - "002049" # 紫光国微 + - "603501" # 韦尔股份 + - "600584" # 长电科技 + HK: + - "0981" # 中芯国际 + - "1347" # 华虹半导体 + US: + - ASML + - AMAT + - LRCX + - TSM + server: + label: 服务器 + order: 7 + description: AI服务器整机与算力交付(材料设备后的下一站) + keywords: + - AI服务器 + - 服务器 + - 算力服务器 + symbols: + CN: + - "601138" # 工业富联 + - "000977" # 浪潮信息 + - "603019" # 中科曙光 + HK: [] + US: [] + idc: + label: IDC + order: 8 + description: 智算中心与数据中心,算力落地载体 + keywords: + - IDC + - 数据中心 + - 智算中心 + - 算力租赁 + - 东数西算 + symbols: + CN: + - "603881" # 数据港 + - "300383" # 光环新网 + - "000938" # 紫光股份 + HK: [] + US: [] + power: + label: 电力 + order: 9 + description: 算力用电与电力基础设施 + keywords: + - 电力 + - 智能电网 + - 特高压 + - 储能 + - 核电 + - 变压器 + symbols: + CN: [] + HK: [] + US: [] + cloud_llm: + label: 云&大模型 + order: 10 + description: 云服务与大模型(A股建议跳过,美股可投) + a_share_skip: true + keywords: + - 大模型 + - 云计算 + - 云服务 + - ChatGPT + - GPT + - 文心一言 + - AI平台 + symbols: + CN: + - "002230" # 科大讯飞 + HK: + - "0700" # 腾讯 + - "9988" # 阿里 + - "9888" # 百度 + US: + - MSFT + - GOOGL + - GOOG + - AMZN + - ORCL + - PLTR + software_app: + label: 软件应用 + order: 11 + description: AI软件与应用层(A股建议跳过,美股可投) + a_share_skip: true + keywords: + - AIGC + - AI应用 + - 人工智能应用 + - 办公软件 + - 数字人 + - 虚拟数字人 + symbols: + CN: + - "688111" # 金山办公 + - "002410" # 广联达 + - "600570" # 恒生电子 + - "300058" # 蓝色光标 + - "002131" # 利欧股份 + - "603533" # 掌阅科技 + - "002908" # 德生科技 + - "600588" # 用友网络 + HK: [] + US: + - META + - ADBE + - NOW + - CRM + - SNOW + - CRWD + physical_ai: + label: 物理AI + order: 12 + description: 机器人、智驾等物理世界AI落地(算力链后的主战场) + keywords: + - 物理AI + - 人形机器人 + - 机器人 + - 智能驾驶 + - 自动驾驶 + - 机器视觉 + - 智能座舱 + symbols: + CN: + - "300024" # 机器人 + - "002747" # 埃斯顿 + - "002920" # 德赛西威 + - "688169" # 石头科技 + - "300496" # 中科创达 + HK: + - "9866" # 蔚来 + - "9868" # 小鹏 + US: + - TSLA + - UBER diff --git a/docs/long-term-position-architecture.md b/docs/long-term-position-architecture.md new file mode 100644 index 0000000..a938e85 --- /dev/null +++ b/docs/long-term-position-architecture.md @@ -0,0 +1,153 @@ +# 长线持仓与核心/卫星仓架构方案 + +## 背景 + +AlphaMind 当前已经支持真实持仓的交易风格字段:`short`、`swing`、`long`。该字段已经能进入持仓 API、前端持仓表单、组合上下文和盘中监测 Agent,但目前仍是粗粒度标签,尚不能表达长线持有、越跌越买、核心仓/卫星仓、目标仓位和加仓纪律。 + +本方案目标是在现有持仓体系上增加“长期投资计划”,让系统在分析长线标的时,不再套用短线止损止盈逻辑,而是按用户预设的长期逻辑、仓位上限和分批加仓规则给出建议。 + +## 设计原则 + +- 长线不是无条件死扛,必须绑定仓位上限、分批预算和长期逻辑。 +- 核心仓与卫星仓分开判断:核心仓服务长期逻辑,卫星仓服务波段增强。 +- AI 建议必须尊重用户的持仓计划,短线指标不能单独推翻长线计划。 +- 已发生的今日交易流水优先级高于新建议,避免重复加仓、重复减仓和来回打脸。 +- 所有建议只做辅助决策,不自动下真实交易指令。 + +## 现有基础 + +### 后端 + +- `src/web/models.py` 中 `Position.trading_style` 已支持交易风格。 +- `src/web/api/accounts.py` 的持仓创建、更新、列表和组合汇总已透出 `trading_style`。 +- `src/core/context_builder.py` 与 `src/agents/tradingagents/portfolio_context.py` 已能把持仓风格带入 Agent 上下文。 +- `src/agents/intraday_monitor.py` 已把短线、波段、长线标签写入盘中分析提示。 +- `src/core/position_trades_context.py` 已能汇总今日和近期持仓变动流水。 + +### 前端 + +- `frontend/src/pages/Stocks.tsx` 的持仓表单已提供“短线 / 波段 / 长线”选择。 +- 持仓列表已能展示交易风格标签。 + +## 目标能力 + +### 股票级投资计划 + +用于描述一只股票的默认长期策略,适合自选股和未建仓股票。 + +建议字段: + +- `long_term_enabled`: 是否启用长线逻辑。 +- `portfolio_role`: `core` 核心仓、`satellite` 卫星仓、`watch` 观察。 +- `target_weight_pct`: 目标仓位占组合资产比例。 +- `max_weight_pct`: 最大仓位占组合资产比例。 +- `add_plan`: 分批加仓规则。 +- `reduce_plan`: 卫星仓止盈或减仓规则。 +- `thesis`: 长线持有逻辑。 +- `thesis_invalidations`: 长线逻辑失效条件。 + +### 持仓级执行状态 + +用于描述真实账户里当前持仓执行到哪一步。 + +建议字段: + +- 当前对应的投资计划 ID。 +- 当前核心仓数量或金额。 +- 当前卫星仓数量或金额。 +- 已触发的加仓档位。 +- 下一档加仓价格或跌幅。 +- 已用长期预算。 + +第一阶段可先不拆分真实股数,只保存计划与建议计算结果;等交互稳定后再考虑核心仓/卫星仓分账。 + +## 加仓计划模型 + +推荐使用“跌幅档位 + 预算比例”的简单模型,避免过度工程化。 + +示例: + +```json +{ + "basis": "avg_cost", + "levels": [ + { "drawdown_pct": -5, "budget_pct": 20 }, + { "drawdown_pct": -10, "budget_pct": 30 }, + { "drawdown_pct": -15, "budget_pct": 50 } + ] +} +``` + +解释: + +- `basis` 初期建议支持 `avg_cost`,后续再扩展为估值区间、均线或手动价格。 +- `drawdown_pct` 表示相对成本或参考价的触发跌幅。 +- `budget_pct` 表示该档使用长期加仓预算的比例。 +- 实际建议必须再经过最大仓位、可用现金、今日交易流水校验。 + +## Agent 决策规则 + +### 短线 + +- 重点关注 RSI、KDJ、量能、日内异动和 K 线形态。 +- 可对短期破位、止盈止损、技术反转给出较敏感建议。 + +### 波段 + +- 重点关注趋势、MACD、支撑压力、阶段性涨跌幅。 +- 建议以分批减仓、等待确认、回踩加仓为主。 + +### 长线核心仓 + +- 不因单日波动、单一死叉、短期破位直接建议清仓。 +- 下跌优先检查是否触发计划内加仓档位。 +- 只有长期逻辑失效、重大基本面恶化、趋势级别破坏且无修复迹象、或超过最大仓位时,才建议减仓。 +- 到达最大仓位后,即使继续下跌,也只能建议观察、复核逻辑或暂停加仓。 + +### 长线卫星仓 + +- 可以执行高抛低吸和波段增强。 +- 止盈或减仓建议默认只作用于卫星仓,不影响核心仓。 +- 若没有拆分核心/卫星股数,应在建议里明确“仅处理卫星仓比例”。 + +## 实施阶段 + +### Phase 1: 数据模型 + +- 新增股票投资计划表,或在 `stocks` 表增加轻量 JSON 配置。 +- 保留 `Position.trading_style`,不要用新字段替代它。 +- 增加迁移和默认值,确保旧数据继续按波段或未设置处理。 + +### Phase 2: API + +- 增加投资计划读取和更新接口。 +- 在股票列表、持仓汇总、组合上下文中返回计划摘要。 +- 增加服务端加仓计划计算函数,输出是否触发、建议金额、建议股数、限制原因。 + +### Phase 3: 前端 + +- 在持仓编辑或股票详情中增加“长线计划”配置区。 +- 展示核心仓/卫星仓标签、目标仓位、最大仓位、下一档加仓提示。 +- 将加仓建议与已有“加仓快速评估”串联,减少重复入口。 + +### Phase 4: Agent 与 Prompt + +- 更新盘中监测 Prompt,把长线纪律提升为高优先级规则。 +- 将投资计划结构化写入上下文,而不是只给自然语言标签。 +- 对 AI 输出做后处理:若违反长线铁律,降级为持有、观察或复核逻辑。 + +### Phase 5: 测试 + +- 长线核心仓下跌但未触发计划时,应持有或观察。 +- 长线核心仓到达加仓档位且未超仓位上限时,应建议计划内加仓。 +- 已超最大仓位时,不应继续建议加仓。 +- 卫星仓触发止盈时,应建议减卫星仓,不应动核心仓。 +- 今日已买入时,不应重复建议加仓。 +- 今日已卖出时,不应重复建议减仓或清仓。 + +## 风险与边界 + +- 越跌越买必须有上限;没有最大仓位的长线计划不可执行。 +- 长期逻辑必须可被复核;没有 `thesis` 的长线标的只能按普通长线风格处理。 +- 自动建议不能替代用户判断,尤其是基本面、监管、财报、黑天鹅事件。 +- 模拟盘长期策略可作为后续阶段,不建议与真实持仓长线计划第一阶段混做。 diff --git a/frontend/index.html b/frontend/index.html index f6ce9fa..ed8d5cf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,23 +3,20 @@ - 盯盘侠 | PanWatch + 智盘 Alpha | AlphaMind - + - -
diff --git a/frontend/package.json b/frontend/package.json index f80b924..96189d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "test": "vitest run", "preview": "vite preview" }, "dependencies": { @@ -22,6 +23,7 @@ "clsx": "^2.1.0", "date-fn": "^0.0.2", "html-to-image": "^1.11.13", + "lightweight-charts": "^5.2.0", "lucide-react": "^0.300.0", "react": "^18.3.0", "react-day-picker": "^9.13.2", @@ -41,6 +43,7 @@ "postcss": "^8.4.32", "tailwindcss": "^3.4.0", "typescript": "^5.3.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^2.1.9" } } diff --git a/frontend/packages/api/src/chat.ts b/frontend/packages/api/src/chat.ts index a07d7ba..7d0283b 100644 --- a/frontend/packages/api/src/chat.ts +++ b/frontend/packages/api/src/chat.ts @@ -1,11 +1,26 @@ import { fetchAPI } from './client' +export interface ChatPendingAction { + id: string + type: 'create_price_alert' | 'add_position' | 'reduce_position' + preview: { + title?: string + lines?: string[] + warnings?: string[] + } + status: 'pending' | 'confirmed' | 'cancelled' | 'expired' | 'failed' + result?: Record | null + expires_at?: string + created_at?: string +} + export interface ChatConversation { id: number title: string stock_symbol?: string | null stock_market?: string | null created_at: string + updated_at?: string } export interface ChatMessage { @@ -13,6 +28,7 @@ export interface ChatMessage { role: 'user' | 'assistant' | 'system' content: string created_at: string + pending_actions?: ChatPendingAction[] } export interface ConversationDetail { @@ -20,6 +36,12 @@ export interface ConversationDetail { messages: ChatMessage[] } +export interface ChatActionResult { + ok: boolean + action: ChatPendingAction + result?: Record +} + export const chatApi = { createConversation: (params?: { stock_symbol?: string; stock_market?: string; initial_context?: string }) => fetchAPI('/chat/conversations', { @@ -30,6 +52,11 @@ export const chatApi = { listConversations: (limit = 30) => fetchAPI(`/chat/conversations?limit=${limit}`), + findRecentConversations: (symbol: string, market: string, limit = 1) => + fetchAPI( + `/chat/conversations/recent?symbol=${encodeURIComponent(symbol)}&market=${encodeURIComponent(market)}&limit=${limit}`, + ), + getConversation: (id: number) => fetchAPI(`/chat/conversations/${id}`), @@ -45,6 +72,16 @@ export const chatApi = { timeoutMs: 120000, }), + confirmAction: (actionId: string) => + fetchAPI(`/chat/actions/${actionId}/confirm`, { + method: 'POST', + }), + + cancelAction: (actionId: string) => + fetchAPI<{ ok: boolean; action: ChatPendingAction }>(`/chat/actions/${actionId}/cancel`, { + method: 'POST', + }), + getSuggestedQuestions: (symbol: string, market: string) => fetchAPI<{ questions: string[] }>( `/chat/suggested-questions?symbol=${encodeURIComponent(symbol)}&market=${encodeURIComponent(market)}` diff --git a/frontend/packages/api/src/dashboard.ts b/frontend/packages/api/src/dashboard.ts index e75f7ea..078fde3 100644 --- a/frontend/packages/api/src/dashboard.ts +++ b/frontend/packages/api/src/dashboard.ts @@ -49,10 +49,18 @@ export interface DashboardPosition { change_pct: number | null } +export interface OtherFundItem { + label: string + amount: number +} + export interface DashboardAccountSummary { id: number name: string available_funds: number + other_funds?: number + other_fund_items?: OtherFundItem[] + base_currency?: string total_cost: number total_market_value: number total_pnl: number @@ -69,6 +77,7 @@ export interface DashboardPortfolioSummary { total_pnl: number total_pnl_pct: number available_funds: number + other_funds?: number total_assets: number } exchange_rates?: { diff --git a/frontend/packages/api/src/index.ts b/frontend/packages/api/src/index.ts index 2b42751..1672bf1 100644 --- a/frontend/packages/api/src/index.ts +++ b/frontend/packages/api/src/index.ts @@ -8,9 +8,14 @@ export * from './recommendations' export * from './discovery' export * from './dashboard' export * from './portfolio' +export * from './positions' +export * from './trade-datetime' +export * from './investment-profile' export * from './factors' export * from './health' export * from './home' export * from './paper-trading' export * from './chat' export * from './tradingagents' +export * from './local-skills' +export * from './settings' diff --git a/frontend/packages/api/src/insight.ts b/frontend/packages/api/src/insight.ts index 7c1cdc4..eb97845 100644 --- a/frontend/packages/api/src/insight.ts +++ b/frontend/packages/api/src/insight.ts @@ -1,4 +1,4 @@ -import { fetchAPI } from './client' +import { fetchAPI, getToken } from './client' type QueryValue = string | number | boolean | null | undefined @@ -30,6 +30,12 @@ export const insightApi = { }) ), + intradayTrends: (symbol: string, market: string) => + fetchAPI( + withQuery(`/klines/${encodeURIComponent(symbol)}/trends`, { market }), + { timeoutMs: 20000 }, + ), + suggestions: ( symbol: string, params: { market?: string; limit?: number; include_expired?: boolean } @@ -60,12 +66,68 @@ export const insightApi = { timeoutMs: 60000, // AI 评估较慢,放宽超时 }), + chanEmotionStrategy: (symbol: string, params: { market?: string; holding?: boolean }) => + fetchAPI( + withQuery(`/insights/chan-emotion/${encodeURIComponent(symbol)}`, { + market: params.market, + holding: params.holding, + }), + { timeoutMs: 45000 }, + ), + + analysisBriefBatch: (items: Array<{ symbol: string; market: string }>) => + fetchAPI( + '/insights/analysis-brief/batch', + { + method: 'POST', + body: JSON.stringify({ items }), + timeoutMs: 15_000, + }, + ), + announcementEval: (params: { symbol: string; market: string; model_id?: number }) => fetchAPI('/insights/announcement-eval', { method: 'POST', body: JSON.stringify(params), timeoutMs: 40000, }), + + /** 把任意分析历史记录导出为 PDF 并触发浏览器下载。 */ + async downloadHistoryPdf(historyId: number): Promise { + const token = getToken() + const resp = await fetch(`/api/history/${historyId}/pdf`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + if (!resp.ok) { + let msg = `导出失败 (${resp.status})` + try { + const j = await resp.json() + msg = (j && (j.message || j.detail)) || msg + } catch { + /* 非 JSON 错误体 */ + } + throw new Error(msg) + } + const blob = await resp.blob() + let filename = `分析报告-${historyId}.pdf` + const cd = resp.headers.get('Content-Disposition') || '' + const m = cd.match(/filename\*=UTF-8''([^;]+)/i) + if (m) { + try { + filename = decodeURIComponent(m[1]) + } catch { + /* 保留默认文件名 */ + } + } + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + }, } export interface AnnouncementToneItem { @@ -103,3 +165,56 @@ export interface AddPositionEvalResult { verdict: string // 适合 / 谨慎 / 不适合 / 未知 content: string // markdown 结论 } + +export interface ChanEmotionLevelAnalysis { + timeframe: string + label: string + bar_count: number + trend: string + stroke_count: number + pivot: { zd: number; zg: number } | null + divergence: string | null + signal_tags: string[] +} + +export interface ChanEmotionBrief { + action: string + action_label: string + win_rate: number + emotion_phase: string + emotion_label: string + reason?: string +} + +export interface AnalysisBriefItem { + symbol: string + market: string + lmd_brief: string | null + deep_brief: string | null +} + +export interface ChanEmotionStrategyResult { + symbol: string + market: string + asof: string + last_close: number | null + emotion_phase: string + emotion_label: string + levels: ChanEmotionLevelAnalysis[] + win_rate: number + position_pct: number + position_label: string + action: string + action_label: string + signal: string + reason: string + stop_loss: number | null + target_price: number | null + invalidation: string + decision_explanation: string + agent_instruction: string + human_notes: string[] + evidence: Array<{ text: string; delta: number }> + strategy_code: string + strategy_name: string +} diff --git a/frontend/packages/api/src/investment-profile.ts b/frontend/packages/api/src/investment-profile.ts new file mode 100644 index 0000000..3f5dabd --- /dev/null +++ b/frontend/packages/api/src/investment-profile.ts @@ -0,0 +1,65 @@ +export interface InvestmentProfileAddLevel { + drawdown_pct: number + budget_pct: number +} + +export interface InvestmentProfile { + long_term_enabled: boolean + portfolio_role: 'core' | 'satellite' | 'watch' | string + target_weight_pct: number | null + max_weight_pct: number | null + add_plan: { + basis: string + levels: InvestmentProfileAddLevel[] + } + reduce_plan: { + take_profit_pct: number + scope: string + } + thesis: string + thesis_invalidations: string[] +} + +export interface InvestmentProfileEvaluateResult { + stock_id: number + symbol: string + market: string + current_price: number | null + /** 组合总资产 = 持仓市值 + 可用现金 */ + total_assets?: number + total_market_value?: number + available_cash?: number + position_value?: number + eligible: boolean + current_drawdown_pct: number + weight_pct: number + triggered_level: InvestmentProfileAddLevel | null + next_level: InvestmentProfileAddLevel | null + next_trigger_price: number | null + suggested_amount: number + suggested_qty: number + blockers: string[] + summary: string + profile: InvestmentProfile +} + +export const DEFAULT_INVESTMENT_PROFILE: InvestmentProfile = { + long_term_enabled: false, + portfolio_role: 'watch', + target_weight_pct: null, + max_weight_pct: null, + add_plan: { + basis: 'avg_cost', + levels: [ + { drawdown_pct: -5, budget_pct: 20 }, + { drawdown_pct: -10, budget_pct: 30 }, + { drawdown_pct: -15, budget_pct: 50 }, + ], + }, + reduce_plan: { + take_profit_pct: 15, + scope: 'satellite_only', + }, + thesis: '', + thesis_invalidations: [], +} diff --git a/frontend/packages/api/src/local-skills.ts b/frontend/packages/api/src/local-skills.ts new file mode 100644 index 0000000..cdc9a0b --- /dev/null +++ b/frontend/packages/api/src/local-skills.ts @@ -0,0 +1,115 @@ +import { fetchAPI } from './client' + +export interface LocalSkillItem { + id: number + slug: string + display_name: string + description: string + skill_path: string + source_root: string + enabled: boolean + config: Record + last_seen_at: string + hermes_available: boolean +} + +export interface LocalSkillUpdatePayload { + enabled?: boolean + display_name?: string + description?: string + config?: Record +} + +export interface LocalSkillTriggerPayload { + stock_id?: number + symbol?: string + market?: string + name?: string +} + +export interface LocalSkillTriggerResponse { + queued?: boolean + trace_id?: string + success?: boolean + message: string + result?: Record +} + +export interface HermesStatusResponse { + available: boolean + bin: string + profile: string + config: Record +} + +export interface HermesTestResponse { + ok: boolean + message: string + bin?: string + profile?: string + skill?: string + reply_preview?: string + session_id?: string +} + +export const LOCAL_SKILL_AGENT_PREFIX = 'local_skill:' + +export function isLocalSkillAgentName(name: string): boolean { + return (name || '').startsWith(LOCAL_SKILL_AGENT_PREFIX) +} + +export function localSkillAgentName(slug: string): string { + return `${LOCAL_SKILL_AGENT_PREFIX}${slug}` +} + +export function parseLocalSkillSlug(agentName: string): string | null { + if (!isLocalSkillAgentName(agentName)) return null + const slug = agentName.slice(LOCAL_SKILL_AGENT_PREFIX.length).trim() + return slug || null +} + +export const localSkillsApi = { + list: (opts?: { enabledOnly?: boolean; refresh?: boolean }) => { + const q = new URLSearchParams() + if (opts?.enabledOnly) q.set('enabled_only', 'true') + if (opts?.refresh) q.set('refresh', 'true') + const qs = q.toString() + return fetchAPI(`/local-skills${qs ? `?${qs}` : ''}`) + }, + + refresh: () => fetchAPI('/local-skills/refresh', { method: 'POST' }), + + update: (slug: string, payload: LocalSkillUpdatePayload) => + fetchAPI(`/local-skills/${encodeURIComponent(slug)}`, { + method: 'PUT', + body: JSON.stringify(payload), + }), + + trigger: ( + slug: string, + payload: LocalSkillTriggerPayload, + opts?: { wait?: boolean; timeoutMs?: number }, + ) => { + const q = new URLSearchParams() + if (opts?.wait === false) q.set('wait', 'false') + const qs = q.toString() + return fetchAPI( + `/local-skills/${encodeURIComponent(slug)}/trigger${qs ? `?${qs}` : ''}`, + { + method: 'POST', + body: JSON.stringify(payload), + timeoutMs: opts?.timeoutMs ?? 720_000, + }, + ) + }, + + hermesStatus: () => fetchAPI('/local-skills/hermes/status'), + + testHermes: (skill?: string) => { + const q = skill ? `?skill=${encodeURIComponent(skill)}` : '' + return fetchAPI(`/local-skills/hermes/test${q}`, { + method: 'POST', + timeoutMs: 120_000, + }) + }, +} diff --git a/frontend/packages/api/src/paper-trading.ts b/frontend/packages/api/src/paper-trading.ts index 1035538..0c6686e 100644 --- a/frontend/packages/api/src/paper-trading.ts +++ b/frontend/packages/api/src/paper-trading.ts @@ -30,6 +30,7 @@ export interface PaperTradingPositionItem { stock_symbol: string stock_market: string stock_name: string + security_type?: string quantity: number entry_price: number stop_loss?: number | null @@ -53,6 +54,7 @@ export interface PaperTradingTradeItem { stock_symbol: string stock_market: string stock_name: string + security_type?: string quantity: number entry_price: number exit_price: number diff --git a/frontend/packages/api/src/positions.ts b/frontend/packages/api/src/positions.ts new file mode 100644 index 0000000..c7a8bb7 --- /dev/null +++ b/frontend/packages/api/src/positions.ts @@ -0,0 +1,131 @@ +import { fetchAPI } from './client' + +export interface PositionTrade { + id: number + position_id: number + side: string + price: number + quantity: number + amount: number + cost_before: number | null + qty_before: number | null + cost_after: number | null + qty_after: number | null + note: string | null + traded_at: string | null + created_at: string | null +} + +export interface PositionSnapshot { + id: number + account_id: number + stock_id: number + cost_price: number + quantity: number + invested_amount: number | null + sort_order: number + trading_style: string | null + account_name: string | null + stock_symbol: string | null + stock_name: string | null +} + +export interface PositionAddResult { + position: PositionSnapshot + trade: PositionTrade + /** 卖出后账户股票现金(清仓/减仓回款已计入) */ + available_funds?: number | null + /** 本次卖出是否导致清仓 */ + closed?: boolean +} + +export interface PortfolioRecentTrade extends PositionTrade { + account_name: string + symbol: string + market: string + stock_name: string +} + +export interface ClosedPositionTrade extends PositionTrade {} + +export interface ClosedPosition { + id: number + account_id: number + stock_id: number + stock_symbol: string | null + stock_name: string | null + market: string | null + account_name: string | null + cost_price: number + quantity: number + invested_amount: number | null + realized_pnl: number + opened_at: string | null + closed_at: string | null + trading_style: string | null + trades: ClosedPositionTrade[] +} + +export interface PositionTradeUpdateResult { + trade: PositionTrade + trades: PositionTrade[] + position: { + id: number + cost_price: number + quantity: number + invested_amount: number | null + status: string + closed_at: string | null + realized_pnl: number + } +} + +export const positionsApi = { + /** 加仓:记录流水并更新加权平均成本 */ + add: ( + positionId: number, + body: { price: number; quantity: number; note?: string; traded_at?: string }, + ) => + fetchAPI(`/positions/${positionId}/add`, { + method: 'POST', + body: JSON.stringify(body), + }), + + /** 减仓/卖出:记录流水并更新股数(成本单价不变) */ + reduce: ( + positionId: number, + body: { price: number; quantity: number; note?: string; traded_at?: string }, + ) => + fetchAPI(`/positions/${positionId}/reduce`, { + method: 'POST', + body: JSON.stringify(body), + }), + + /** 持仓变动流水 */ + trades: (positionId: number, limit = 20) => + fetchAPI(`/positions/${positionId}/trades?limit=${limit}`), + + /** 修改历史交易流水(会重放流水并同步持仓) */ + updateTrade: ( + tradeId: number, + body: { + price?: number + quantity?: number + note?: string + traded_at?: string + side?: 'buy' | 'sell' + }, + ) => + fetchAPI(`/positions/trades/${tradeId}`, { + method: 'PUT', + body: JSON.stringify(body), + }), + + /** 全账户最近加仓/变动流水 */ + recentTrades: (limit = 50) => + fetchAPI(`/portfolio/recent-trades?limit=${limit}`), + + /** 已清仓持仓列表(含历史成交明细) */ + closedPositions: (limit = 100) => + fetchAPI(`/portfolio/closed-positions?limit=${limit}`), +} diff --git a/frontend/packages/api/src/settings.test.ts b/frontend/packages/api/src/settings.test.ts new file mode 100644 index 0000000..5316f1f --- /dev/null +++ b/frontend/packages/api/src/settings.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { parseBoolSetting } from './settings' + +describe('parseBoolSetting', () => { + it('空值默认 false', () => { + expect(parseBoolSetting(undefined, false)).toBe(false) + expect(parseBoolSetting('', false)).toBe(false) + }) + + it('true 字符串解析为 true', () => { + expect(parseBoolSetting('true')).toBe(true) + expect(parseBoolSetting('TRUE')).toBe(true) + }) + + it('其他值解析为 false', () => { + expect(parseBoolSetting('false')).toBe(false) + expect(parseBoolSetting('1')).toBe(false) + }) +}) diff --git a/frontend/packages/api/src/settings.ts b/frontend/packages/api/src/settings.ts new file mode 100644 index 0000000..fe4f27f --- /dev/null +++ b/frontend/packages/api/src/settings.ts @@ -0,0 +1,53 @@ +import { fetchAPI } from './client' + +export type AppSetting = { + key: string + value: string + description: string +} + +let cache: AppSetting[] | null = null +let inflight: Promise | null = null + +export const APP_SETTINGS_CHANGED_EVENT = 'panwatch-settings-changed' + +export function invalidateAppSettingsCache(): void { + cache = null + inflight = null +} + +export function notifyAppSettingsChanged(key?: string): void { + invalidateAppSettingsCache() + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(APP_SETTINGS_CHANGED_EVENT, { detail: { key } })) + } +} + +export async function fetchAppSettings(): Promise { + if (cache) return cache + if (inflight) return inflight + inflight = fetchAPI('/settings') + .then((data) => { + cache = data + return data + }) + .finally(() => { + inflight = null + }) + return inflight +} + +export function parseBoolSetting(value: string | undefined | null, defaultValue = false): boolean { + if (value == null || value === '') return defaultValue + return String(value).trim().toLowerCase() === 'true' +} + +export async function getAppSettingValue(key: string): Promise { + const settings = await fetchAppSettings() + return settings.find((s) => s.key === key)?.value +} + +export async function isKlineExternalLinkEnabled(): Promise { + const value = await getAppSettingValue('kline_external_link_enabled') + return parseBoolSetting(value, false) +} diff --git a/frontend/packages/api/src/stocks.ts b/frontend/packages/api/src/stocks.ts index b66697d..36dbe74 100644 --- a/frontend/packages/api/src/stocks.ts +++ b/frontend/packages/api/src/stocks.ts @@ -1,4 +1,8 @@ import { fetchAPI } from './client' +import type { InvestmentProfile, InvestmentProfileEvaluateResult } from './investment-profile' + +export type { InvestmentProfile, InvestmentProfileEvaluateResult } from './investment-profile' +export { DEFAULT_INVESTMENT_PROFILE } from './investment-profile' export interface StockAgentInfo { agent_name: string @@ -7,12 +11,38 @@ export interface StockAgentInfo { notify_channel_ids: number[] } +export interface StockConceptTag { + name: string + source: 'auto' | 'manual' | string +} + +export interface IndustryChainInfo { + sector: string + sector_label: string + layer: string + layer_label: string + display: string + description?: string + score?: number + match_source?: string + source?: 'manual' | 'auto' | string + matched?: string[] +} + export interface StockItem { id: number symbol: string name: string market: string + security_type?: string sort_order?: number + is_featured?: boolean + concept_tags?: StockConceptTag[] + concept_tags_auto?: string[] + concept_tags_manual?: string[] + industry_chain?: IndustryChainInfo | null + industry_chain_manual?: { sector?: string; layer?: string } | null + investment_profile?: InvestmentProfile agents?: StockAgentInfo[] } @@ -20,6 +50,40 @@ export interface StockCreatePayload { symbol: string name: string market: string + security_type?: string +} + +export interface EtfSpot { + symbol: string + name: string + price: number | null + iopv: number | null + premium_pct: number | null + change_pct: number | null + turnover: number | null + total_value: number | null + turnover_rate: number | null + volume: number | null +} + +export interface EtfHolding { + symbol: string + name: string + weight_pct: number +} + +export interface EtfNavPoint { + date: string + unit_nav: number | null + cum_nav: number | null + change_pct: number | null +} + +export interface EtfOverview { + symbol: string + spot: EtfSpot | null + holdings: EtfHolding[] + nav_history: EtfNavPoint[] } export interface StockAgentUpdatePayload { @@ -47,6 +111,8 @@ export interface TriggerStockAgentResponse { success?: boolean message: string queued?: boolean + deduplicated?: boolean + trace_id?: string } function withQuery(path: string, params: TriggerStockAgentOptions): string { @@ -61,6 +127,24 @@ function withQuery(path: string, params: TriggerStockAgentOptions): string { return s ? `${path}?${s}` : path } +export interface LmdReportSnapshot { + symbol: string + market: string + pe_ttm?: number | null + forward_pe?: number | null + pb?: number | null + profit_yoy_pct?: number | null + revenue_yoy_pct?: number | null + roe_pct?: number | null + gross_margin_pct?: number | null + consensus_eps?: number | null + valuation_score?: number | null + valuation_verdict?: string | null + expectation_hint?: string | null + report_date?: string | null + has_report: boolean +} + export const stocksApi = { list: () => fetchAPI('/stocks'), create: (payload: StockCreatePayload) => @@ -68,7 +152,36 @@ export const stocksApi = { method: 'POST', body: JSON.stringify(payload), }), + etfOverview: (code: string, top = 30, navDays = 180) => + fetchAPI( + `/stocks/etf/${encodeURIComponent(code)}/overview?top=${top}&nav_days=${navDays}`, + { timeoutMs: 30_000 } + ), remove: (id: number) => fetchAPI<{ ok: boolean }>(`/stocks/${id}`, { method: 'DELETE' }), + updateConceptTags: (id: number, manual: string[]) => + fetchAPI(`/stocks/${id}/concept-tags`, { + method: 'PUT', + body: JSON.stringify({ manual }), + }), + refreshConceptTags: (id: number) => + fetchAPI(`/stocks/${id}/concept-tags/refresh`, { method: 'POST' }), + refreshMissingConceptTags: (limit = 20) => + fetchAPI<{ queued: boolean; limit: number }>('/stocks/concept-tags/refresh', { + method: 'POST', + body: JSON.stringify({ limit }), + }), + refreshIndustryChains: (limit = 50) => + fetchAPI<{ queued: boolean; limit: number }>('/stocks/industry-chains/refresh', { + method: 'POST', + body: JSON.stringify({ limit }), + }), + refreshIndustryChain: (id: number) => + fetchAPI(`/stocks/${id}/industry-chain/refresh`, { method: 'POST' }), + updateIndustryChain: (id: number, layer: string | null) => + fetchAPI(`/stocks/${id}/industry-chain`, { + method: 'PUT', + body: JSON.stringify({ layer }), + }), updateAgents: (id: number, payload: StockAgentUpdatePayload) => fetchAPI(`/stocks/${id}/agents`, { method: 'PUT', @@ -79,4 +192,32 @@ export const stocksApi = { withQuery(`/stocks/${id}/agents/${encodeURIComponent(agentName)}/trigger`, options), { method: 'POST', timeoutMs: 120_000 } ), + ensureLmdReport: (id: number) => + fetchAPI<{ has_report: boolean; queued: boolean; deduplicated?: boolean; message?: string }>( + `/stocks/${id}/agents/lmd_outlook/ensure`, + { method: 'POST' }, + ), + lmdSnapshotsBatch: (symbols: string[]) => + fetchAPI('/stocks/lmd-snapshots/batch', { + method: 'POST', + body: JSON.stringify({ symbols }), + }), + getInvestmentProfile: (id: number) => + fetchAPI<{ stock_id: number; symbol: string; market: string; investment_profile: InvestmentProfile; portfolio_role_label: string }>( + `/stocks/${id}/investment-profile`, + ), + updateInvestmentProfile: (id: number, profile: Partial) => + fetchAPI(`/stocks/${id}/investment-profile`, { + method: 'PUT', + body: JSON.stringify(profile), + }), + setFeatured: (id: number, isFeatured: boolean) => + fetchAPI(`/stocks/${id}/featured`, { + method: 'PUT', + body: JSON.stringify({ is_featured: isFeatured }), + }), + evaluateInvestmentProfile: (id: number, price?: number) => { + const q = price != null && price > 0 ? `?price=${price}` : '' + return fetchAPI(`/stocks/${id}/investment-profile/evaluate${q}`) + }, } diff --git a/frontend/packages/api/src/trade-datetime.ts b/frontend/packages/api/src/trade-datetime.ts new file mode 100644 index 0000000..11aed15 --- /dev/null +++ b/frontend/packages/api/src/trade-datetime.ts @@ -0,0 +1,24 @@ +/** 将 datetime-local 值转为 ISO 字符串;空值表示使用当前时间。 */ +export function tradeDatetimeLocalToIso(value: string): string | undefined { + const v = value.trim() + if (!v) return undefined + const d = new Date(v) + if (Number.isNaN(d.getTime())) return undefined + return d.toISOString() +} + +/** datetime-local 默认值:当前本地时间。 */ +export function nowDatetimeLocalValue(): string { + const d = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +/** 将 ISO 时间转为 datetime-local 输入值(本地时区)。 */ +export function tradeDatetimeIsoToLocal(value: string | null | undefined): string { + if (!value) return '' + const d = new Date(value) + if (Number.isNaN(d.getTime())) return '' + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} diff --git a/frontend/packages/api/src/tradingagents.ts b/frontend/packages/api/src/tradingagents.ts index d14c113..7059888 100644 --- a/frontend/packages/api/src/tradingagents.ts +++ b/frontend/packages/api/src/tradingagents.ts @@ -5,6 +5,48 @@ */ import { fetchAPI, getToken } from './client' +export type DeepAnalysisMode = 'full' | 'fundamentals' + +export const DEEP_ANALYSIS_MODE_STORAGE_KEY = 'panwatch:deep-analysis:mode' + +export const FULL_ANALYST_TYPES = ['market', 'social', 'news', 'fundamentals'] as const +export const FUNDAMENTALS_ANALYST_TYPES = ['fundamentals'] as const + +export function analystTypesForMode(mode: DeepAnalysisMode): string[] { + return mode === 'fundamentals' ? [...FUNDAMENTALS_ANALYST_TYPES] : [...FULL_ANALYST_TYPES] +} + +export function loadDeepAnalysisMode(): DeepAnalysisMode { + try { + const raw = localStorage.getItem(DEEP_ANALYSIS_MODE_STORAGE_KEY) + return raw === 'fundamentals' ? 'fundamentals' : 'full' + } catch { + return 'full' + } +} + +export function saveDeepAnalysisMode(mode: DeepAnalysisMode): void { + try { + localStorage.setItem(DEEP_ANALYSIS_MODE_STORAGE_KEY, mode) + } catch { + /* ignore */ + } +} + +export function deepAnalysisModeLabel(mode: DeepAnalysisMode): string { + return mode === 'fundamentals' ? '仅基本面' : '全面分析' +} + +export function deepAnalysisModeDescription(mode: DeepAnalysisMode): string { + return mode === 'fundamentals' + ? '仅调用基本面分析师 + 辩论/风控/PM 整合(更快、更省)' + : '调用 4 类分析师(技术 / 情绪 / 新闻 / 基本面)+ 看多看空辩论 + 风控 + PM 整合' +} + +export function deepAnalysisModeEta(mode: DeepAnalysisMode): string { + return mode === 'fundamentals' ? '1-3 分钟' : '3-8 分钟' +} + export interface TradingAgentsTriggerResult { ok: boolean queued?: boolean @@ -160,21 +202,31 @@ export interface HistoryComparisonResponse { export const tradingAgentsApi = { /** 触发深度分析(异步排队)。force=true 跳过同日缓存。 * TradingAgents 不要求 StockAgent 绑定 — 始终带 allow_unbound=true。 */ - trigger(stockId: number, opts: { force?: boolean } = {}): Promise { + trigger( + stockId: number, + opts: { force?: boolean; analystTypes?: string[] } = {}, + ): Promise { const qsParts = ['allow_unbound=true'] if (opts.force) qsParts.push('force_refresh=true') + const body = + opts.analystTypes && opts.analystTypes.length > 0 + ? { analyst_types: opts.analystTypes } + : {} return fetchAPI( `/stocks/${stockId}/agents/tradingagents/trigger?${qsParts.join('&')}`, { method: 'POST', - body: JSON.stringify({}), + body: JSON.stringify(body), }, ) }, /** 读取本月预算 + 单次预估成本(用于触发前确认弹窗)。 */ - getBudget(): Promise { - return fetchAPI('/agents/tradingagents/budget') + getBudget(analystTypes?: string[]): Promise { + const qs = analystTypes?.length + ? `?analyst_types=${encodeURIComponent(analystTypes.join(','))}` + : '' + return fetchAPI(`/agents/tradingagents/budget${qs}`) }, /** 把某次深度分析报告导出为 PDF 文件并触发下载(后台直出,不走打印对话框)。 */ diff --git a/frontend/packages/api/src/types.ts b/frontend/packages/api/src/types.ts index 77b78ae..b04a96a 100644 --- a/frontend/packages/api/src/types.ts +++ b/frontend/packages/api/src/types.ts @@ -23,6 +23,15 @@ export interface NotifyChannel { is_default: boolean } +export interface DataSourceCookieHealth { + status: 'not_configured' | 'unknown' | 'ok' | 'expired' | 'blocked' | 'error' + label: string + message: string + checked_at?: string | null + sample_count?: number + update_hint?: string +} + export interface DataSource { id: number name: string @@ -33,4 +42,5 @@ export interface DataSource { priority: number supports_batch: boolean test_symbols: string[] + cookie_health?: DataSourceCookieHealth | null } diff --git a/frontend/packages/biz-ui/src/components/InteractiveKline.tsx b/frontend/packages/biz-ui/src/components/InteractiveKline.tsx index 37e172d..e6fd2a8 100644 --- a/frontend/packages/biz-ui/src/components/InteractiveKline.tsx +++ b/frontend/packages/biz-ui/src/components/InteractiveKline.tsx @@ -1,9 +1,21 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { RefreshCw } from 'lucide-react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Maximize2, Minimize2, RefreshCw } from 'lucide-react' +import { + CandlestickSeries, + CrosshairMode, + HistogramSeries, + LineSeries, + createChart, + type Time, +} from 'lightweight-charts' import { fetchAPI } from '@panwatch/api' import { Button } from '@panwatch/base-ui/components/ui/button' +import IntradayChart from '@panwatch/biz-ui/components/IntradayChart' +import StockExternalLink from '@panwatch/biz-ui/components/stock-external-link' +import { chartMarketLocalization, parseMarketChartTime } from '../market-time' type BusinessDay = { year: number; month: number; day: number } +export type KlineInterval = 'trend' | '1d' | '1w' | '1m' | 'm5' | 'm30' type KlineItem = { date: string @@ -36,11 +48,58 @@ type HoverTipRow = { rsi6: number | null } -type HoverTip = { - visible: boolean - x: number - y: number - row: HoverTipRow | null +function ChartValueChip(props: { label: string; value: number | null; precision?: number }) { + const { label, value, precision = 2 } = props + return ( + + {label}{' '} + + {value != null ? value.toFixed(precision) : '--'} + + + ) +} + +/** 主图均线颜色,与左上角图例一致 */ +const CHART_SERIES_COLORS = { + ma5: '#6366f1', + ma10: '#f59e0b', + ma20: '#0ea5e9', + macd: '#6366f1', + signal: '#0ea5e9', + rsi: '#ea580c', +} as const + +const INDICATOR_LABELS = { + ma5: 'MA5(5日均线)', + ma10: 'MA10(10日均线)', + ma20: 'MA20(20日均线)', + macd: 'MACD(MACD线)', + signal: 'DEA(信号线)', + rsi: 'RSI6(RSI强弱)', +} as const + +function ChartLegendRow(props: { + color: string + label: string + value: number | null + precision?: number + compact?: boolean +}) { + const { color, label, value, precision = 2, compact = false } = props + return ( +
+ + {label} + + {value != null ? value.toFixed(precision) : '--'} + +
+ ) } function parseBusinessDay(dateStr: string): BusinessDay | null { @@ -49,6 +108,20 @@ function parseBusinessDay(dateStr: string): BusinessDay | null { return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) } } +function isIntradayInterval(interval: KlineInterval): boolean { + return interval === 'm5' || interval === 'm30' +} + +function isTrendInterval(interval: KlineInterval): boolean { + return interval === 'trend' +} + +function parseChartTime(dateStr: string): Time | null { + const daily = parseBusinessDay(dateStr) + if (daily) return daily + return parseMarketChartTime(dateStr) +} + function parseCrosshairDateKey(time: any): string | null { if (!time || typeof time !== 'object') return null const year = Number(time.year) @@ -58,6 +131,32 @@ function parseCrosshairDateKey(time: any): string | null { return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` } +function resolveCrosshairIndex( + time: unknown, + klines: KlineItem[], + indexByDate: Map, +): number | null { + if (typeof time === 'number' && Number.isFinite(time)) { + for (let i = 0; i < klines.length; i++) { + const chartTime = parseChartTime(klines[i].date) + if (typeof chartTime === 'number' && chartTime === time) return i + } + return null + } + const dateKey = parseCrosshairDateKey(time) + if (!dateKey) return null + const idx = indexByDate.get(dateKey) + return idx == null ? null : idx +} + +function chartViewDefaults(interval: KlineInterval): { bars: number; spacing: number; intraday: boolean } { + if (interval === 'm5') return { bars: 120, spacing: 4, intraday: true } + if (interval === 'm30') return { bars: 80, spacing: 6, intraday: true } + if (interval === '1w') return { bars: 78, spacing: 10, intraday: false } + if (interval === '1m') return { bars: 72, spacing: 10, intraday: false } + return { bars: 100, spacing: 8.5, intraday: false } +} + function sma(values: number[], period: number): Array { if (period <= 1) return values.map(v => v) const out: Array = new Array(values.length).fill(null) @@ -130,42 +229,69 @@ function computeRsi(closes: number[], period = 6): Array { return out } -function getLW() { - return (window as any)?.LightweightCharts || null -} - -function addCandles(chart: any, LW: any, options: any) { - if (typeof chart?.addCandlestickSeries === 'function') return chart.addCandlestickSeries(options) - if (typeof chart?.addSeries === 'function' && LW?.CandlestickSeries) return chart.addSeries(LW.CandlestickSeries, options) - throw new Error('Candlestick series API not available') +function resolveChartHeights(fullscreen: boolean, showRsi: boolean, viewportHeight: number) { + if (!fullscreen) { + return { main: 340, macd: 140, rsi: 140, trendMain: 300, trendVol: 80 } + } + const chrome = 120 + const available = Math.max(420, viewportHeight - chrome) + const rsi = showRsi ? Math.max(120, Math.round(available * 0.2)) : 0 + const macd = Math.max(120, Math.round(available * 0.26)) + const main = Math.max(200, available - macd - rsi) + return { + main, + macd, + rsi: rsi || 140, + trendMain: Math.round(available * 0.78), + trendVol: Math.round(available * 0.22), + } } -function addLine(chart: any, LW: any, options: any) { - if (typeof chart?.addLineSeries === 'function') return chart.addLineSeries(options) - if (typeof chart?.addSeries === 'function' && LW?.LineSeries) return chart.addSeries(LW.LineSeries, options) - throw new Error('Line series API not available') +function chartInteractionOptions(fullscreen: boolean) { + return { + timeScale: { + fixRightEdge: !fullscreen, + lockVisibleTimeRangeOnResize: true, + }, + handleScale: { + mouseWheel: true, + pinch: true, + axisPressedMouseMove: true, + }, + handleScroll: { + mouseWheel: false, + pressedMouseMove: true, + horzTouchDrag: true, + vertTouchDrag: false, + }, + } } -function addHistogram(chart: any, LW: any, options: any) { - if (typeof chart?.addHistogramSeries === 'function') return chart.addHistogramSeries(options) - if (typeof chart?.addSeries === 'function' && LW?.HistogramSeries) return chart.addSeries(LW.HistogramSeries, options) - throw new Error('Histogram series API not available') -} +export const klineDialogFullscreenClassName = + 'fixed inset-0 z-50 max-w-none w-screen h-[100dvh] translate-x-0 translate-y-0 left-0 top-0 rounded-none overflow-hidden flex flex-col p-4 md:p-6' export default function InteractiveKline(props: { symbol: string market: string - initialInterval?: '1d' | '1w' | '1m' + initialInterval?: KlineInterval initialDays?: '60' | '120' | '250' + onFullscreenChange?: (open: boolean) => void }) { - const [lwReady, setLwReady] = useState(!!getLW()) - const [libError, setLibError] = useState(false) - const [interval, setIntervalValue] = useState<'1d' | '1w' | '1m'>(props.initialInterval || '1d') + const [interval, setIntervalValue] = useState(props.initialInterval || 'trend') const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [data, setData] = useState([]) const [showRsi, setShowRsi] = useState(true) - const [hoverTip, setHoverTip] = useState({ visible: false, x: 0, y: 0, row: null }) + const [hoverRow, setHoverRow] = useState(null) + const [fullscreen, setFullscreen] = useState(false) + const [viewportHeight, setViewportHeight] = useState(() => ( + typeof window !== 'undefined' ? window.innerHeight : 800 + )) + + const chartHeights = useMemo( + () => resolveChartHeights(fullscreen, showRsi, viewportHeight), + [fullscreen, showRsi, viewportHeight], + ) const fixedDays = useMemo(() => { const customDays = Number(props.initialDays) @@ -177,23 +303,50 @@ export default function InteractiveKline(props: { return 120 }, [props.initialDays, interval]) + const fixedCount = useMemo(() => { + if (interval === 'm5') return 480 + if (interval === 'm30') return 240 + return 0 + }, [interval]) + + const intraday = isIntradayInterval(interval) + const trend = isTrendInterval(interval) + const containerRef = useRef(null) const macdRef = useRef(null) + const rsiRef = useRef(null) + const subChartsRef = useRef<{ macd?: any; rsi?: any }>({}) + const visibleRangeRef = useRef<{ from: number; to: number } | null>(null) + const zoomKeyRef = useRef('') const load = async () => { if (!props.symbol) return setLoading(true) setError('') - setHoverTip(prev => (prev.visible ? { visible: false, x: 0, y: 0, row: null } : prev)) + setHoverRow(null) try { - const query = (days: number) => - `/klines/${encodeURIComponent(props.symbol)}?market=${encodeURIComponent(props.market)}&days=${encodeURIComponent(String(days))}&interval=${encodeURIComponent(interval)}` + const buildQuery = (days?: number, count?: number) => { + const params = new URLSearchParams({ + market: props.market, + interval, + }) + if (count != null) params.set('count', String(count)) + if (days != null) params.set('days', String(days)) + return `/klines/${encodeURIComponent(props.symbol)}?${params.toString()}` + } + + if (intraday) { + const res = await fetchAPI(buildQuery(undefined, fixedCount), { timeoutMs: 45000 }) + setData(res.klines || []) + return + } + const attempts = Array.from(new Set([fixedDays, Math.max(90, Math.floor(fixedDays * 0.75))])) let best: KlineItem[] = [] let lastError: unknown = null for (const d of attempts) { try { - const res = await fetchAPI(query(d)) + const res = await fetchAPI(buildQuery(d), { timeoutMs: 45000 }) const kl = res.klines || [] if (kl.length > best.length) best = kl if (d === fixedDays && kl.length > 0) break @@ -212,47 +365,52 @@ export default function InteractiveKline(props: { } useEffect(() => { + if (trend) return void load() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.symbol, props.market, interval, fixedDays]) + }, [props.symbol, props.market, interval, fixedDays, fixedCount, intraday, trend]) useEffect(() => { if (props.initialInterval) setIntervalValue(props.initialInterval) }, [props.initialInterval, props.symbol, props.market]) useEffect(() => { - if (lwReady) return - let cancelled = false - const start = Date.now() - const t = window.setInterval(() => { - if (cancelled) return - if (getLW()) { - setLwReady(true) - clearInterval(t) - return - } - if (Date.now() - start > 3500) { - setLibError(true) - clearInterval(t) - } - }, 200) + if (!fullscreen) return + const onResize = () => setViewportHeight(window.innerHeight) + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setFullscreen(false) + } + window.addEventListener('resize', onResize) + window.addEventListener('keydown', onKeyDown) return () => { - cancelled = true - clearInterval(t) + window.removeEventListener('resize', onResize) + window.removeEventListener('keydown', onKeyDown) + } + }, [fullscreen]) + + useEffect(() => { + props.onFullscreenChange?.(fullscreen) + }, [fullscreen, props.onFullscreenChange]) + + useEffect(() => { + const zoomKey = `${props.symbol}:${props.market}:${interval}` + if (zoomKeyRef.current !== zoomKey) { + visibleRangeRef.current = null + zoomKeyRef.current = zoomKey } - }, [lwReady]) + }, [props.symbol, props.market, interval]) const series = useMemo(() => { - const klines = (data || []).slice().filter(k => !!parseBusinessDay(k.date)) + const klines = (data || []).slice().filter(k => parseChartTime(k.date) != null) const candles = klines.map(k => ({ - time: parseBusinessDay(k.date) as BusinessDay, + time: parseChartTime(k.date) as Time, open: k.open, high: k.high, low: k.low, close: k.close, })) const volumes = klines.map(k => ({ - time: parseBusinessDay(k.date) as BusinessDay, + time: parseChartTime(k.date) as Time, value: k.volume, color: k.close >= k.open ? 'rgba(239, 68, 68, 0.35)' : 'rgba(16, 185, 129, 0.35)', })) @@ -287,11 +445,30 @@ export default function InteractiveKline(props: { } return m }, [series.klines]) + + const activeIndicators = useMemo((): HoverTipRow | null => { + if (hoverRow) return hoverRow + const last = series.klines.length - 1 + if (last < 0) return null + const k = series.klines[last] + return { + date: k.date, + open: k.open, + high: k.high, + low: k.low, + close: k.close, + ma5: series.ma5[last], + ma10: series.ma10[last], + ma20: series.ma20[last], + macd: series.macd.macd[last], + signal: series.macd.signal[last], + rsi6: series.rsi6[last], + } + }, [hoverRow, series]) + const showSkeleton = loading && !series.klines.length useEffect(() => { - const LW = getLW() - if (!LW || !lwReady) return if (!containerRef.current) return if (!series.candles.length) return @@ -300,16 +477,21 @@ export default function InteractiveKline(props: { container.innerHTML = '' if (macdEl) macdEl.innerHTML = '' + subChartsRef.current.macd = undefined const rootStyle = getComputedStyle(document.documentElement) const bg = rootStyle.getPropertyValue('--card').trim() const fg = rootStyle.getPropertyValue('--foreground').trim() - const defaultBars = interval === '1d' ? 100 : interval === '1w' ? 78 : 72 - const defaultSpacing = interval === '1d' ? 8.5 : interval === '1w' ? 10 : 10 - const chart = LW.createChart(container, { + const defaultBars = chartViewDefaults(interval).bars + const defaultSpacing = chartViewDefaults(interval).spacing + const mainHeight = chartHeights.main + const macdHeight = chartHeights.macd + const interaction = chartInteractionOptions(fullscreen) + const chart = createChart(container, { width: container.clientWidth, - height: 380, + height: mainHeight, + ...(intraday ? { localization: chartMarketLocalization } : {}), layout: { background: { color: `hsl(${bg})` }, textColor: `hsl(${fg} / 0.85)`, @@ -317,22 +499,23 @@ export default function InteractiveKline(props: { rightPriceScale: { borderVisible: false }, timeScale: { borderVisible: false, - fixRightEdge: true, rightOffset: 1, barSpacing: defaultSpacing, minBarSpacing: 1, - lockVisibleTimeRangeOnResize: true, + timeVisible: intraday, + secondsVisible: false, + ...interaction.timeScale, }, - handleScale: { mouseWheel: true, pinch: true, axisPressedMouseMove: true }, - handleScroll: { mouseWheel: false, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: false }, + handleScale: interaction.handleScale, + handleScroll: interaction.handleScroll, grid: { vertLines: { color: 'rgba(148, 163, 184, 0.08)' }, horzLines: { color: 'rgba(148, 163, 184, 0.08)' }, }, - crosshair: { mode: 1 }, + crosshair: { mode: CrosshairMode.Magnet }, }) - const candleSeries = addCandles(chart, LW, { + const candleSeries = chart.addSeries(CandlestickSeries, { upColor: '#ef4444', downColor: '#10b981', borderUpColor: '#ef4444', @@ -342,24 +525,40 @@ export default function InteractiveKline(props: { }) candleSeries.setData(series.candles) - const volSeries = addHistogram(chart, LW, { + const volSeries = chart.addSeries(HistogramSeries, { priceScaleId: 'vol', priceFormat: { type: 'volume' }, }) volSeries.setData(series.volumes) chart.priceScale('vol').applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } }) - const volMa5Series = addLine(chart, LW, { priceScaleId: 'vol', color: 'rgba(245, 158, 11, 0.9)', lineWidth: 1 }) - const volMa10Series = addLine(chart, LW, { priceScaleId: 'vol', color: 'rgba(14, 165, 233, 0.9)', lineWidth: 1 }) - - const ma5Series = addLine(chart, LW, { color: 'rgba(99, 102, 241, 0.85)', lineWidth: 2 }) - const ma10Series = addLine(chart, LW, { color: 'rgba(245, 158, 11, 0.85)', lineWidth: 2 }) - const ma20Series = addLine(chart, LW, { color: 'rgba(14, 165, 233, 0.85)', lineWidth: 2 }) + const volMa5Series = chart.addSeries(LineSeries, { priceScaleId: 'vol', color: 'rgba(245, 158, 11, 0.9)', lineWidth: 1 }) + const volMa10Series = chart.addSeries(LineSeries, { priceScaleId: 'vol', color: 'rgba(14, 165, 233, 0.9)', lineWidth: 1 }) + + const ma5Series = chart.addSeries(LineSeries, { + color: CHART_SERIES_COLORS.ma5, + lineWidth: 2, + lastValueVisible: false, + priceLineVisible: false, + }) + const ma10Series = chart.addSeries(LineSeries, { + color: CHART_SERIES_COLORS.ma10, + lineWidth: 2, + lastValueVisible: false, + priceLineVisible: false, + }) + const ma20Series = chart.addSeries(LineSeries, { + color: CHART_SERIES_COLORS.ma20, + lineWidth: 2, + lastValueVisible: false, + priceLineVisible: false, + }) const mapLine = (arr: Array) => series.klines .map((k, i) => { const v = arr[i] - return v == null ? null : { time: parseBusinessDay(k.date) as BusinessDay, value: v } + const time = parseChartTime(k.date) + return v == null || time == null ? null : { time, value: v } }) .filter(Boolean) @@ -371,11 +570,10 @@ export default function InteractiveKline(props: { // MACD chart let macdChart: any = null - let rsiChart: any = null if (macdEl) { - macdChart = LW.createChart(macdEl, { + macdChart = createChart(macdEl, { width: macdEl.clientWidth, - height: 150, + height: macdHeight, layout: { background: { color: `hsl(${bg})` }, textColor: `hsl(${fg} / 0.75)`, @@ -388,30 +586,43 @@ export default function InteractiveKline(props: { }, crosshair: { mode: 0 }, }) - const macdLine = addLine(macdChart, LW, { color: 'rgba(99, 102, 241, 0.85)', lineWidth: 2 }) - const sigLine = addLine(macdChart, LW, { color: 'rgba(14, 165, 233, 0.85)', lineWidth: 2 }) - const hist = addHistogram(macdChart, LW, { + const macdLine = macdChart.addSeries(LineSeries, { + color: CHART_SERIES_COLORS.macd, + lineWidth: 2, + lastValueVisible: false, + priceLineVisible: false, + }) + const sigLine = macdChart.addSeries(LineSeries, { + color: CHART_SERIES_COLORS.signal, + lineWidth: 2, + lastValueVisible: false, + priceLineVisible: false, + }) + const hist = macdChart.addSeries(HistogramSeries, { priceFormat: { type: 'price', precision: 3, minMove: 0.001 }, }) const macdLineData = series.klines .map((k, i) => { const v = series.macd.macd[i] - return v == null ? null : { time: parseBusinessDay(k.date) as BusinessDay, value: v } + const time = parseChartTime(k.date) + return v == null || time == null ? null : { time, value: v } }) .filter(Boolean) const sigLineData = series.klines .map((k, i) => { const v = series.macd.signal[i] - return v == null ? null : { time: parseBusinessDay(k.date) as BusinessDay, value: v } + const time = parseChartTime(k.date) + return v == null || time == null ? null : { time, value: v } }) .filter(Boolean) const histData = series.klines .map((k, i) => { const v = series.macd.hist[i] - if (v == null) return null + const time = parseChartTime(k.date) + if (v == null || time == null) return null return { - time: parseBusinessDay(k.date) as BusinessDay, + time, value: v, color: v >= 0 ? 'rgba(239, 68, 68, 0.35)' : 'rgba(16, 185, 129, 0.35)', } @@ -421,43 +632,13 @@ export default function InteractiveKline(props: { macdLine.setData(macdLineData as any) sigLine.setData(sigLineData as any) hist.setData(histData as any) - } - - // RSI chart - if (showRsi && macdEl) { - const rsiRoot = document.createElement('div') - rsiRoot.className = 'mt-2' - macdEl.parentElement?.appendChild(rsiRoot) - rsiChart = LW.createChart(rsiRoot, { - width: macdEl.clientWidth, - height: 110, - layout: { - background: { color: `hsl(${bg})` }, - textColor: `hsl(${fg} / 0.75)`, - }, - rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.15, bottom: 0.1 } }, - timeScale: { borderVisible: false, visible: false }, - grid: { - vertLines: { color: 'rgba(148, 163, 184, 0.06)' }, - horzLines: { color: 'rgba(148, 163, 184, 0.06)' }, - }, - }) - const rsiLine = addLine(rsiChart, LW, { color: 'rgba(234, 88, 12, 0.9)', lineWidth: 2 }) - const rsiData = series.klines - .map((k, i) => { - const v = series.rsi6[i] - return v == null ? null : { time: parseBusinessDay(k.date) as BusinessDay, value: v } - }) - .filter(Boolean) - rsiLine.setData(rsiData as any) - rsiLine.createPriceLine?.({ price: 70, color: 'rgba(239,68,68,0.45)', lineWidth: 1, lineStyle: 2, title: '70' }) - rsiLine.createPriceLine?.({ price: 30, color: 'rgba(16,185,129,0.45)', lineWidth: 1, lineStyle: 2, title: '30' }) + subChartsRef.current.macd = macdChart } const sync = (range: any) => { try { - macdChart?.timeScale().setVisibleRange(range) - rsiChart?.timeScale().setVisibleRange(range) + subChartsRef.current.macd?.timeScale().setVisibleRange(range) + subChartsRef.current.rsi?.timeScale().setVisibleRange(range) } catch { // ignore } @@ -465,9 +646,8 @@ export default function InteractiveKline(props: { chart.timeScale().subscribeVisibleTimeRangeChange(sync) chart.subscribeCrosshairMove?.((param: any) => { const point = param?.point - const dateKey = parseCrosshairDateKey(param?.time) - if (!point || !dateKey || !series.klines.length) { - setHoverTip(prev => (prev.visible ? { visible: false, x: 0, y: 0, row: null } : prev)) + if (!point || !series.klines.length) { + setHoverRow(null) return } const inBounds = @@ -476,49 +656,34 @@ export default function InteractiveKline(props: { point.x <= container.clientWidth && point.y <= container.clientHeight if (!inBounds) { - setHoverTip(prev => (prev.visible ? { visible: false, x: 0, y: 0, row: null } : prev)) + setHoverRow(null) return } - const idx = indexByDate.get(dateKey) + const idx = resolveCrosshairIndex(param?.time, series.klines, indexByDate) if (idx == null || idx < 0 || idx >= series.klines.length) { - setHoverTip(prev => (prev.visible ? { visible: false, x: 0, y: 0, row: null } : prev)) + setHoverRow(null) return } const k = series.klines[idx] - const tooltipWidth = 280 - const tooltipHeight = 152 - let x = point.x + 12 - let y = point.y + 12 - if (x + tooltipWidth > container.clientWidth - 6) x = point.x - tooltipWidth - 12 - if (y + tooltipHeight > container.clientHeight - 6) y = point.y - tooltipHeight - 12 - x = Math.max(6, Math.min(x, Math.max(6, container.clientWidth - tooltipWidth - 6))) - y = Math.max(6, Math.min(y, Math.max(6, container.clientHeight - tooltipHeight - 6))) - - setHoverTip({ - visible: true, - x, - y, - row: { - date: k.date, - open: k.open, - high: k.high, - low: k.low, - close: k.close, - ma5: series.ma5[idx], - ma10: series.ma10[idx], - ma20: series.ma20[idx], - macd: series.macd.macd[idx], - signal: series.macd.signal[idx], - rsi6: series.rsi6[idx], - }, + setHoverRow({ + date: k.date, + open: k.open, + high: k.high, + low: k.low, + close: k.close, + ma5: series.ma5[idx], + ma10: series.ma10[idx], + ma20: series.ma20[idx], + macd: series.macd.macd[idx], + signal: series.macd.signal[idx], + rsi6: series.rsi6[idx], }) }) const ro = new ResizeObserver(() => { - chart.applyOptions({ width: container.clientWidth }) - if (macdEl) macdChart?.applyOptions({ width: macdEl.clientWidth }) - if (macdEl && rsiChart) rsiChart?.applyOptions({ width: macdEl.clientWidth }) + chart.applyOptions({ width: container.clientWidth, height: mainHeight }) + if (macdEl) subChartsRef.current.macd?.applyOptions({ width: macdEl.clientWidth, height: macdHeight }) }) ro.observe(container) if (macdEl) ro.observe(macdEl) @@ -526,7 +691,17 @@ export default function InteractiveKline(props: { const total = series.candles.length const from = Math.max(0, total - defaultBars) const to = Math.max(total - 1, 0) - chart.timeScale().setVisibleLogicalRange({ from, to }) + const savedRange = visibleRangeRef.current + const nextRange = + savedRange && savedRange.from >= 0 && savedRange.to < total + ? savedRange + : { from, to } + chart.timeScale().setVisibleLogicalRange(nextRange) + chart.timeScale().subscribeVisibleLogicalRangeChange(range => { + if (!range) return + visibleRangeRef.current = { from: range.from as number, to: range.to as number } + }) + return () => { ro.disconnect() try { @@ -539,24 +714,116 @@ export default function InteractiveKline(props: { } catch { // ignore } + subChartsRef.current.macd = undefined + } + }, [series, indexByDate, interval, intraday, chartHeights.main, chartHeights.macd, fullscreen]) + + useLayoutEffect(() => { + if (!showRsi) { + subChartsRef.current.rsi = undefined + return + } + if (!series.candles.length) return + const rsiEl = rsiRef.current + if (!rsiEl) return + + rsiEl.innerHTML = '' + + const rootStyle = getComputedStyle(document.documentElement) + const bg = rootStyle.getPropertyValue('--card').trim() + const fg = rootStyle.getPropertyValue('--foreground').trim() + const rsiHeight = chartHeights.rsi + const chartWidth = Math.max(rsiEl.clientWidth, rsiEl.parentElement?.clientWidth ?? 0) + + const rsiChart = createChart(rsiEl, { + width: chartWidth, + height: rsiHeight, + layout: { + background: { color: `hsl(${bg})` }, + textColor: `hsl(${fg} / 0.75)`, + }, + rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.1, bottom: 0.1 } }, + timeScale: { borderVisible: false, visible: false }, + grid: { + vertLines: { color: 'rgba(148, 163, 184, 0.06)' }, + horzLines: { color: 'rgba(148, 163, 184, 0.06)' }, + }, + crosshair: { mode: CrosshairMode.Magnet }, + }) + + const rsiLine = rsiChart.addSeries(LineSeries, { + color: CHART_SERIES_COLORS.rsi, + lineWidth: 2, + lastValueVisible: false, + priceLineVisible: false, + autoscaleInfoProvider: () => ({ + priceRange: { minValue: 0, maxValue: 100 }, + }), + }) + + const rsiData = series.klines + .map((k, i) => { + const v = series.rsi6[i] + const time = parseChartTime(k.date) + return v == null || time == null ? null : { time, value: v } + }) + .filter(Boolean) + rsiLine.setData(rsiData as any) + rsiLine.createPriceLine?.({ price: 70, color: 'rgba(239,68,68,0.45)', lineWidth: 1, lineStyle: 2, title: '70' }) + rsiLine.createPriceLine?.({ price: 30, color: 'rgba(16,185,129,0.45)', lineWidth: 1, lineStyle: 2, title: '30' }) + + subChartsRef.current.rsi = rsiChart + + const savedRange = visibleRangeRef.current + if (savedRange) { try { - rsiChart?.remove() + rsiChart.timeScale().setVisibleLogicalRange(savedRange) } catch { // ignore } } - }, [series, lwReady, showRsi, indexByDate, interval]) - return ( -
-
-
K线图
+ const ro = new ResizeObserver(() => { + const width = Math.max(rsiEl.clientWidth, rsiEl.parentElement?.clientWidth ?? 0) + subChartsRef.current.rsi?.applyOptions({ width, height: rsiHeight }) + }) + ro.observe(rsiEl) + + return () => { + ro.disconnect() + try { + rsiChart.remove() + } catch { + // ignore + } + subChartsRef.current.rsi = undefined + } + }, [showRsi, series, chartHeights.rsi, interval, intraday]) + + const shellClass = fullscreen + ? 'flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-card' + : 'card p-4 md:p-5' + + const content = ( +
+
+
+
+ K线图{props.symbol ? ` · ${props.symbol}` : ''} +
+ +
- -
+ {!trend ? ( + + ) : null} +
{([ + { value: 'trend', label: '分时' }, + { value: 'm5', label: '5分' }, + { value: 'm30', label: '30分' }, { value: '1d', label: '日K' }, { value: '1w', label: '周K' }, { value: '1m', label: '月K' }, @@ -575,26 +842,57 @@ export default function InteractiveKline(props: { ))}
- + ) : null} +
+ {trend ? ( +
+ +
+ ) : ( +
+ {error ? (
{error}
) : null} - {!lwReady && libError ? ( -
- 图表库加载失败(网络受限时可能发生)。可稍后重试或检查网络/代理。 + {!loading && !error && !series.klines.length ? ( +
+ {intraday + ? '暂无分钟K线数据(A/HK 支持 5/30 分钟;美股暂降级为日K)。请稍后刷新。' + : '暂无K线数据,请稍后刷新或检查行情数据源/网络。'}
) : null} - {showSkeleton ? ( +
+ {!fullscreen && showSkeleton ? (
{Array.from({ length: 5 }).map((_, i) => (
@@ -603,7 +901,7 @@ export default function InteractiveKline(props: {
))}
- ) : latestMetrics ? ( + ) : !fullscreen && latestMetrics ? (
最新价 {latestMetrics.last.close.toFixed(2)}
涨跌 = 0 ? 'text-rose-500' : 'text-emerald-500'}`}>{latestMetrics.changePct >= 0 ? '+' : ''}{latestMetrics.changePct.toFixed(2)}%
@@ -614,48 +912,84 @@ export default function InteractiveKline(props: { ) : null}
{showSkeleton ? ( -
+
) : ( -
+ <> +
+ {activeIndicators ? ( +
+
+ {hoverRow ? activeIndicators.date : `${activeIndicators.date} · 最新`} +
+
+ + + + + + + + + + {showRsi ? ( + + ) : null} +
+
+ ) : null} + )} - {hoverTip.visible && hoverTip.row ? ( -
-
{hoverTip.row.date}
-
- 开盘价 {hoverTip.row.open.toFixed(2)} - 收盘价 {hoverTip.row.close.toFixed(2)} - 最高价 {hoverTip.row.high.toFixed(2)} - 最低价 {hoverTip.row.low.toFixed(2)} - 5日均线 {hoverTip.row.ma5 != null ? hoverTip.row.ma5.toFixed(2) : '--'} - 10日均线 {hoverTip.row.ma10 != null ? hoverTip.row.ma10.toFixed(2) : '--'} - 20日均线 {hoverTip.row.ma20 != null ? hoverTip.row.ma20.toFixed(2) : '--'} - MACD线 {hoverTip.row.macd != null ? hoverTip.row.macd.toFixed(3) : '--'} - 信号线 {hoverTip.row.signal != null ? hoverTip.row.signal.toFixed(3) : '--'} - RSI强弱 {hoverTip.row.rsi6 != null ? hoverTip.row.rsi6.toFixed(1) : '--'} -
-
- ) : null}
-
+
-
动能指标(MACD{showRsi ? ' + RSI强弱线' : ''})
+
+
MACD 动能指标
+ {activeIndicators ? ( +
+ + +
+ ) : null} +
- MACD 用来看趋势动能和拐点;RSI 用来看是否偏热/偏弱(一般 70 以上偏热,30 以下偏弱)。 + MACD 用来看趋势动能和拐点。
{showSkeleton ? ( -
+
) : ( -
+
)}
+ {showRsi ? ( +
+
+
{INDICATOR_LABELS.rsi}
+ {activeIndicators ? ( + + ) : null} +
+
+ RSI 用来看是否偏热/偏弱(一般 70 以上偏热,30 以下偏弱)。 +
+ {showSkeleton ? ( +
+
+
+ ) : ( +
+ )} +
+ ) : null}
+
+
+ )}
) + + return content } diff --git a/frontend/packages/biz-ui/src/components/IntradayChart.tsx b/frontend/packages/biz-ui/src/components/IntradayChart.tsx new file mode 100644 index 0000000..b29c616 --- /dev/null +++ b/frontend/packages/biz-ui/src/components/IntradayChart.tsx @@ -0,0 +1,398 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { RefreshCw } from 'lucide-react' +import { + CrosshairMode, + HistogramSeries, + LineSeries, + createChart, + type Time, +} from 'lightweight-charts' +import { insightApi } from '@panwatch/api' +import { Button } from '@panwatch/base-ui/components/ui/button' +import { chartMarketLocalization, parseMarketChartTime } from '../market-time' + +type TrendPoint = { + time: string + price: number + avg_price: number | null + volume: number + turnover: number +} + +type TrendsResponse = { + symbol: string + market: string + trade_date: string + pre_close: number | null + updated_at: string + points: TrendPoint[] +} + +type HoverRow = { + time: string + price: number + avg_price: number | null + volume: number + changePct: number | null +} + +const AUTO_REFRESH_MS = 20_000 + +function parseTrendTime(timeStr: string): Time | null { + return parseMarketChartTime(timeStr) +} + +export default function IntradayChart(props: { + symbol: string + market: string + autoRefresh?: boolean + mainHeight?: number + volumeHeight?: number + fullscreen?: boolean +}) { + const mainHeight = props.mainHeight ?? 300 + const volumeHeight = props.volumeHeight ?? 80 + const fullscreen = !!props.fullscreen + const autoRefresh = props.autoRefresh !== false + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [data, setData] = useState(null) + const [hover, setHover] = useState(null) + + const containerRef = useRef(null) + const volRef = useRef(null) + + const load = useCallback(async (opts?: { silent?: boolean }) => { + if (!props.symbol) return + const silent = !!opts?.silent + if (!silent) setLoading(true) + setError('') + try { + const res = await insightApi.intradayTrends(props.symbol, props.market) + setData(res) + } catch (e) { + if (!silent) { + setError(e instanceof Error ? e.message : '加载分时失败') + } + if (!silent) setData(null) + } finally { + if (!silent) setLoading(false) + } + }, [props.symbol, props.market]) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (!autoRefresh) return + const timer = setInterval(() => { + void load({ silent: true }) + }, AUTO_REFRESH_MS) + return () => clearInterval(timer) + }, [autoRefresh, load]) + + const series = useMemo(() => { + const points = (data?.points || []).filter(p => parseTrendTime(p.time) != null) + const preClose = data?.pre_close ?? null + const prices = points.map(p => ({ + time: parseTrendTime(p.time) as Time, + value: p.price, + })) + const avgs = points + .map(p => { + const t = parseTrendTime(p.time) + const v = p.avg_price + return t != null && v != null ? { time: t, value: v } : null + }) + .filter(Boolean) as Array<{ time: Time; value: number }> + const volumes = points.map(p => { + const up = preClose == null ? true : p.price >= preClose + return { + time: parseTrendTime(p.time) as Time, + value: p.volume, + color: up ? 'rgba(239, 68, 68, 0.35)' : 'rgba(16, 185, 129, 0.35)', + } + }) + const last = points.length ? points[points.length - 1] : null + const changePct = + last && preClose && preClose > 0 ? ((last.price - preClose) / preClose) * 100 : null + return { points, prices, avgs, volumes, preClose, last, changePct } + }, [data]) + + const showSkeleton = loading && !series.points.length + + useEffect(() => { + if (!containerRef.current || !series.prices.length) return + + const container = containerRef.current + const volEl = volRef.current + container.innerHTML = '' + if (volEl) volEl.innerHTML = '' + + const rootStyle = getComputedStyle(document.documentElement) + const bg = rootStyle.getPropertyValue('--card').trim() + const fg = rootStyle.getPropertyValue('--foreground').trim() + const up = series.last && series.preClose != null && series.last.price >= series.preClose + + const chart = createChart(container, { + width: container.clientWidth, + height: mainHeight, + localization: chartMarketLocalization, + layout: { + background: { color: `hsl(${bg})` }, + textColor: `hsl(${fg} / 0.85)`, + }, + rightPriceScale: { borderVisible: false }, + timeScale: { + borderVisible: false, + fixRightEdge: !fullscreen, + rightOffset: 2, + barSpacing: 3, + minBarSpacing: 1, + lockVisibleTimeRangeOnResize: true, + timeVisible: true, + secondsVisible: false, + }, + handleScale: { mouseWheel: true, pinch: true, axisPressedMouseMove: true }, + handleScroll: { mouseWheel: false, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: false }, + grid: { + vertLines: { color: 'rgba(148, 163, 184, 0.08)' }, + horzLines: { color: 'rgba(148, 163, 184, 0.08)' }, + }, + crosshair: { mode: CrosshairMode.Magnet }, + }) + + const priceSeries = chart.addSeries(LineSeries, { + color: up ? '#ef4444' : '#10b981', + lineWidth: 2, + priceLineVisible: false, + lastValueVisible: true, + }) + priceSeries.setData(series.prices) + + if (series.preClose != null) { + priceSeries.createPriceLine({ + price: series.preClose, + color: 'rgba(148, 163, 184, 0.75)', + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: '昨收', + }) + } + + if (series.avgs.length) { + const avgSeries = chart.addSeries(LineSeries, { + color: 'rgba(245, 158, 11, 0.9)', + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: false, + }) + avgSeries.setData(series.avgs) + } + + if (volEl) { + const volChart = createChart(volEl, { + width: volEl.clientWidth, + height: volumeHeight, + localization: chartMarketLocalization, + layout: { + background: { color: `hsl(${bg})` }, + textColor: `hsl(${fg} / 0.75)`, + }, + rightPriceScale: { borderVisible: false }, + timeScale: { borderVisible: false, visible: false }, + grid: { + vertLines: { color: 'rgba(148, 163, 184, 0.06)' }, + horzLines: { color: 'rgba(148, 163, 184, 0.06)' }, + }, + crosshair: { mode: CrosshairMode.Magnet }, + }) + const volSeries = volChart.addSeries(HistogramSeries, { + priceFormat: { type: 'volume' }, + }) + volSeries.setData(series.volumes) + chart.timeScale().subscribeVisibleLogicalRangeChange(range => { + if (range) volChart.timeScale().setVisibleLogicalRange(range) + }) + volChart.timeScale().subscribeVisibleLogicalRangeChange(range => { + if (range) chart.timeScale().setVisibleLogicalRange(range) + }) + const ro = new ResizeObserver(() => { + chart.applyOptions({ width: container.clientWidth, height: mainHeight }) + volChart.applyOptions({ width: volEl.clientWidth, height: volumeHeight }) + }) + ro.observe(container) + ro.observe(volEl) + + chart.subscribeCrosshairMove(param => { + if (!param.time || !param.point) { + setHover(null) + return + } + const t = typeof param.time === 'number' ? param.time : null + if (t == null) { + setHover(null) + return + } + const idx = series.prices.findIndex(p => p.time === t) + if (idx < 0) { + setHover(null) + return + } + const row = series.points[idx] + const cp = + series.preClose && series.preClose > 0 + ? ((row.price - series.preClose) / series.preClose) * 100 + : null + setHover({ + time: row.time, + price: row.price, + avg_price: row.avg_price, + volume: row.volume, + changePct: cp, + }) + }) + + chart.timeScale().fitContent() + + return () => { + ro.disconnect() + volChart.remove() + chart.remove() + } + } + + chart.subscribeCrosshairMove(param => { + if (!param.time || !param.point) { + setHover(null) + return + } + const t = typeof param.time === 'number' ? param.time : null + if (t == null) { + setHover(null) + return + } + const idx = series.prices.findIndex(p => p.time === t) + if (idx < 0) { + setHover(null) + return + } + const row = series.points[idx] + const cp = + series.preClose && series.preClose > 0 + ? ((row.price - series.preClose) / series.preClose) * 100 + : null + setHover({ + time: row.time, + price: row.price, + avg_price: row.avg_price, + volume: row.volume, + changePct: cp, + }) + }) + + const ro = new ResizeObserver(() => { + chart.applyOptions({ width: container.clientWidth, height: mainHeight }) + }) + ro.observe(container) + chart.timeScale().fitContent() + + return () => { + ro.disconnect() + chart.remove() + } + }, [series, mainHeight, volumeHeight, fullscreen]) + + const display = hover || (series.last + ? { + time: series.last.time, + price: series.last.price, + avg_price: series.last.avg_price, + volume: series.last.volume, + changePct: series.changePct, + } + : null) + + return ( +
+
+
+ 分时 + {data?.trade_date ? ( + {data.trade_date} + ) : null} +
+
+ {autoRefresh ? ( + 每 20 秒自动刷新 + ) : null} + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {!loading && !error && !series.points.length ? ( +
+ 暂无当日分时数据(非交易时段或数据源暂不可用)。请稍后刷新。 +
+ ) : null} + + {display ? ( +
+
+ 现价 + {display.price.toFixed(2)} +
+
+ 涨跌 + = 0 ? 'text-rose-500' : 'text-emerald-500'}`}> + {display.changePct == null ? '--' : `${display.changePct >= 0 ? '+' : ''}${display.changePct.toFixed(2)}%`} + +
+
+ 均价 + {display.avg_price != null ? display.avg_price.toFixed(2) : '--'} +
+
+ 昨收 + {series.preClose != null ? series.preClose.toFixed(2) : '--'} +
+
+ 成交量 + {(display.volume / 10000).toFixed(1)}万手 +
+
+ ) : showSkeleton ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) : null} + +
+ {showSkeleton ? ( +
+
+
+ ) : ( +
+ )} +
+
+
+ ) +} diff --git a/frontend/packages/biz-ui/src/components/KlineModal.tsx b/frontend/packages/biz-ui/src/components/KlineModal.tsx index 908aa6b..bb01475 100644 --- a/frontend/packages/biz-ui/src/components/KlineModal.tsx +++ b/frontend/packages/biz-ui/src/components/KlineModal.tsx @@ -1,5 +1,7 @@ +import { useCallback, useEffect, useState } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@panwatch/base-ui/components/ui/dialog' -import InteractiveKline from '@panwatch/biz-ui/components/InteractiveKline' +import InteractiveKline, { type KlineInterval, klineDialogFullscreenClassName } from '@panwatch/biz-ui/components/InteractiveKline' +import { cn } from '@panwatch/base-ui' export default function KlineModal(props: { open: boolean @@ -8,27 +10,47 @@ export default function KlineModal(props: { market: string title?: string description?: string - initialInterval?: '1d' | '1w' | '1m' + initialInterval?: KlineInterval initialDays?: '60' | '120' | '250' }) { const symbol = String(props.symbol || '').trim() const market = String(props.market || '').trim() || 'CN' + const [klineFullscreen, setKlineFullscreen] = useState(false) + + useEffect(() => { + if (!props.open) setKlineFullscreen(false) + }, [props.open]) return ( - - - - {props.title || (symbol ? `K线:${symbol}` : 'K线')} - - {props.description || '日K/周K/月K切换,含MA/成交量/MACD。'} - - + { + if (!open) setKlineFullscreen(false) + props.onOpenChange(open) + }} + > + + {!klineFullscreen ? ( + + {props.title || (symbol ? `K线:${symbol}` : 'K线')} + + {props.description || '5分/30分/日K/周K/月K切换,含MA/成交量/MACD。'} + + + ) : null} {symbol ? ( ) : (
未选择股票
diff --git a/frontend/packages/biz-ui/src/components/add-position-calculator.tsx b/frontend/packages/biz-ui/src/components/add-position-calculator.tsx index 9997d1a..2eebe5d 100644 --- a/frontend/packages/biz-ui/src/components/add-position-calculator.tsx +++ b/frontend/packages/biz-ui/src/components/add-position-calculator.tsx @@ -1,8 +1,9 @@ -import { useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import ReactMarkdown from 'react-markdown' -import { insightApi, type AddPositionEvalResult } from '@panwatch/api' +import { insightApi, positionsApi, tradeDatetimeLocalToIso, type AddPositionEvalResult, type PositionAddResult, type PositionTrade } from '@panwatch/api' import { Button } from '@panwatch/base-ui/components/ui/button' import { Input } from '@panwatch/base-ui/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@panwatch/base-ui/components/ui/select' import { useToast } from '@panwatch/base-ui/components/ui/toast' export interface AddPositionCalc { @@ -14,6 +15,21 @@ export interface AddPositionCalc { isAdd: boolean } +export interface ReducePositionCalc { + newQty: number + costUnchanged: number + realizedPnl: number + realizedPct: number + sellAmount: number +} + +export interface PositionHoldingOption { + id: number + account_name: string + quantity: number + cost_price: number +} + /** 加仓后摊薄成本(正算)。无效输入返回 null。 */ export function calcAddPosition( curQty: number, @@ -31,6 +47,27 @@ export function calcAddPosition( return { newQty, newCost, diluteAbs, dilutePct, totalInvested: newQty * newCost, isAdd } } +/** 减仓后剩余股数与实现盈亏。卖出股数不得超过持仓。 */ +export function calcReducePosition( + curQty: number, + curCost: number, + sellQty: number, + sellPrice: number, +): ReducePositionCalc | null { + if (!(sellQty > 0) || !(sellPrice > 0)) return null + if (!(curQty > 0) || sellQty > curQty) return null + const newQty = curQty - sellQty + const realizedPnl = (sellPrice - curCost) * sellQty + const realizedPct = curCost > 0 ? ((sellPrice - curCost) / curCost) * 100 : 0 + return { + newQty, + costUnchanged: curCost, + realizedPnl, + realizedPct, + sellAmount: sellQty * sellPrice, + } +} + /** 反推:把成本降到 target 需要按 addPrice 加多少股。仅当 addPrice < target < curCost 可行。 */ export function calcSharesForTargetCost( curQty: number, @@ -49,11 +86,23 @@ function fmt(n: number | null | undefined, d = 2): string { if (n == null || !isFinite(n)) return '--' return n.toFixed(d) } +/** 成本价:保留有效小数位,避免四舍五入后前后相同 */ +function fmtCost(n: number | null | undefined): string { + if (n == null || !isFinite(n)) return '--' + return n.toFixed(4).replace(/\.?0+$/, '') +} function fmtInt(n: number | null | undefined): string { if (n == null || !isFinite(n)) return '--' return Math.round(n).toLocaleString() } +function fmtTradeTime(raw: string | null | undefined): string { + if (!raw) return '--' + const d = new Date(raw) + if (Number.isNaN(d.getTime())) return raw.slice(0, 16) + return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) +} + const VERDICT_STYLE: Record = { 适合: 'bg-emerald-500/15 text-emerald-500 border-emerald-500/30', 谨慎: 'bg-amber-500/15 text-amber-600 border-amber-500/30', @@ -67,6 +116,16 @@ interface Props { currentQuantity: number currentCost: number currentPrice?: number | null + /** 各账户下的持仓明细(用于选择加仓账户) */ + holdings?: PositionHoldingOption[] + /** 加仓成功后回调(刷新持仓) */ + onApplied?: (result: PositionAddResult) => void + /** 有持仓时默认展开 */ + defaultOpen?: boolean + /** 递增时强制展开(供外部「加仓/减仓」按钮触发) */ + expandSignal?: number + /** 与 expandSignal 配合,展开后切换到加仓或减仓 */ + expandMode?: 'add' | 'reduce' } export default function AddPositionCalculator({ @@ -75,15 +134,55 @@ export default function AddPositionCalculator({ currentQuantity, currentCost, currentPrice, + holdings = [], + onApplied, + defaultOpen = false, + expandSignal = 0, + expandMode = 'add', }: Props) { const { toast } = useToast() - const [open, setOpen] = useState(false) + const [open, setOpen] = useState(defaultOpen) + const [tradeMode, setTradeMode] = useState<'add' | 'reduce'>('add') const [mode, setMode] = useState<'shares' | 'amount'>('shares') const [addRaw, setAddRaw] = useState('') const [priceRaw, setPriceRaw] = useState('') + const [tradeTimeRaw, setTradeTimeRaw] = useState('') const [targetRaw, setTargetRaw] = useState('') const [aiLoading, setAiLoading] = useState(false) const [aiResult, setAiResult] = useState(null) + const [applyLoading, setApplyLoading] = useState(false) + const [trades, setTrades] = useState([]) + const [tradesLoading, setTradesLoading] = useState(false) + + const defaultPositionId = holdings.length === 1 ? String(holdings[0].id) : '' + const [selectedPositionId, setSelectedPositionId] = useState(defaultPositionId) + + useEffect(() => { + if (defaultOpen) setOpen(true) + }, [defaultOpen]) + + useEffect(() => { + if (expandSignal > 0) { + setOpen(true) + setTradeMode(expandMode) + } + }, [expandSignal, expandMode]) + + useEffect(() => { + if (holdings.length === 1) setSelectedPositionId(String(holdings[0].id)) + else if (holdings.length === 0) setSelectedPositionId('') + else if (!holdings.some(h => String(h.id) === selectedPositionId)) { + setSelectedPositionId('') + } + }, [holdings, selectedPositionId]) + + const selectedHolding = useMemo( + () => holdings.find(h => String(h.id) === selectedPositionId) ?? null, + [holdings, selectedPositionId], + ) + + const baseQty = selectedHolding?.quantity ?? currentQuantity + const baseCost = selectedHolding?.cost_price ?? currentCost const isCN = market === 'CN' @@ -93,27 +192,51 @@ export default function AddPositionCalculator({ return currentPrice && currentPrice > 0 ? currentPrice : 0 }, [priceRaw, currentPrice]) - // 输入(股数/金额)→ 加仓股数 - const addQty = useMemo(() => { + const tradeQty = useMemo(() => { const v = parseFloat(addRaw) if (!isFinite(v) || v <= 0) return 0 - if (mode === 'shares') return v - return addPrice > 0 ? v / addPrice : 0 + if (mode === 'shares') return Math.round(v) + return addPrice > 0 ? Math.round(v / addPrice) : 0 }, [addRaw, mode, addPrice]) - const calc = useMemo( - () => calcAddPosition(currentQuantity, currentCost, addQty, addPrice), - [currentQuantity, currentCost, addQty, addPrice], + const addCalc = useMemo( + () => (tradeMode === 'add' ? calcAddPosition(baseQty, baseCost, tradeQty, addPrice) : null), + [tradeMode, baseQty, baseCost, tradeQty, addPrice], + ) + + const reduceCalc = useMemo( + () => (tradeMode === 'reduce' ? calcReducePosition(baseQty, baseCost, tradeQty, addPrice) : null), + [tradeMode, baseQty, baseCost, tradeQty, addPrice], ) const reverseShares = useMemo(() => { const t = parseFloat(targetRaw) if (!isFinite(t) || t <= 0) return null - return calcSharesForTargetCost(currentQuantity, currentCost, addPrice, t) - }, [targetRaw, currentQuantity, currentCost, addPrice]) + return calcSharesForTargetCost(baseQty, baseCost, addPrice, t) + }, [targetRaw, baseQty, baseCost, addPrice]) + + const loadTrades = useCallback(async (positionId: number) => { + setTradesLoading(true) + try { + const rows = await positionsApi.trades(positionId, 10) + setTrades(rows || []) + } catch { + setTrades([]) + } finally { + setTradesLoading(false) + } + }, []) + + useEffect(() => { + if (!open || !selectedPositionId) { + setTrades([]) + return + } + void loadTrades(Number(selectedPositionId)) + }, [open, selectedPositionId, loadTrades]) const runAi = async () => { - if (!calc || addQty <= 0 || addPrice <= 0) { + if (!addCalc || tradeQty <= 0 || addPrice <= 0) { toast('请先填写有效的加仓股数/金额与价格', 'error') return } @@ -123,9 +246,9 @@ export default function AddPositionCalculator({ const res = await insightApi.addPositionEval({ symbol, market, - current_quantity: currentQuantity, - current_cost: currentCost, - add_quantity: Math.round(addQty), + current_quantity: baseQty, + current_cost: baseCost, + add_quantity: tradeQty, add_price: addPrice, }) setAiResult(res) @@ -136,23 +259,169 @@ export default function AddPositionCalculator({ } } - const pricePlaceholder = currentPrice && currentPrice > 0 ? String(currentPrice) : '加仓价' - const hasHolding = currentQuantity > 0 && currentCost > 0 - const lotWarn = isCN && addQty > 0 && Math.round(addQty) % 100 !== 0 + const applyTradeResult = useCallback( + (result: PositionAddResult) => { + if (result.trade) { + setTrades((prev) => [result.trade, ...prev.filter((t) => t.id !== result.trade.id)]) + } + onApplied?.(result) + }, + [onApplied], + ) + + const confirmAdd = async () => { + if (!selectedHolding) { + toast('请选择要加仓的账户', 'error') + return + } + if (!addCalc || tradeQty <= 0 || addPrice <= 0) { + toast('请先填写有效的加仓股数/金额与价格', 'error') + return + } + setApplyLoading(true) + try { + const traded_at = tradeDatetimeLocalToIso(tradeTimeRaw) + const result = await positionsApi.add(selectedHolding.id, { + price: addPrice, + quantity: tradeQty, + ...(traded_at ? { traded_at } : {}), + }) + toast(`已记录加仓 ${tradeQty} 股 @ ${fmt(addPrice)},新成本 ${fmtCost(result.position.cost_price)},股票现金已扣减 ${fmtInt(tradeQty * addPrice)} 元`, 'success') + setAddRaw('') + setPriceRaw('') + setTradeTimeRaw('') + setTargetRaw('') + setAiResult(null) + applyTradeResult(result) + await loadTrades(selectedHolding.id) + } catch (e: any) { + toast(e?.message || '加仓记录失败', 'error') + } finally { + setApplyLoading(false) + } + } + + const confirmReduce = async () => { + if (!selectedHolding) { + toast('请选择要减仓的账户', 'error') + return + } + if (!reduceCalc || tradeQty <= 0 || addPrice <= 0) { + toast('请先填写有效的卖出股数/金额与价格', 'error') + return + } + setApplyLoading(true) + try { + const traded_at = tradeDatetimeLocalToIso(tradeTimeRaw) + const result = await positionsApi.reduce(selectedHolding.id, { + price: addPrice, + quantity: tradeQty, + ...(traded_at ? { traded_at } : {}), + }) + const pnl = reduceCalc.realizedPnl + const isClosed = result.closed === true + const proceeds = tradeQty * addPrice + toast( + isClosed + ? `已清仓 ${tradeQty} 股 @ ${fmt(addPrice)},回款 ${fmtInt(proceeds)} 元已计入股票现金` + : `已记录减仓 ${tradeQty} 股 @ ${fmt(addPrice)},剩余 ${fmtInt(reduceCalc.newQty)} 股,回款 ${fmtInt(proceeds)} 元已计入股票现金,本次盈亏 ${pnl >= 0 ? '+' : ''}${fmt(pnl)}`, + 'success', + ) + setAddRaw('') + setPriceRaw('') + setTradeTimeRaw('') + setTargetRaw('') + setAiResult(null) + applyTradeResult(result) + await loadTrades(selectedHolding.id) + } catch (e: any) { + toast(e?.message || '减仓记录失败', 'error') + } finally { + setApplyLoading(false) + } + } + + const pricePlaceholder = currentPrice && currentPrice > 0 + ? String(currentPrice) + : (tradeMode === 'reduce' ? '卖出价' : '买入价') + const hasHolding = baseQty > 0 && baseCost > 0 + const lotWarn = isCN && tradeQty > 0 && tradeQty % 100 !== 0 + const canApplyAdd = !!selectedHolding && !!addCalc && tradeQty > 0 && addPrice > 0 + const canApplyReduce = !!selectedHolding && !!reduceCalc && tradeQty > 0 && addPrice > 0 + const isReduce = tradeMode === 'reduce' return (
{open && (
+ {hasHolding && ( +
+ {(['add', 'reduce'] as const).map((m) => ( + + ))} +
+ )} + + {holdings.length > 1 && ( + + )} + + {holdings.length === 1 && ( +
+ 账户:{holdings[0].account_name} · 当前 {fmtInt(holdings[0].quantity)} 股 @ {fmtCost(holdings[0].cost_price)} +
+ )} +
{(['shares', 'amount'] as const).map((m) => (
- {mode === 'amount' && addQty > 0 && ( + + + {mode === 'amount' && tradeQty > 0 && (
- ≈ {fmtInt(addQty)} 股{isCN ? `(≈${fmtInt(addQty / 100)} 手)` : ''} + ≈ {fmtInt(tradeQty)} 股{isCN ? `(≈${fmtInt(tradeQty / 100)} 手)` : ''}
)} - {calc ? ( + {isReduce ? ( + reduceCalc ? ( +
+
+ 剩余股数 + {fmtInt(reduceCalc.newQty)} +
+
+ 成本(不变) + {fmt(reduceCalc.costUnchanged)} +
+
+ 本次盈亏 + = 0 ? 'text-rose-500' : 'text-emerald-500'}`}> + {reduceCalc.realizedPnl >= 0 ? '+' : ''}{fmt(reduceCalc.realizedPnl)} + ({reduceCalc.realizedPct >= 0 ? '+' : ''}{fmt(reduceCalc.realizedPct)}%) + +
+
+ 卖出金额 + {fmtInt(reduceCalc.sellAmount)} +
+ {lotWarn && ( +
提示:A股通常 100 股/手,建议取整到 100 的倍数
+ )} +
+ ) : tradeQty > baseQty ? ( +
卖出股数不能超过当前持仓 {fmtInt(baseQty)} 股
+ ) : ( +
填写卖出股数/金额与价格后自动计算
+ ) + ) : addCalc ? (
- {calc.isAdd ? '加仓后成本' : '建仓成本'} - {fmt(calc.newCost)} + {addCalc.isAdd ? '加仓后成本' : '建仓成本'} + {fmt(addCalc.newCost)}
- {calc.isAdd && ( + {addCalc.isAdd && (
摊薄 - = 0 ? 'text-emerald-500' : 'text-rose-500'}`}> - {calc.diluteAbs >= 0 ? '↓' : '↑'} - {fmt(Math.abs(calc.diluteAbs))}({fmt(Math.abs(calc.dilutePct))}%) + = 0 ? 'text-emerald-500' : 'text-rose-500'}`}> + {addCalc.diluteAbs >= 0 ? '↓' : '↑'} + {fmt(Math.abs(addCalc.diluteAbs))}({fmt(Math.abs(addCalc.dilutePct))}%)
)}
合计股数 / 投入 - {fmtInt(calc.newQty)} / {fmtInt(calc.totalInvested)} + {fmtInt(addCalc.newQty)} / {fmtInt(addCalc.totalInvested)}
{lotWarn && ( @@ -225,10 +540,39 @@ export default function AddPositionCalculator({ )}
) : ( -
填写加仓股数/金额与价格后自动计算
+
填写买入股数/金额与价格后自动计算
)} - {hasHolding && ( + {hasHolding && isReduce && ( + + )} + + {hasHolding && !isReduce && ( + + )} + + {!hasHolding && holdings.length === 0 && ( +
+ 当前未持仓。请先在「持仓」页添加持仓后再记录加仓。 +
+ )} + + {hasHolding && !isReduce && (
{targetRaw.trim() === '' ? ( - 按加仓价反推所需股数 + 按买入价反推所需股数 ) : reverseShares != null ? ( 需加 {fmtInt(reverseShares)} 股 @@ -249,22 +593,52 @@ export default function AddPositionCalculator({
{fmtInt(reverseShares * addPrice)}
) : ( - 需 加仓价 < 目标 < 现成本 才能降到该成本 + 需 买入价 < 目标 < 现成本 才能降到该成本 )}
)} + {selectedPositionId && ( +
+
最近交易记录
+ {tradesLoading ? ( +
加载中…
+ ) : trades.length === 0 ? ( +
暂无记录
+ ) : ( +
    + {trades.map(t => ( +
  • + {fmtTradeTime(t.traded_at)} + + {t.side === 'sell' ? '-' : '+'}{fmtInt(t.quantity)} @ {fmt(t.price)} + + + 成本 {fmtCost(t.cost_before)} → {fmtCost(t.cost_after)} + {t.qty_before != null && t.qty_after != null && t.qty_before !== t.qty_after ? ( + · {fmtInt(t.qty_before)}→{fmtInt(t.qty_after)}股 + ) : null} + +
  • + ))} +
+ )} +
+ )} +
- + {!isReduce && ( + + )}
{aiResult && ( diff --git a/frontend/packages/biz-ui/src/components/ai-chain-rotation-banner.tsx b/frontend/packages/biz-ui/src/components/ai-chain-rotation-banner.tsx new file mode 100644 index 0000000..d5b7590 --- /dev/null +++ b/frontend/packages/biz-ui/src/components/ai-chain-rotation-banner.tsx @@ -0,0 +1,230 @@ +import { ChevronRight } from 'lucide-react' +import { cn } from '@panwatch/base-ui' +import { + AI_COMPUTE_STEPS, + AI_POST_COMPUTE_PHASES, + CHAIN_HOT_LAYER, + CHAIN_NEXT_LAYER, + CHAIN_THEME_LAYER, + chainLayerFilterKey, + type ChainLayerTheme, +} from '../lib/ai-chain-rotation' + +export interface ChainRotationFilterOption { + key: string + layer: string + count: number +} + +interface AiChainRotationBannerProps { + layerCounts?: ChainRotationFilterOption[] + activeFilterKey?: string + onToggleFilter?: (key: string) => void + className?: string +} + +function countForLayer( + layerKey: string, + layerCounts?: ChainRotationFilterOption[], +): number { + const filterKey = chainLayerFilterKey(layerKey) + return layerCounts?.find((opt) => opt.key === filterKey || opt.layer === layerKey)?.count ?? 0 +} + +function RotationNode({ + theme, + count, + active, + pulse, + badge, + onClick, + size = 'md', +}: { + theme: ChainLayerTheme + count: number + active: boolean + pulse?: 'hot' | 'next' | 'theme' | null + badge?: string + onClick?: () => void + size?: 'md' | 'lg' +}) { + const skipped = theme.skipAshare + const large = size === 'lg' + + return ( + + ) +} + +function FlowTrack({ className, large = false }: { className?: string; large?: boolean }) { + return ( +
+
+
+ ) +} + +export function AiChainRotationBanner({ + layerCounts, + activeFilterKey = '', + onToggleFilter, + className, +}: AiChainRotationBannerProps) { + const handleToggle = (layerKey: string) => { + onToggleFilter?.(chainLayerFilterKey(layerKey)) + } + + const isActive = (layerKey: string) => activeFilterKey === chainLayerFilterKey(layerKey) + + return ( +
+
+
AI 行情轮动
+
+ 沿物理连接顺序 · 颜色与下方卡片对应 +
+
+ + {/* 主链:算力 → 云&大模型 → 软件 → 物理AI */} +
+ AI算力 + + + 云&大模型 + 跳过 + + + + 软件应用 + 跳过 + + + + 物理AI + 登场 + +
+ + {/* 算力细分链 */} +
+
算力细分
+
+ +
+ {AI_COMPUTE_STEPS.map((theme, index) => ( +
+ handleToggle(theme.key)} + /> + {index < AI_COMPUTE_STEPS.length - 1 ? ( + + ) : null} +
+ ))} +
+
+
+ + {/* 算力链后:跳过环节 + 物理AI */} +
+ A股主线 + {AI_POST_COMPUTE_PHASES.map((theme) => ( + handleToggle(theme.key)} + /> + ))} +
+
+ ) +} diff --git a/frontend/packages/biz-ui/src/components/chan-emotion-brief.tsx b/frontend/packages/biz-ui/src/components/chan-emotion-brief.tsx new file mode 100644 index 0000000..46b4b52 --- /dev/null +++ b/frontend/packages/biz-ui/src/components/chan-emotion-brief.tsx @@ -0,0 +1,90 @@ +import { cn } from '@panwatch/base-ui' +import { TechnicalBadge, technicalToneFromSuggestionAction } from '@panwatch/biz-ui/components/technical-badge' +import type { ChanEmotionBrief } from '@panwatch/api/insight' +import type { MouseEvent } from 'react' + +function emotionTone(phase?: string): 'bullish' | 'bearish' | 'neutral' { + if (phase === 'profit_effect') return 'bullish' + if (phase === 'loss_effect') return 'bearish' + return 'neutral' +} + +function emotionShortLabel(label?: string): string { + if (!label) return '情绪' + return label.split('(')[0] || label +} + +export function ChanEmotionBrief(props: { + data?: ChanEmotionBrief | null + loading?: boolean + compact?: boolean + className?: string + onClick?: () => void + onRequestLoad?: () => void +}) { + const { data, loading = false, compact = true, className, onClick, onRequestLoad } = props + + if (loading && !data) { + return ( +
+ + 缠论分析中… + +
+ ) + } + + if (!data) { + if (!onRequestLoad) return null + const handleRequestLoad = (e: MouseEvent) => { + e.stopPropagation() + onRequestLoad() + } + return ( +
+ +
+ ) + } + + const title = data.reason || `${data.action_label} · 赢面 ${data.win_rate}% · ${data.emotion_label}` + + const handleClick = (e: MouseEvent) => { + e.stopPropagation() + onClick?.() + } + + return ( +
+ + = 70 ? 'bullish' : data.win_rate < 55 ? 'bearish' : 'neutral'} + size={compact ? 'xs' : 'sm'} + /> + +
+ ) +} diff --git a/frontend/packages/biz-ui/src/components/chan-emotion-strategy-panel.tsx b/frontend/packages/biz-ui/src/components/chan-emotion-strategy-panel.tsx new file mode 100644 index 0000000..b101fc6 --- /dev/null +++ b/frontend/packages/biz-ui/src/components/chan-emotion-strategy-panel.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from 'react' +import { TechnicalBadge } from '@panwatch/biz-ui/components/technical-badge' +import { technicalToneFromSuggestionAction } from '@panwatch/biz-ui/components/technical-badge' +import { insightApi, type ChanEmotionStrategyResult } from '@panwatch/api/insight' + +interface ChanEmotionStrategyPanelProps { + symbol: string + market: string + hasPosition?: boolean +} + +function trendLabel(trend: string): string { + switch (trend) { + case 'trend_up': + return '上升趋势' + case 'trend_down': + return '下降趋势' + case 'consolidation': + return '盘整' + default: + return '待识别' + } +} + +function emotionTone(phase: string): 'bullish' | 'bearish' | 'neutral' { + if (phase === 'profit_effect') return 'bullish' + if (phase === 'loss_effect') return 'bearish' + return 'neutral' +} + +function fmt(value: number | null | undefined, digits = 2): string { + if (value == null || !Number.isFinite(value)) return '--' + return value.toFixed(digits) +} + +export function ChanEmotionStrategyPanel({ + symbol, + market, + hasPosition = false, +}: ChanEmotionStrategyPanelProps) { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [data, setData] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + if (!open || !symbol) return + let cancelled = false + setLoading(true) + setError('') + insightApi + .chanEmotionStrategy(symbol, { market, holding: hasPosition }) + .then((res) => { + if (!cancelled) setData(res) + }) + .catch((e: Error) => { + if (!cancelled) { + setError(e.message || '加载失败') + setData(null) + } + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [open, symbol, market, hasPosition]) + + const brief = data + ? `${data.action_label} · 赢面${data.win_rate}% · ${data.emotion_label}` + : '缠论几何+养家心法:多级别联立、背驰触发、情绪仓位' + + return ( +
+ + + {!open && ( +
{brief}
+ )} + + {open && ( +
+ {loading &&
分析中...
} + {error &&
{error}
} + {data && !loading && ( + <> +
+ 以 30 分钟为主操作级别,5 分钟精确定位,日线定方向;形态识别为基础,动力学背驰为触发,市场情绪定仓位。 +
+ +
+ + = 70 ? 'bullish' : data.win_rate < 60 ? 'bearish' : 'neutral'} + size="xs" + /> + +
+ +
{data.signal}
+
{data.reason}
+ + {data.decision_explanation && ( +
+
信号推导
+ {data.decision_explanation} +
+ )} + +
+ {data.levels.map((lvl) => ( +
+
{lvl.label}
+
{trendLabel(lvl.trend)}
+
+ 笔 {lvl.stroke_count} · K {lvl.bar_count} +
+ {lvl.pivot && ( +
+ ZD {fmt(lvl.pivot.zd)} / ZG {fmt(lvl.pivot.zg)} +
+ )} + {lvl.bar_count === 0 && ( +
+ {lvl.timeframe === '1d' ? '日线数据暂不可用' : '分钟数据暂不可用'} +
+ )} + {lvl.signal_tags.length > 0 && ( +
+ {lvl.signal_tags.slice(0, 2).map((tag) => ( + + {tag} + + ))} +
+ )} +
+ ))} +
+ +
+
+
建议仓位
+
{data.position_label}
+
+
+
止损参考
+
{fmt(data.stop_loss)}
+
+
+
目标参考
+
{fmt(data.target_price)}
+
+
+ + {data.invalidation && ( +
+ 失效条件:{data.invalidation} +
+ )} + +
+
Agent 执行指令
+ {data.agent_instruction} +
+ +
    + {data.human_notes.map((note) => ( +
  • {note}
  • + ))} +
+ + )} +
+ )} +
+ ) +} diff --git a/frontend/packages/biz-ui/src/components/deep-analysis-modal.tsx b/frontend/packages/biz-ui/src/components/deep-analysis-modal.tsx index aca5310..63eb0b8 100644 --- a/frontend/packages/biz-ui/src/components/deep-analysis-modal.tsx +++ b/frontend/packages/biz-ui/src/components/deep-analysis-modal.tsx @@ -18,10 +18,15 @@ import { HoverPopover } from '@panwatch/base-ui/components/ui/hover-popover' import { tradingAgentsApi, type BudgetInfo, + type DeepAnalysisMode, type DeepAnalysisResult, + analystTypesForMode, + deepAnalysisModeEta, + loadDeepAnalysisMode, type ProgressResponse, type ProgressStage, } from '@panwatch/api' +import { DeepAnalysisModePicker } from './deep-analysis-mode-picker' const STAGE_LABEL: Record = { market_analyst: '技术分析师', @@ -108,6 +113,7 @@ export function DeepAnalysisModal({ const [result, setResult] = useState(initialResult) const [error, setError] = useState('') const [budget, setBudget] = useState(null) + const [analysisMode, setAnalysisMode] = useState(() => loadDeepAnalysisMode()) const timerRef = useRef | null>(null) // trigger 时间戳:前 60s 内允许 not_found(后端日志还没来得及写),不重置 const triggerStartedRef = useRef(0) @@ -148,7 +154,7 @@ export function DeepAnalysisModal({ Promise.all([ tradingAgentsApi.findRunning(stockSymbol).catch(() => ({ trace_id: null, status: 'none' as const })), tradingAgentsApi.getLatestForStock(stockSymbol).catch(() => null), - tradingAgentsApi.getBudget().catch(() => null), + tradingAgentsApi.getBudget(analystTypesForMode(analysisMode)).catch(() => null), ]).then(([runningInfo, latestResult, budgetInfo]) => { setBudget(budgetInfo) @@ -203,7 +209,12 @@ export function DeepAnalysisModal({ clearRunningTrace(stockSymbol) }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, initialResult, stockSymbol]) + }, [open, initialResult, stockSymbol, analysisMode]) + + useEffect(() => { + if (!open || stage !== 'idle' || initialResult) return + tradingAgentsApi.getBudget(analystTypesForMode(analysisMode)).then(setBudget).catch(() => null) + }, [analysisMode, initialResult, open, stage]) const pollProgress = useCallback( async (tid: string) => { @@ -272,8 +283,9 @@ export function DeepAnalysisModal({ setError('') setProgress(null) triggerStartedRef.current = Date.now() + const analystTypes = analystTypesForMode(analysisMode) try { - const triggerResp = await tradingAgentsApi.trigger(stockId, { force }) + const triggerResp = await tradingAgentsApi.trigger(stockId, { force, analystTypes }) const tid = triggerResp.trace_id || '' setTraceId(tid) if (!tid) { @@ -294,7 +306,7 @@ export function DeepAnalysisModal({ setStage('error') setError(e instanceof Error ? e.message : '触发失败') } - }, [stockId, stockSymbol, pollProgress, toast]) + }, [analysisMode, stockId, stockSymbol, pollProgress, toast]) const handleClose = useCallback(() => { if (timerRef.current) { @@ -320,6 +332,8 @@ export function DeepAnalysisModal({ handleStart(false)} onCancel={handleClose} /> @@ -355,11 +369,15 @@ export function DeepAnalysisModal({ function IdleView({ stockSymbol, budget, + analysisMode, + onAnalysisModeChange, onStart, onCancel, }: { stockSymbol: string budget: BudgetInfo | null + analysisMode: DeepAnalysisMode + onAnalysisModeChange: (mode: DeepAnalysisMode) => void onStart: () => void onCancel: () => void }) { @@ -369,11 +387,9 @@ function IdleView({
即将分析:{stockSymbol}
-
- 调用 4 类分析师(技术 / 情绪 / 新闻 / 基本面) + 看多看空辩论 + 风控 + PM 整合 -
+
-
⏱ 预计耗时:3-8 分钟
+
⏱ 预计耗时:{deepAnalysisModeEta(analysisMode)}
{est ? (
💰 预估成本:${est.cost_low_usd.toFixed(2)} - ${est.cost_high_usd.toFixed(2)} ({est.model})
) : ( @@ -506,7 +522,7 @@ export function ToolkitDiagnostics({ 数据注入诊断 - (PanWatch 数据 → TradingAgents 工具) + (AlphaMind 数据 → TradingAgents 工具) HIT {hit} @@ -517,8 +533,8 @@ export function ToolkitDiagnostics({
- HIT: 用 PanWatch 数据 ·{' '} - MISS: 命中但 PanWatch 未实现 ·{' '} + HIT: 用 AlphaMind 数据 ·{' '} + MISS: 命中但 AlphaMind 未实现 ·{' '} 透传: 非 A 股直接走上游 vendor ·{' '} 兜底: A 股但 cache 为空,走了上游
diff --git a/frontend/packages/biz-ui/src/components/deep-analysis-mode-picker.tsx b/frontend/packages/biz-ui/src/components/deep-analysis-mode-picker.tsx new file mode 100644 index 0000000..3927fc7 --- /dev/null +++ b/frontend/packages/biz-ui/src/components/deep-analysis-mode-picker.tsx @@ -0,0 +1,49 @@ +import { + type DeepAnalysisMode, + deepAnalysisModeDescription, + deepAnalysisModeLabel, + saveDeepAnalysisMode, +} from '@panwatch/api/tradingagents' + +export function DeepAnalysisModePicker({ + mode, + onChange, + className = '', +}: { + mode: DeepAnalysisMode + onChange: (mode: DeepAnalysisMode) => void + className?: string +}) { + const options: Array<{ value: DeepAnalysisMode; label: string }> = [ + { value: 'full', label: deepAnalysisModeLabel('full') }, + { value: 'fundamentals', label: deepAnalysisModeLabel('fundamentals') }, + ] + + return ( +
+
分析范围
+
+ {options.map((opt) => ( + + ))} +
+
+ {deepAnalysisModeDescription(mode)} +
+
+ ) +} diff --git a/frontend/packages/biz-ui/src/components/etf-nav-chart.tsx b/frontend/packages/biz-ui/src/components/etf-nav-chart.tsx new file mode 100644 index 0000000..93badbe --- /dev/null +++ b/frontend/packages/biz-ui/src/components/etf-nav-chart.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef } from 'react' +import { + AreaSeries, + createChart, + type IChartApi, + type Time, +} from 'lightweight-charts' + +export interface EtfNavChartProps { + /** [{date:'YYYY-MM-DD', unit_nav, cum_nav}] 升序 */ + data: Array<{ date: string; unit_nav: number | null; cum_nav: number | null }> + /** 画哪条曲线,默认单位净值 */ + field?: 'unit_nav' | 'cum_nav' + height?: number +} + +/** + * ETF 净值曲线 —— 基于 lightweight-charts AreaSeries。 + * 无外部交互态,数据驱动重绘;容器为空时跳过。 + */ +export function EtfNavChart({ data, field = 'unit_nav', height = 240 }: EtfNavChartProps) { + const containerRef = useRef(null) + const chartRef = useRef(null) + + useEffect(() => { + if (!containerRef.current) return + const chart = createChart(containerRef.current, { + height, + width: containerRef.current.clientWidth, + layout: { + background: { color: 'transparent' }, + textColor: '#94a3b8', + fontSize: 11, + }, + grid: { + vertLines: { color: 'rgba(148,163,184,0.08)' }, + horzLines: { color: 'rgba(148,163,184,0.08)' }, + }, + rightPriceScale: { borderVisible: false }, + timeScale: { borderVisible: false, timeVisible: false }, + crosshair: { mode: 0 }, + }) + chartRef.current = chart + + const series = chart.addSeries(AreaSeries, { + lineColor: '#3b82f6', + topColor: 'rgba(59,130,246,0.25)', + bottomColor: 'rgba(59,130,246,0.02)', + lineWidth: 2, + priceLineVisible: false, + }) + + const points = data + .filter((p) => p[field] != null && p.date) + .map((p) => ({ time: p.date as Time, value: p[field] as number })) + series.setData(points) + chart.timeScale().fitContent() + + const ro = new ResizeObserver(() => { + chart.applyOptions({ width: containerRef.current?.clientWidth ?? 0 }) + }) + ro.observe(containerRef.current) + + return () => { + ro.disconnect() + chart.remove() + chartRef.current = null + } + }, [data, field, height]) + + return
+} diff --git a/frontend/packages/biz-ui/src/components/etf-overview-modal.tsx b/frontend/packages/biz-ui/src/components/etf-overview-modal.tsx new file mode 100644 index 0000000..11f7173 --- /dev/null +++ b/frontend/packages/biz-ui/src/components/etf-overview-modal.tsx @@ -0,0 +1,296 @@ +import { useEffect, useState } from 'react' +import { RefreshCw, Sparkles } from 'lucide-react' +import { stocksApi, type EtfOverview } from '@panwatch/api' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@panwatch/base-ui/components/ui/dialog' +import { Button } from '@panwatch/base-ui/components/ui/button' +import { Badge } from '@panwatch/base-ui/components/ui/badge' +import { EtfNavChart } from './etf-nav-chart' +import { ReportMarkdown } from './report-markdown' + +export interface EtfOverviewModalProps { + code: string + name?: string + open: boolean + onOpenChange: (open: boolean) => void +} + +type Tab = 'overview' | 'holdings' | 'nav' | 'ai' + +function fmtNum(v: number | null | undefined, digits = 2): string { + if (v == null || Number.isNaN(v)) return '--' + return v.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }) +} + +function fmtPct(v: number | null | undefined): string { + if (v == null || Number.isNaN(v)) return '--' + const sign = v > 0 ? '+' : '' + return `${sign}${v.toFixed(2)}%` +} + +function fmtMoney(v: number | null | undefined): string { + if (v == null || Number.isNaN(v)) return '--' + if (v >= 1e8) return `${(v / 1e8).toFixed(2)} 亿` + if (v >= 1e4) return `${(v / 1e4).toFixed(2)} 万` + return v.toLocaleString('zh-CN') +} + +/** + * 场内 ETF 详情弹窗 —— 实时行情(IOPV/折价率/规模) + 成分股 + 净值曲线。 + * 数据来自 GET /api/stocks/etf/{code}/overview,各部分独立兜底。 + */ +export function EtfOverviewModal({ code, name, open, onOpenChange }: EtfOverviewModalProps) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [tab, setTab] = useState('overview') + const [aiContent, setAiContent] = useState('') + const [aiLoading, setAiLoading] = useState(false) + const [aiError, setAiError] = useState('') + + const load = async () => { + setLoading(true) + setError('') + try { + const res = await stocksApi.etfOverview(code) + setData(res) + } catch (e) { + setError(e instanceof Error ? e.message : '加载 ETF 详情失败') + } finally { + setLoading(false) + } + } + + const runAiAnalysis = async () => { + setAiLoading(true) + setAiError('') + setAiContent('') + try { + // 无绑定触发(详情弹窗的 ETF 可能未加入自选),wait 同步等结果 + const res = await stocksApi.triggerAgent(0, 'etf_holding_analyst', { + allow_unbound: true, + symbol: code, + market: 'CN', + name: name || data?.spot?.name || code, + wait: true, + }) + const content = + (res.result as Record | undefined)?.content as string | undefined + setAiContent(content || res.message || '分析完成,但未返回内容') + } catch (e) { + setAiError(e instanceof Error ? e.message : 'AI 分析失败') + } finally { + setAiLoading(false) + } + } + + useEffect(() => { + if (open && code) { + setTab('overview') + setAiContent('') + setAiError('') + load() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, code]) + + const spot = data?.spot + const holdings = data?.holdings ?? [] + const nav = data?.nav_history ?? [] + + return ( + + + + + ETF + {code} + {name || spot?.name} + + + + +
+ {(['overview', 'holdings', 'nav', 'ai'] as Tab[]).map((t) => ( + + ))} +
+ +
+ {error && ( +
{error}
+ )} + + {!error && !data && loading && ( +
加载中…
+ )} + + {data && tab === 'overview' && ( +
+ = 0 ? 'up' : 'down'} /> + = 0 ? 'up' : 'down'} /> + + + + + + +
+ )} + + {data && tab === 'holdings' && ( + holdings.length === 0 ? ( +
暂无成分股数据(季报披露后更新)
+ ) : ( + + + + + + + + + + + + {holdings.map((h, i) => { + const maxW = holdings[0]?.weight_pct || 1 + return ( + + + + + + + + ) + })} + +
#代码名称占净值权重
{i + 1}{h.symbol}{h.name}{h.weight_pct.toFixed(2)}% +
+
+
+
+ ) + )} + + {data && tab === 'nav' && ( + nav.length === 0 ? ( +
暂无净值数据
+ ) : ( +
+
+ 区间: {nav[0]?.date} ~ {nav[nav.length - 1]?.date} + · + 最新单位净值: {fmtNum(nav[nav.length - 1]?.unit_nav)} +
+ +
+ ) + )} + + {data && tab === 'ai' && ( +
+ {!aiContent && !aiLoading && !aiError && ( +
+ +
+ 分析 ETF 成分股集中度、折溢价、与持仓重叠,生成结构化操作建议 +
+ +
+ )} + {aiLoading && ( +
+ +
+ 正在分析成分股与持仓重叠…(约 10-30 秒) +
+
+ )} + {aiError && ( +
+
{aiError}
+ +
+ )} + {aiContent && ( +
+
+ +
+ +
+ )} +
+ )} +
+
+
+ ) +} + +function Metric({ + label, + value, + hint, + accent, +}: { + label: string + value: string + hint?: string + accent?: 'up' | 'down' +}) { + const color = + accent === 'up' ? 'text-red-500' : accent === 'down' ? 'text-green-500' : 'text-foreground' + return ( +
+
{label}
+
{value}
+ {hint &&
{hint}
} +
+ ) +} + +export default EtfOverviewModal diff --git a/frontend/packages/biz-ui/src/components/industry-chain-badge.tsx b/frontend/packages/biz-ui/src/components/industry-chain-badge.tsx new file mode 100644 index 0000000..a7ba972 --- /dev/null +++ b/frontend/packages/biz-ui/src/components/industry-chain-badge.tsx @@ -0,0 +1,93 @@ +import { cn } from '@panwatch/base-ui' +import type { IndustryChainInfo } from '@panwatch/api' +import { + CHAIN_LAYER_BADGE_STYLES, + formatIndustryChainDisplay, + normalizeChainLayer, +} from '../lib/ai-chain-rotation' + +const ROTATION_FRAMEWORK = + '轮动框架:GPU→CPO→HBM→PCB→液冷→材料设备→服务器→IDC→电力→物理AI(云&大模型、软件应用 A股跳过)' + +const A_SHARE_SKIP_LAYERS = new Set(['cloud_llm', 'software_app']) + +function formatMatchSource(source?: string): string { + if (source === 'symbol') return '龙头代码白名单直配' + if (source === 'keyword') return 'AI 赛道 + 轮动环节关键词匹配' + if (source === 'ai') return 'AI 结合名称/行业/概念归类' + if (source === 'manual') return '手动指定' + if (source === 'fallback') return '未命中人工智能赛道' + return '自动归类' +} + +function formatMatchedItems(matched?: string[]): string[] { + if (!matched?.length) return [] + const out: string[] = [] + for (const raw of matched) { + const item = (raw || '').trim() + if (!item) continue + if (item.startsWith('symbol:')) { + out.push(`白名单代码 ${item.slice('symbol:'.length)}`) + continue + } + out.push(item) + } + return out +} + +export function buildIndustryChainBasis(chain: IndustryChainInfo): string[] { + const lines: string[] = [] + if (chain.description) { + lines.push(`环节说明:${chain.description}`) + } + if (chain.layer !== 'other') { + lines.push(ROTATION_FRAMEWORK) + } + if (A_SHARE_SKIP_LAYERS.has(normalizeChainLayer(chain.layer))) { + lines.push('投资提示:A股此环节建议跳过(美股例外)') + } + lines.push(`归类方式:${formatMatchSource(chain.match_source)}`) + const items = formatMatchedItems(chain.matched) + if (items.length) { + lines.push(`命中依据:${items.join('、')}`) + } + return lines +} + +interface IndustryChainBadgeProps { + chain: IndustryChainInfo + compact?: boolean + className?: string + onClick?: () => void +} + +export function IndustryChainBadge({ + chain, + compact = false, + className, + onClick, +}: IndustryChainBadgeProps) { + const displayLabel = formatIndustryChainDisplay(chain) + if (!displayLabel) return null + + const layerKey = normalizeChainLayer(chain.layer) + const layerStyle = CHAIN_LAYER_BADGE_STYLES[layerKey] || 'bg-accent/50 text-muted-foreground' + + return ( + + ) +} diff --git a/frontend/packages/biz-ui/src/components/kline-summary-dialog.tsx b/frontend/packages/biz-ui/src/components/kline-summary-dialog.tsx index 165d529..ceb2c49 100644 --- a/frontend/packages/biz-ui/src/components/kline-summary-dialog.tsx +++ b/frontend/packages/biz-ui/src/components/kline-summary-dialog.tsx @@ -1,10 +1,13 @@ import { useCallback, useEffect, useState } from 'react' -import { Sparkles } from 'lucide-react' +import { LineChart, Sparkles } from 'lucide-react' import { fetchAPI } from '@panwatch/api' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@panwatch/base-ui/components/ui/dialog' import { Button } from '@panwatch/base-ui/components/ui/button' import { buildKlineSuggestion } from '@/lib/kline-scorer' import { HoverPopover } from '@panwatch/base-ui/components/ui/hover-popover' +import { resolveSuggestionActionDescription } from '@panwatch/biz-ui/components/suggestion-action' +import KlineModal from '@panwatch/biz-ui/components/KlineModal' +import StockExternalLink from '@panwatch/biz-ui/components/stock-external-link' import { TechnicalBadge, technicalToneFromSuggestionAction } from '@panwatch/biz-ui/components/technical-badge' export interface KlineSummaryData { @@ -92,6 +95,7 @@ export function KlineSummaryDialog({ const [loading, setLoading] = useState(false) const [summary, setSummary] = useState(null) const [error, setError] = useState(null) + const [klineModalOpen, setKlineModalOpen] = useState(false) const buildSuggestion = (s: KlineSummaryData, holding?: boolean) => { const scored = buildKlineSuggestion(s, holding) @@ -152,6 +156,12 @@ export function KlineSummaryDialog({ const effectiveSummary = initialSummary || summary const suggestion = effectiveSummary ? buildSuggestion(effectiveSummary, hasPosition) : null + const handleOpenKline = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + onOpenChange(false) + setKlineModalOpen(true) + }, [onOpenChange]) + const handleAskAI = useCallback(() => { if (!effectiveSummary) return const s = effectiveSummary @@ -183,6 +193,7 @@ export function KlineSummaryDialog({ }, [effectiveSummary, suggestion, symbol, market, stockName, onOpenChange]) return ( + <> K线 / 技术指标
+
+ + +
{stockName ? `${stockName} (${symbol})` : symbol}
{(effectiveSummary?.timeframe || effectiveSummary?.computed_at || effectiveSummary?.asof) && (
@@ -215,10 +238,27 @@ export function KlineSummaryDialog({ {suggestion && (
- +
{resolveSuggestionActionDescription(suggestion.action, suggestion.action_label)}
+
    +
  • 买入/建仓:可以考虑进场
  • +
  • 观望:中性,等更清晰信号
  • +
  • 回避:风险较大,不建议参与
  • +
  • 减仓/卖出:已有持仓时的离场建议
  • +
+
+ } + trigger={ + + } /> {hasPosition ? '已持仓' : '未持仓'} · score {suggestion.score} @@ -254,7 +294,7 @@ export function KlineSummaryDialog({ )}
- 提示:悬停指标标签可查看详细说明 + 提示:悬停建议或指标标签可查看详细说明
@@ -751,5 +791,13 @@ export function KlineSummaryDialog({ )}
+ + ) } diff --git a/frontend/packages/biz-ui/src/components/lmd-report-section-modal.tsx b/frontend/packages/biz-ui/src/components/lmd-report-section-modal.tsx new file mode 100644 index 0000000..31f6ac0 --- /dev/null +++ b/frontend/packages/biz-ui/src/components/lmd-report-section-modal.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from 'react' +import { insightApi } from '@panwatch/api' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@panwatch/base-ui/components/ui/dialog' +import { ReportViewer } from './report-markdown' +import { findLmdReportSectionSlug, type LmdReportSection } from '../lib/report-toc' +import { isLmdReportAgent, LMD_DISPLAY_NAME, pickLatestLmdReport } from '../lib/lmd-report' + +const SECTION_LABELS: Record = { + valuation: '估值', + fundamentals: '基本面', +} + +interface LmdHistoryRecord { + id: number + agent_name: string + title?: string + content?: string + analysis_date?: string + updated_at?: string + created_at?: string +} + +export function LmdReportSectionModal({ + open, + onOpenChange, + symbol, + market, + stockName, + section, +}: { + open: boolean + onOpenChange: (open: boolean) => void + symbol: string + market: string + stockName?: string + section: LmdReportSection | null +}) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [content, setContent] = useState('') + const [reportTitle, setReportTitle] = useState('') + const [sectionSlug, setSectionSlug] = useState(null) + + const sectionLabel = section ? SECTION_LABELS[section] : '' + + useEffect(() => { + if (!open || !symbol || !section) return + let cancelled = false + setLoading(true) + setError('') + setContent('') + setReportTitle('') + setSectionSlug(null) + + ;(async () => { + try { + const records = await insightApi.history({ + stock_symbol: symbol, + kind: 'all', + limit: 50, + }) + const report = pickLatestLmdReport( + (records || []).filter(r => isLmdReportAgent(r.agent_name)), + ) + if (!report?.content?.trim()) { + if (!cancelled) setError(`暂无${LMD_DISPLAY_NAME}报告,请先在报告页生成`) + return + } + const slug = findLmdReportSectionSlug(report.content, section) + if (!cancelled) { + setContent(report.content) + setReportTitle(report.title || LMD_DISPLAY_NAME) + setSectionSlug(slug) + if (!slug) setError(`报告中未找到「${SECTION_LABELS[section]}」章节`) + } + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : `加载${LMD_DISPLAY_NAME}报告失败`) + } finally { + if (!cancelled) setLoading(false) + } + })() + + return () => { + cancelled = true + } + }, [open, symbol, market, section]) + + return ( + + + + + {stockName || symbol} + {symbol} + · + {LMD_DISPLAY_NAME} · {sectionLabel} + + {reportTitle ? ( + {reportTitle} + ) : ( + {LMD_DISPLAY_NAME}报告章节预览 + )} + + +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : ( + + )} +
+
+
+ ) +} diff --git a/frontend/packages/biz-ui/src/components/long-term-plan-panel.tsx b/frontend/packages/biz-ui/src/components/long-term-plan-panel.tsx new file mode 100644 index 0000000..705ecd6 --- /dev/null +++ b/frontend/packages/biz-ui/src/components/long-term-plan-panel.tsx @@ -0,0 +1,216 @@ +import { useEffect, useState } from 'react' +import { + stocksApi, + DEFAULT_INVESTMENT_PROFILE, + type InvestmentProfile, + type InvestmentProfileEvaluateResult, + type StockItem, +} from '@panwatch/api' +import { Button } from '@panwatch/base-ui/components/ui/button' +import { Input } from '@panwatch/base-ui/components/ui/input' +import { Label } from '@panwatch/base-ui/components/ui/label' +import { Switch } from '@panwatch/base-ui/components/ui/switch' +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@panwatch/base-ui/components/ui/select' +import { Badge } from '@panwatch/base-ui/components/ui/badge' +import { useToast } from '@panwatch/base-ui/components/ui/toast' + +interface LongTermPlanPanelProps { + stock: Pick + onSaved?: (profile: InvestmentProfile) => void +} + +const ROLE_LABEL: Record = { + core: '核心仓', + satellite: '卫星仓', + watch: '观察', +} + +export function LongTermPlanPanel({ stock, onSaved }: LongTermPlanPanelProps) { + const { toast } = useToast() + const [profile, setProfile] = useState({ + ...DEFAULT_INVESTMENT_PROFILE, + ...(stock.investment_profile || {}), + }) + const [evalResult, setEvalResult] = useState(null) + const [saving, setSaving] = useState(false) + const [loadingEval, setLoadingEval] = useState(false) + + useEffect(() => { + setProfile({ + ...DEFAULT_INVESTMENT_PROFILE, + ...(stock.investment_profile || {}), + }) + }, [stock.id, stock.investment_profile]) + + const updateLevel = (index: number, field: 'drawdown_pct' | 'budget_pct', value: string) => { + const levels = [...(profile.add_plan?.levels || [])] + const num = parseFloat(value) + if (!levels[index]) return + levels[index] = { ...levels[index], [field]: Number.isFinite(num) ? num : 0 } + setProfile({ ...profile, add_plan: { ...profile.add_plan, levels } }) + } + + const handleSave = async () => { + setSaving(true) + try { + const res = await stocksApi.updateInvestmentProfile(stock.id, profile) + const saved = res.investment_profile || profile + setProfile({ ...DEFAULT_INVESTMENT_PROFILE, ...saved }) + onSaved?.(saved) + toast('长线计划已保存', 'success') + } catch { + toast('保存失败', 'error') + } finally { + setSaving(false) + } + } + + const handleEvaluate = async () => { + setLoadingEval(true) + try { + const res = await stocksApi.evaluateInvestmentProfile(stock.id) + setEvalResult(res) + } catch { + toast('评估失败', 'error') + } finally { + setLoadingEval(false) + } + } + + return ( +
+
+
+
启用长线计划
+
+ 启用后 AI 将按核心/卫星仓与分批加仓纪律给建议 +
+
+ setProfile({ ...profile, long_term_enabled: checked })} + /> +
+ + {profile.long_term_enabled && ( + <> +
+
+ + +
+
+ {ROLE_LABEL[profile.portfolio_role] || '观察'} +
+
+ + setProfile({ + ...profile, + target_weight_pct: e.target.value === '' ? null : parseFloat(e.target.value), + })} + placeholder="如 15" + inputMode="decimal" + /> +
+
+ + setProfile({ + ...profile, + max_weight_pct: e.target.value === '' ? null : parseFloat(e.target.value), + })} + placeholder="如 25" + inputMode="decimal" + /> +
+
+ +
+ +