commit 5a5dd3c9fdd0195eb8d068186a6e5c611929fb04 Author: wanghep Date: Fri Apr 17 21:20:26 2026 +0800 chore: initialize lhbfx project and documentation diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1fe3fd7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.ps1 text eol=crlf +*.bat text eol=crlf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69a275e --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# OS / editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ + +# Local runtime data +logs/ +output/ +.playwright-cli/ +*.log + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ + +# Node / frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +frontend/.vite-temp/ + +# Sensitive or machine-local config +backend/config.yaml + +# Temp / debug files +_tmp_*.json +_curl_*.json +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..96f14c9 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# lhbfx + +`lhbfx` 是一个围绕 A 股龙虎榜数据构建的“顶级游资监控系统”,当前聚焦盘后分析、游资席位跟踪、关注池管理、预警提示与个股复盘。 + +## 项目结构 + +- `backend/` + - FastAPI 服务 + - 龙虎榜导入、席位映射、预警生成脚本 + - MySQL schema 与查询逻辑 +- `frontend/` + - Vue 3 + TypeScript + Vite 单页应用 + - 首页总控台、游资详情、个股详情、预警中心 +- `docs/` + - 需求文档 + - 技术文档 + - 历史说明与 UI 草稿 + +## 当前核心能力 + +- 首页支持“关注池与操作流水”联动展示。 +- 待加入关注列表支持按买卖结构着色、净额重点突出、快速加入关注。 +- 关注池写入数据库,支持新增和删除。 +- 个股详情支持 K 线、MA5、买卖点与预警信息展示。 +- 预警中心支持卖出预警、慢流出观察等风险信息查看。 + +## 环境要求 + +- Python 3.11+ +- Node.js 20+ +- MySQL 8+ + +## 后端启动 + +1. 复制配置文件: + - 将 `backend/config.example.yaml` 复制为 `backend/config.yaml` + - 按实际数据库连接信息修改 +2. 初始化数据库: + +```powershell +python backend/scripts/init_db.py +``` + +3. 启动 API: + +```powershell +python backend/scripts/run_api.py +``` + +默认地址: + +- `http://127.0.0.1:8000` + +## 前端启动 + +```powershell +cd frontend +npm install +npm run dev -- --host 127.0.0.1 --port 5173 +``` + +默认地址: + +- `http://127.0.0.1:5173` + +## 一键开发启动 + +根目录提供了 `start-dev.ps1`: + +```powershell +powershell -ExecutionPolicy Bypass -File .\start-dev.ps1 +``` + +脚本会同时拉起: + +- 后端:`127.0.0.1:8000` +- 前端:`127.0.0.1:5173` + +日志输出目录: + +- `logs/` + +## 常用后端接口 + +- `GET /api/summary` +- `GET /api/pipeline/status` +- `GET /api/traders` +- `GET /api/traders/{trader_id}` +- `GET /api/stocks/{stock_code}` +- `GET /api/actions` +- `GET /api/watchlist` +- `POST /api/watchlist` +- `DELETE /api/watchlist/{stock_code}` +- `GET /api/warnings` + +## 文档索引 + +- 需求文档:`docs/需求文档.md` +- 技术文档:`docs/技术文档.md` +- 历史需求梳理:`docs/需求梳理-今日游资操作优先.md` +- 历史 UI 说明:`docs/UI设计说明.md` + +## 仓库说明 + +- 仓库已补充 `.gitignore` 与 `.gitattributes` +- 本地敏感配置 `backend/config.yaml` 已忽略,不会推送 +- 提交前建议使用 `backend/config.example.yaml` 作为共享配置模板 diff --git a/backend/config.example.yaml b/backend/config.example.yaml new file mode 100644 index 0000000..d8731c4 --- /dev/null +++ b/backend/config.example.yaml @@ -0,0 +1,92 @@ +app: + name: "lhbfx" + timezone: "Asia/Shanghai" + environment: "local" + +database: + driver: "mysql" + host: "127.0.0.1" + port: 3306 + username: "your_username" + password: "your_password" + database: "lhbfx" + charset: "utf8mb4" + pool_size: 10 + connect_timeout_seconds: 10 + +data_sources: + lhb_priority: + - "tonghuashun" + quote_priority: + - "tonghuashun" + fundamentals_priority: + - "tonghuashun" + - "akshare" + +monitoring: + target_year: 2026 + warning_days_without_action: 4 + max_position_ratio: 0.10 + fib_levels: + - 0.382 + - 0.5 + - 0.618 + moving_average_days: + - 5 + turnover_warning_threshold: 0.30 + +traders: + - name: "章盟主" + alias: "章建平" + style_tags: + - "板块中军" + - "权重大票" + - "趋势强化" + - "大资金体量" + warning_weight: "high" + match_keywords: + - "上海长宁区江苏路" + - "上海浦东新区海阳西路" + - "宁波彩虹北路" + seats: + core: + - "国泰海通证券上海江苏路" + active: + - "国泰海通证券上海海阳西路" + - "国泰海通证券宁波彩虹北路" + history: [] + + - name: "炒股养家" + alias: "" + style_tags: + - "情绪周期" + - "题材确认" + - "高辨识度龙头" + warning_weight: "medium" + match_keywords: + - "上海宛平南路" + - "上海茅台路" + seats: + core: + - "华鑫证券上海宛平南路" + active: + - "华鑫证券上海茅台路" + history: [] + + - name: "欢乐海岸" + alias: "" + style_tags: + - "高位强势股" + - "妖股" + - "封板后锁仓" + - "高溢价品牌效应" + warning_weight: "high" + match_keywords: + - "中信证券深圳总部" + - "中信证券深圳后海" + seats: + core: + - "中信证券深圳总部" + - "中信证券深圳后海" + active: [] + history: [] diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..6f8f241 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "lhbfx" +version = "0.1.0" +description = "龙虎榜游资监控系统" +requires-python = ">=3.11" +dependencies = [ + "PyMySQL>=1.1.1", + "PyYAML>=6.0.2", + "requests>=2.32.3", + "beautifulsoup4>=4.12.3", + "fastapi>=0.116.1", + "uvicorn>=0.35.0", + "jinja2>=3.1.6", +] + +[tool.setuptools] +package-dir = {"" = "src"} diff --git a/backend/scripts/_bootstrap.py b/backend/scripts/_bootstrap.py new file mode 100644 index 0000000..0b8eaf4 --- /dev/null +++ b/backend/scripts/_bootstrap.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +def add_src_to_path() -> None: + backend_dir = Path(__file__).resolve().parents[1] + src_dir = backend_dir / "src" + if str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) diff --git a/backend/scripts/generate_warnings.py b/backend/scripts/generate_warnings.py new file mode 100644 index 0000000..75944e3 --- /dev/null +++ b/backend/scripts/generate_warnings.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from _bootstrap import add_src_to_path + +add_src_to_path() + +from lhbfx.pipeline import generate_warnings + + +def main() -> None: + result = generate_warnings() + print( + "预警生成完成:" + f"sell_alert={result['sell_alert']}, " + f"slow_exit_watch={result['slow_exit_watch']}, " + f"total={result['total']}" + ) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/import_ths_daily.py b/backend/scripts/import_ths_daily.py new file mode 100644 index 0000000..7a29ecf --- /dev/null +++ b/backend/scripts/import_ths_daily.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import argparse +from datetime import datetime + +from _bootstrap import add_src_to_path + +add_src_to_path() + +from lhbfx.pipeline import import_daily + + +def main() -> None: + parser = argparse.ArgumentParser(description="导入同花顺某日全量龙虎榜数据") + parser.add_argument("--date", default=datetime.now().strftime("%Y-%m-%d"), help="交易日期,格式 YYYY-MM-DD") + args = parser.parse_args() + + result = import_daily(args.date) + print(f"导入日期: {args.date}") + print(f"概览记录数: {result.overview_count}") + print(f"营业部明细数: {result.detail_count}") + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/import_ths_year.py b/backend/scripts/import_ths_year.py new file mode 100644 index 0000000..5f6988b --- /dev/null +++ b/backend/scripts/import_ths_year.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import argparse +from datetime import datetime +from pathlib import Path + +from _bootstrap import add_src_to_path + +add_src_to_path() + +from lhbfx.pipeline import import_range + + +def log_line(log_file: Path, message: str) -> None: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{timestamp}] {message}" + print(line, flush=True) + log_file.parent.mkdir(parents=True, exist_ok=True) + with log_file.open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +def main() -> None: + parser = argparse.ArgumentParser(description="按工作日批量导入某年同花顺龙虎榜数据") + parser.add_argument("--year", type=int, default=2026, help="年份,默认 2026") + parser.add_argument("--start", default=None, help="起始日期 YYYY-MM-DD") + parser.add_argument("--end", default=None, help="结束日期 YYYY-MM-DD") + parser.add_argument("--limit", type=int, default=None, help="限制导入的交易日数量") + parser.add_argument("--log-file", default=None, help="日志文件路径") + args = parser.parse_args() + + default_log = Path(__file__).resolve().parents[1] / "output" / "logs" / f"import_{args.year}.log" + log_file = Path(args.log_file) if args.log_file else default_log + + log_line( + log_file, + f"开始导入 year={args.year}, start={args.start}, end={args.end}, limit={args.limit}", + ) + result = import_range(year=args.year, start=args.start, end=args.end, limit=args.limit) + + for item in result["success"]: + log_line( + log_file, + f"{item['trade_date']} -> 成功, 概览 {item['overview_count']} 条, 明细 {item['detail_count']} 条", + ) + for item in result["failed"]: + log_line(log_file, f"{item['trade_date']} -> 失败, error={item['error']}") + + log_line( + log_file, + f"导入完成 success={result['success_count']}, failed={result['failed_count']}, total={result['requested_days']}", + ) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/init_db.py b/backend/scripts/init_db.py new file mode 100644 index 0000000..67ba177 --- /dev/null +++ b/backend/scripts/init_db.py @@ -0,0 +1,75 @@ +from pathlib import Path + +from _bootstrap import add_src_to_path + +add_src_to_path() + +from lhbfx.config import AppConfig, load_config +from lhbfx.db import db_cursor, execute_schema +from lhbfx.seed import seed_traders + + +def _column_exists(cursor, schema_name: str, table_name: str, column_name: str) -> bool: + cursor.execute( + """ + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s + LIMIT 1 + """, + (schema_name, table_name, column_name), + ) + return cursor.fetchone() is not None + + +def _index_exists(cursor, schema_name: str, table_name: str, index_name: str) -> bool: + cursor.execute( + """ + SELECT 1 + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s + LIMIT 1 + """, + (schema_name, table_name, index_name), + ) + return cursor.fetchone() is not None + + +def apply_incremental_alters(config: AppConfig) -> None: + schema_name = config.database.database + with db_cursor(config=config) as (_, cursor): + if not _column_exists(cursor, schema_name, "lhb_overview", "rid"): + cursor.execute("ALTER TABLE lhb_overview ADD COLUMN rid VARCHAR(32) NULL AFTER flag") + if not _column_exists(cursor, schema_name, "lhb_overview", "detail_url"): + cursor.execute("ALTER TABLE lhb_overview ADD COLUMN detail_url VARCHAR(255) NULL AFTER net_buy") + if not _index_exists(cursor, schema_name, "lhb_overview", "uniq_lhb_overview_record"): + cursor.execute("ALTER TABLE lhb_overview ADD UNIQUE KEY uniq_lhb_overview_record (trade_date, stock_code, rid)") + + if not _column_exists(cursor, schema_name, "lhb_detail_seats", "rid"): + cursor.execute("ALTER TABLE lhb_detail_seats ADD COLUMN rid VARCHAR(32) NULL AFTER stock_name") + if not _column_exists(cursor, schema_name, "lhb_detail_seats", "section_title"): + cursor.execute("ALTER TABLE lhb_detail_seats ADD COLUMN section_title VARCHAR(255) NULL AFTER rid") + if not _column_exists(cursor, schema_name, "lhb_detail_seats", "buy_ratio"): + cursor.execute("ALTER TABLE lhb_detail_seats ADD COLUMN buy_ratio VARCHAR(32) NULL AFTER buy_amount_wan") + if not _column_exists(cursor, schema_name, "lhb_detail_seats", "sell_ratio"): + cursor.execute("ALTER TABLE lhb_detail_seats ADD COLUMN sell_ratio VARCHAR(32) NULL AFTER sell_amount_wan") + if not _column_exists(cursor, schema_name, "lhb_detail_seats", "detail_url"): + cursor.execute("ALTER TABLE lhb_detail_seats ADD COLUMN detail_url VARCHAR(255) NULL AFTER matched_seat") + if not _index_exists(cursor, schema_name, "lhb_detail_seats", "uniq_lhb_detail_record"): + cursor.execute( + "ALTER TABLE lhb_detail_seats ADD UNIQUE KEY uniq_lhb_detail_record (trade_date, stock_code, rid, table_title, seat_name)" + ) + + +def main() -> None: + root = Path(__file__).resolve().parents[1] + schema_path = root / "src" / "lhbfx" / "schema.sql" + config = load_config() + execute_schema(schema_path=schema_path, config=config) + apply_incremental_alters(config) + seed_traders(config=config) + print("数据库建表完成,游资配置已写入数据库。") + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/rematch_traders.py b/backend/scripts/rematch_traders.py new file mode 100644 index 0000000..6941f3c --- /dev/null +++ b/backend/scripts/rematch_traders.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from _bootstrap import add_src_to_path + +add_src_to_path() + +from lhbfx.pipeline import rematch_traders + + +def main() -> None: + result = rematch_traders() + print(f"重新匹配完成,处理记录数: {result['updated']}") + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/run_api.py b/backend/scripts/run_api.py new file mode 100644 index 0000000..601a223 --- /dev/null +++ b/backend/scripts/run_api.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import uvicorn + +from _bootstrap import add_src_to_path + + +def main() -> None: + add_src_to_path() + uvicorn.run("lhbfx.app:app", host="127.0.0.1", port=8000, reload=False) + + +if __name__ == "__main__": + main() diff --git a/backend/src/lhbfx/__init__.py b/backend/src/lhbfx/__init__.py new file mode 100644 index 0000000..33e148c --- /dev/null +++ b/backend/src/lhbfx/__init__.py @@ -0,0 +1,2 @@ +"""lhbfx package.""" + diff --git a/backend/src/lhbfx/app.py b/backend/src/lhbfx/app.py new file mode 100644 index 0000000..094912d --- /dev/null +++ b/backend/src/lhbfx/app.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from .pipeline import ( + generate_warnings, + get_pipeline_status, + import_daily, + import_range, + refresh_trade_date, + rematch_traders, +) +from .queries import ( + fetch_stock_detail, + fetch_summary, + fetch_trader_actions, + fetch_trader_detail, + fetch_traders, + fetch_watchlist, + fetch_warnings, + delete_watchlist_item, + upsert_watchlist_item, +) + + +BASE_DIR = Path(__file__).resolve().parents[2] +PROJECT_DIR = BASE_DIR.parent +FRONTEND_DIST_DIR = PROJECT_DIR / "frontend" / "dist" +FRONTEND_ASSETS_DIR = FRONTEND_DIST_DIR / "assets" + + +app = FastAPI(title="lhbfx API", version="0.1.0") + + +class WatchlistPayload(BaseModel): + stock_code: str + stock_name: str + source_trade_date: str | None = None + source_trader_name: str | None = None + +if FRONTEND_ASSETS_DIR.exists(): + app.mount("/assets", StaticFiles(directory=str(FRONTEND_ASSETS_DIR)), name="assets") + + +@app.get("/api/summary") +def api_summary(): + return fetch_summary() + + +@app.get("/api/warnings") +def api_warnings(limit: int = 50): + return fetch_warnings(limit=limit) + + +@app.get("/api/traders") +def api_traders(): + return fetch_traders() + + +@app.get("/api/watchlist") +def api_watchlist(include_archived: bool = False): + return fetch_watchlist(include_archived=include_archived) + + +@app.post("/api/watchlist") +def api_watchlist_upsert(payload: WatchlistPayload): + return upsert_watchlist_item( + stock_code=payload.stock_code, + stock_name=payload.stock_name, + source_trade_date=payload.source_trade_date, + source_trader_name=payload.source_trader_name, + ) + + +@app.delete("/api/watchlist/{stock_code}") +def api_watchlist_delete(stock_code: str): + deleted = delete_watchlist_item(stock_code) + if not deleted: + raise HTTPException(status_code=404, detail="watchlist item not found") + return {"ok": True, "stock_code": stock_code} + + +@app.get("/api/actions") +def api_trader_actions( + trade_date: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + trader_name: str | None = None, + limit: int = 200, +): + return fetch_trader_actions( + trade_date=trade_date, + date_from=date_from, + date_to=date_to, + trader_name=trader_name, + limit=limit, + ) + + +@app.get("/api/traders/{trader_id}") +def api_trader_detail(trader_id: int): + data = fetch_trader_detail(trader_id) + if not data: + raise HTTPException(status_code=404, detail="trader not found") + return data + + +@app.get("/api/stocks/{stock_code}") +def api_stock_detail(stock_code: str): + data = fetch_stock_detail(stock_code) + if not data: + raise HTTPException(status_code=404, detail="stock not found") + return data + + +@app.get("/api/pipeline/status") +def api_pipeline_status(): + return get_pipeline_status() + + +@app.post("/api/pipeline/import/daily") +def api_import_daily(trade_date: str): + result = import_daily(trade_date) + return { + "trade_date": result.trade_date, + "overview_count": result.overview_count, + "detail_count": result.detail_count, + } + + +@app.post("/api/pipeline/import/range") +def api_import_range( + year: int | None = None, + start: str | None = None, + end: str | None = None, + limit: int | None = None, +): + return import_range(year=year, start=start, end=end, limit=limit) + + +@app.post("/api/pipeline/rematch-traders") +def api_rematch_traders(): + return rematch_traders() + + +@app.post("/api/pipeline/generate-warnings") +def api_generate_warnings(clear_existing: bool = True): + return generate_warnings(clear_existing=clear_existing) + + +@app.post("/api/pipeline/refresh") +def api_refresh_trade_date(trade_date: str, regenerate_warnings: bool = True): + return refresh_trade_date(trade_date, regenerate_warnings=regenerate_warnings) + + +@app.get("/") +def index(): + html_path = FRONTEND_DIST_DIR / "index.html" + if not html_path.exists(): + return { + "name": "lhbfx API", + "status": "frontend_pending", + "message": "Frontend dist has not been built yet. Use the Vue dev server during development or /api/* endpoints.", + } + return FileResponse(str(html_path)) diff --git a/backend/src/lhbfx/config.py b/backend/src/lhbfx/config.py new file mode 100644 index 0000000..51e35b1 --- /dev/null +++ b/backend/src/lhbfx/config.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + + +DEFAULT_CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.yaml" + + +@dataclass(slots=True) +class DatabaseConfig: + driver: str + host: str + port: int + username: str + password: str + database: str + charset: str = "utf8mb4" + pool_size: int = 10 + connect_timeout_seconds: int = 10 + + +class AppConfig: + def __init__(self, raw: dict[str, Any], path: Path) -> None: + self.raw = raw + self.path = path + + @property + def database(self) -> DatabaseConfig: + db = self.raw["database"] + return DatabaseConfig( + driver=db["driver"], + host=db["host"], + port=int(db["port"]), + username=db["username"], + password=db["password"], + database=db["database"], + charset=db.get("charset", "utf8mb4"), + pool_size=int(db.get("pool_size", 10)), + connect_timeout_seconds=int(db.get("connect_timeout_seconds", 10)), + ) + + @property + def traders(self) -> list[dict[str, Any]]: + return self.raw.get("traders", []) + + @property + def monitoring(self) -> dict[str, Any]: + return self.raw.get("monitoring", {}) + + @property + def data_sources(self) -> dict[str, Any]: + return self.raw.get("data_sources", {}) + + +def load_config(path: str | Path | None = None) -> AppConfig: + config_path = Path(path) if path else DEFAULT_CONFIG_PATH + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + return AppConfig(raw=raw, path=config_path) + diff --git a/backend/src/lhbfx/db.py b/backend/src/lhbfx/db.py new file mode 100644 index 0000000..015346a --- /dev/null +++ b/backend/src/lhbfx/db.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +import pymysql +from pymysql.connections import Connection +from pymysql.cursors import DictCursor + +from .config import AppConfig, load_config + + +def get_connection(config: AppConfig | None = None, autocommit: bool = False) -> Connection: + cfg = config or load_config() + db = cfg.database + timeout = max(db.connect_timeout_seconds, 120) + return pymysql.connect( + host=db.host, + port=db.port, + user=db.username, + password=db.password, + database=db.database, + charset=db.charset, + cursorclass=DictCursor, + autocommit=autocommit, + connect_timeout=db.connect_timeout_seconds, + read_timeout=timeout, + write_timeout=timeout, + ) + + +@contextmanager +def db_cursor(config: AppConfig | None = None, autocommit: bool = False) -> Iterator: + conn = get_connection(config=config, autocommit=autocommit) + try: + with conn.cursor() as cursor: + yield conn, cursor + if not autocommit: + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def execute_schema(schema_path: str | Path, config: AppConfig | None = None) -> None: + sql = Path(schema_path).read_text(encoding="utf-8") + statements = [stmt.strip() for stmt in sql.split(";") if stmt.strip()] + with db_cursor(config=config, autocommit=False) as (_, cursor): + for statement in statements: + cursor.execute(statement) diff --git a/backend/src/lhbfx/pipeline.py b/backend/src/lhbfx/pipeline.py new file mode 100644 index 0000000..4f977d1 --- /dev/null +++ b/backend/src/lhbfx/pipeline.py @@ -0,0 +1,511 @@ +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Any + +from .config import AppConfig, load_config +from .db import db_cursor +from .sources.tonghuashun import TongHuaShunClient + + +BUY_TABLE_TITLE = "买入金额最大的前5名营业部" +SELL_TABLE_TITLE = "卖出金额最大的前5名营业部" + + +@dataclass(slots=True) +class ImportResult: + trade_date: str + overview_count: int + detail_count: int + + +def build_seat_matchers(config: AppConfig) -> list[tuple[str, str, str]]: + matchers: list[tuple[str, str, str]] = [] + for trader in config.traders: + trader_name = trader["name"] + for _, seats in trader.get("seats", {}).items(): + for seat in seats or []: + matchers.append((trader_name, seat, "seat")) + for keyword in trader.get("match_keywords", []) or []: + matchers.append((trader_name, keyword, "keyword")) + return matchers + + +def normalize_seat_name(seat_name: str) -> str: + text = seat_name.replace("股份有限公司", "") + text = text.replace("有限责任公司", "") + text = text.replace("有限公司", "") + text = text.replace("证券营业部", "") + text = text.replace("证券", "") + text = text.replace("分公司", "") + text = text.replace("股份", "") + text = text.replace("责任公司", "") + return text.strip() + + +def match_trader(seat_name: str, matchers: list[tuple[str, str, str]]) -> tuple[str | None, str | None]: + normalized = normalize_seat_name(seat_name) + for trader_name, seat, _match_type in matchers: + if not seat: + continue + if seat in seat_name or seat in normalized: + return trader_name, seat + return None, None + + +def upsert_stock(cursor, row: dict[str, Any]) -> None: + cursor.execute( + """ + INSERT INTO stocks (stock_code, stock_name) + VALUES (%s, %s) + ON DUPLICATE KEY UPDATE + stock_name = VALUES(stock_name) + """, + (row["stock_code"], row["stock_name"]), + ) + + +def upsert_overview(cursor, row: dict[str, Any]) -> None: + cursor.execute( + """ + INSERT INTO lhb_overview ( + trade_date, stock_code, stock_name, flag, rid, price, pct_chg, + amount, net_buy, detail_url, source, raw_payload + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'tonghuashun', %s) + ON DUPLICATE KEY UPDATE + stock_name = VALUES(stock_name), + flag = VALUES(flag), + price = VALUES(price), + pct_chg = VALUES(pct_chg), + amount = VALUES(amount), + net_buy = VALUES(net_buy), + detail_url = VALUES(detail_url), + raw_payload = VALUES(raw_payload) + """, + ( + row["trade_date"], + row["stock_code"], + row["stock_name"], + row["flag"], + row["rid"], + row["price"], + row["pct_chg"], + row["amount"], + row["net_buy"], + row["detail_url"], + json.dumps(row, ensure_ascii=False), + ), + ) + + +def resolve_table_title(section_title: str) -> str: + if "买入金额最大的前5名" in section_title: + return BUY_TABLE_TITLE + return SELL_TABLE_TITLE + + +def upsert_detail_rows(cursor, detail: dict[str, Any], matchers: list[tuple[str, str, str]]) -> int: + count = 0 + for section in detail["sections"]: + section_title = section.get("section_title", "") + table_title = resolve_table_title(section_title) + for row in section.get("rows", []): + trader_name, matched_seat = match_trader(row["seat_name"], matchers) + cursor.execute( + """ + INSERT INTO lhb_detail_seats ( + trade_date, stock_code, stock_name, rid, section_title, table_title, seat_name, + buy_amount_wan, buy_ratio, sell_amount_wan, sell_ratio, net_amount_wan, + matched_trader_name, matched_seat, detail_url, source, raw_payload + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'tonghuashun', %s) + ON DUPLICATE KEY UPDATE + buy_amount_wan = VALUES(buy_amount_wan), + buy_ratio = VALUES(buy_ratio), + sell_amount_wan = VALUES(sell_amount_wan), + sell_ratio = VALUES(sell_ratio), + net_amount_wan = VALUES(net_amount_wan), + matched_trader_name = VALUES(matched_trader_name), + matched_seat = VALUES(matched_seat), + detail_url = VALUES(detail_url), + raw_payload = VALUES(raw_payload) + """, + ( + detail["trade_date"], + detail["stock_code"], + detail["title"].replace("龙虎榜数据", "").strip(), + detail["rid"], + section_title, + table_title, + row["seat_name"], + row["buy_amount_wan"], + row["buy_ratio"], + row["sell_amount_wan"], + row["sell_ratio"], + row["net_amount_wan"], + trader_name, + matched_seat, + detail["detail_url"], + json.dumps({"detail": detail, "row": row}, ensure_ascii=False), + ), + ) + count += 1 + return count + + +def import_daily( + trade_date: str, + *, + config: AppConfig | None = None, + max_retries: int = 3, + retry_delay_seconds: float = 2.0, +) -> ImportResult: + cfg = config or load_config() + client = TongHuaShunClient(config=cfg) + matchers = build_seat_matchers(cfg) + last_error: Exception | None = None + + for attempt in range(1, max_retries + 1): + try: + overview_rows = client.fetch_daily_overview(trade_date) + overview_count = 0 + detail_count = 0 + + with db_cursor(config=cfg) as (_, cursor): + for row in overview_rows: + upsert_stock(cursor, row) + upsert_overview(cursor, row) + overview_count += 1 + + rid = row.get("rid") or "" + if not rid: + continue + + detail = client.fetch_record_detail( + stock_code=row["stock_code"], + trade_date=trade_date, + rid=rid, + ) + detail_count += upsert_detail_rows(cursor, detail, matchers) + + return ImportResult( + trade_date=trade_date, + overview_count=overview_count, + detail_count=detail_count, + ) + except Exception as exc: + last_error = exc + if attempt >= max_retries: + break + time.sleep(retry_delay_seconds) + + if last_error is not None: + raise last_error + return ImportResult(trade_date=trade_date, overview_count=0, detail_count=0) + + +def iter_weekdays(year: int): + current = date(year, 1, 1) + end = date(year, 12, 31) + while current <= end: + if current.weekday() < 5: + yield current.isoformat() + current += timedelta(days=1) + + +def build_schedule(year: int, start: str | None = None, end: str | None = None) -> list[str]: + dates: list[str] = [] + for trade_date in iter_weekdays(year): + if start and trade_date < start: + continue + if end and trade_date > end: + continue + dates.append(trade_date) + return dates + + +def import_range( + *, + year: int | None = None, + start: str | None = None, + end: str | None = None, + limit: int | None = None, + config: AppConfig | None = None, +) -> dict[str, Any]: + schedule = build_schedule(year or date.today().year, start=start, end=end) + if limit is not None: + schedule = schedule[:limit] + + success: list[dict[str, Any]] = [] + failed: list[dict[str, Any]] = [] + + for trade_date in schedule: + try: + result = import_daily(trade_date, config=config) + success.append( + { + "trade_date": result.trade_date, + "overview_count": result.overview_count, + "detail_count": result.detail_count, + } + ) + except Exception as exc: + failed.append({"trade_date": trade_date, "error": repr(exc)}) + + return { + "requested_days": len(schedule), + "success_count": len(success), + "failed_count": len(failed), + "success": success, + "failed": failed, + } + + +def rematch_traders(*, config: AppConfig | None = None) -> dict[str, int]: + cfg = config or load_config() + matchers = build_seat_matchers(cfg) + updated = 0 + + with db_cursor(config=cfg) as (_, cursor): + cursor.execute("SELECT id, seat_name FROM lhb_detail_seats") + rows = cursor.fetchall() + matched_rows = [] + for row in rows: + trader_name, matched_seat = match_trader(row["seat_name"], matchers) + if trader_name: + matched_rows.append((trader_name, matched_seat, row["id"])) + + if matched_rows: + cursor.executemany( + """ + UPDATE lhb_detail_seats + SET matched_trader_name = %s, + matched_seat = %s + WHERE id = %s + """, + matched_rows, + ) + updated = len(matched_rows) + + return {"updated": updated} + + +def insert_warning(cursor, payload: dict[str, Any]) -> None: + cursor.execute( + """ + INSERT INTO warning_events ( + trade_date, stock_code, stock_name, trader_name, warning_type, + warning_level, trigger_reason, current_price, pct_chg, status, suggestion + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'new', %s) + """, + ( + payload["trade_date"], + payload["stock_code"], + payload["stock_name"], + payload["trader_name"], + payload["warning_type"], + payload["warning_level"], + payload["trigger_reason"], + payload.get("current_price"), + payload.get("pct_chg"), + payload["suggestion"], + ), + ) + + +def generate_sell_warnings(cursor) -> int: + cursor.execute( + """ + SELECT DISTINCT + d.trade_date, + d.stock_code, + COALESCE(o.stock_name, d.stock_name) AS stock_name, + d.matched_trader_name, + o.price, + o.pct_chg + FROM lhb_detail_seats d + LEFT JOIN lhb_overview o + ON d.trade_date = o.trade_date AND d.stock_code = o.stock_code + WHERE d.matched_trader_name IS NOT NULL + AND d.table_title = %s + AND CAST(COALESCE(NULLIF(d.sell_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 + """, + (SELL_TABLE_TITLE,), + ) + rows = cursor.fetchall() + count = 0 + for row in rows: + insert_warning( + cursor, + { + "trade_date": row["trade_date"], + "stock_code": row["stock_code"], + "stock_name": row.get("stock_name"), + "trader_name": row["matched_trader_name"], + "warning_type": "sell_alert", + "warning_level": "high", + "trigger_reason": "目标游资出现卖出记录,触发卖出即时预警规则。", + "current_price": row["price"], + "pct_chg": row["pct_chg"], + "suggestion": "优先审查该股是否进入撤退阶段。", + }, + ) + count += 1 + return count + + +def generate_slow_exit_warnings(cursor, warning_days_without_action: int) -> int: + cursor.execute("SELECT DISTINCT trade_date FROM lhb_overview WHERE trade_date IS NOT NULL ORDER BY trade_date") + trading_days = [row["trade_date"] for row in cursor.fetchall()] + trade_index = {trade_day: idx for idx, trade_day in enumerate(trading_days)} + if not trading_days: + return 0 + latest_trade_day = trading_days[-1] + + cursor.execute( + """ + SELECT + matched_trader_name, + stock_code, + COALESCE(MAX(stock_name), stock_code) AS stock_name, + MIN(trade_date) AS first_trade_date, + MAX(trade_date) AS last_trade_date + FROM lhb_detail_seats + WHERE matched_trader_name IS NOT NULL + GROUP BY matched_trader_name, stock_code + """ + ) + rows = cursor.fetchall() + count = 0 + + for row in rows: + first_trade_date = row["first_trade_date"] + last_trade_date = row["last_trade_date"] + if first_trade_date is None or last_trade_date is None: + continue + if last_trade_date not in trade_index or latest_trade_day not in trade_index: + continue + + distance = trade_index[latest_trade_day] - trade_index[last_trade_date] + if distance < warning_days_without_action: + continue + + cursor.execute( + """ + SELECT price, pct_chg, trade_date + FROM lhb_overview + WHERE stock_code = %s + ORDER BY trade_date DESC + LIMIT 1 + """, + (row["stock_code"],), + ) + latest = cursor.fetchone() + current_price = latest["price"] if latest else None + pct_chg = latest["pct_chg"] if latest else None + + insert_warning( + cursor, + { + "trade_date": latest_trade_day, + "stock_code": row["stock_code"], + "stock_name": row["stock_name"], + "trader_name": row["matched_trader_name"], + "warning_type": "slow_exit_watch", + "warning_level": "medium", + "trigger_reason": f"上榜后连续 {warning_days_without_action} 个交易日未出现新动作,触发慢流出观察。", + "current_price": current_price, + "pct_chg": pct_chg, + "suggestion": "继续观察是否转为明确卖出或破位。", + }, + ) + count += 1 + + return count + + +def generate_warnings(*, config: AppConfig | None = None, clear_existing: bool = True) -> dict[str, int]: + cfg = config or load_config() + warning_days = int(cfg.monitoring.get("warning_days_without_action", 4)) + with db_cursor(config=cfg) as (_, cursor): + if clear_existing: + cursor.execute("DELETE FROM warning_events") + sell_count = generate_sell_warnings(cursor) + slow_exit_count = generate_slow_exit_warnings(cursor, warning_days) + + return { + "sell_alert": sell_count, + "slow_exit_watch": slow_exit_count, + "total": sell_count + slow_exit_count, + } + + +def refresh_trade_date( + trade_date: str, + *, + config: AppConfig | None = None, + regenerate_warnings: bool = True, +) -> dict[str, Any]: + import_result = import_daily(trade_date, config=config) + rematch_result = rematch_traders(config=config) + warning_result = generate_warnings(config=config) if regenerate_warnings else None + return { + "trade_date": trade_date, + "import_result": { + "overview_count": import_result.overview_count, + "detail_count": import_result.detail_count, + }, + "rematch_result": rematch_result, + "warning_result": warning_result, + } + + +def get_pipeline_status(*, config: AppConfig | None = None) -> dict[str, Any]: + cfg = config or load_config() + with db_cursor(config=cfg) as (_, cursor): + cursor.execute("SELECT COUNT(*) AS c FROM lhb_overview") + overview_total = cursor.fetchone()["c"] + + cursor.execute("SELECT COUNT(*) AS c FROM lhb_detail_seats") + detail_total = cursor.fetchone()["c"] + + cursor.execute("SELECT COUNT(*) AS c FROM warning_events") + warning_total = cursor.fetchone()["c"] + + cursor.execute("SELECT COUNT(*) AS c FROM traders") + trader_total = cursor.fetchone()["c"] + + cursor.execute("SELECT MAX(trade_date) AS latest_trade_date FROM lhb_overview") + latest_trade_date = cursor.fetchone()["latest_trade_date"] + + cursor.execute( + """ + SELECT trade_date, COUNT(*) AS c + FROM lhb_overview + WHERE trade_date IS NOT NULL + GROUP BY trade_date + ORDER BY trade_date DESC + LIMIT 10 + """ + ) + recent_trade_days = cursor.fetchall() + + return { + "overview_total": overview_total, + "detail_total": detail_total, + "warning_total": warning_total, + "trader_total": trader_total, + "latest_trade_date": latest_trade_date.isoformat() if latest_trade_date else None, + "recent_trade_days": [ + { + "trade_date": row["trade_date"].isoformat() if row["trade_date"] else None, + "overview_count": row["c"], + } + for row in recent_trade_days + ], + } diff --git a/backend/src/lhbfx/queries.py b/backend/src/lhbfx/queries.py new file mode 100644 index 0000000..a793fe2 --- /dev/null +++ b/backend/src/lhbfx/queries.py @@ -0,0 +1,534 @@ +from __future__ import annotations + +import json +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from .db import db_cursor +from .sources.eastmoney import EastMoneyClient +from .sources.sina import SinaClient + + +def _normalize_value(value: Any) -> Any: + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, Decimal): + return float(value) + return value + + +def _normalize_row(row: dict[str, Any]) -> dict[str, Any]: + return {key: _normalize_value(value) for key, value in row.items()} + + +def _parse_json_list(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if isinstance(value, str) and value: + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, list) else [] + except json.JSONDecodeError: + return [] + return [] + + +def _infer_market_label(stock_code: str) -> str: + if stock_code.startswith(("6", "9", "5", "688")): + return "沪A" + return "深A" + + +def _infer_board_label(stock_code: str) -> str: + if stock_code.startswith(("688", "689")): + return "绉戝垱鏉?" + if stock_code.startswith(("300", "301")): + return "鍒涗笟鏉?" + if stock_code.startswith(("8", "4", "920")): + return "鍖椾氦鎵€" + if stock_code.startswith(("60", "601", "603", "605", "900")): + return "娌富鏉?" + if stock_code.startswith(("000", "001", "002", "003", "200")): + return "娣变富鏉?" + return "A鑲?" + + +def fetch_summary() -> dict[str, Any]: + with db_cursor() as (_, cursor): + cursor.execute("SELECT COUNT(*) AS c FROM warning_events") + warning_total = cursor.fetchone()["c"] + + cursor.execute( + """ + SELECT warning_level, COUNT(*) AS c + FROM warning_events + GROUP BY warning_level + """ + ) + warning_by_level = {row["warning_level"]: row["c"] for row in cursor.fetchall()} + + cursor.execute("SELECT COUNT(*) AS c FROM traders") + trader_total = cursor.fetchone()["c"] + + cursor.execute("SELECT COUNT(DISTINCT stock_code) AS c FROM lhb_overview") + stock_total = cursor.fetchone()["c"] + + cursor.execute("SELECT COUNT(DISTINCT trade_date) AS c FROM lhb_overview WHERE trade_date IS NOT NULL") + imported_days = cursor.fetchone()["c"] + + return { + "warning_total": warning_total, + "warning_by_level": warning_by_level, + "trader_total": trader_total, + "stock_total": stock_total, + "imported_days": imported_days, + } + + +def fetch_warnings(limit: int = 50) -> list[dict[str, Any]]: + with db_cursor() as (_, cursor): + cursor.execute( + """ + SELECT + trade_date, + stock_code, + stock_name, + trader_name, + warning_type, + warning_level, + trigger_reason, + current_price, + pct_chg, + suggestion, + created_at + FROM warning_events + ORDER BY trade_date DESC, created_at DESC + LIMIT %s + """, + (limit,), + ) + return [_normalize_row(row) for row in cursor.fetchall()] + + +def fetch_traders() -> list[dict[str, Any]]: + with db_cursor() as (_, cursor): + cursor.execute( + """ + SELECT + t.id, + t.name, + t.alias_name, + t.warning_weight, + t.style_tags, + COUNT(DISTINCT d.stock_code) AS stock_count, + COUNT(DISTINCT CASE WHEN w.warning_type = 'sell_alert' THEN CONCAT(w.trade_date, ':', w.stock_code) END) AS sell_alert_count, + COUNT(DISTINCT CASE WHEN w.warning_type = 'slow_exit_watch' THEN CONCAT(w.trade_date, ':', w.stock_code) END) AS slow_exit_count + FROM traders t + LEFT JOIN lhb_detail_seats d + ON d.matched_trader_name = t.name + LEFT JOIN warning_events w + ON w.trader_name = t.name + GROUP BY t.id, t.name, t.alias_name, t.warning_weight, t.style_tags + ORDER BY stock_count DESC, t.name + """ + ) + rows = [_normalize_row(row) for row in cursor.fetchall()] + for row in rows: + row["style_tags"] = _parse_json_list(row.get("style_tags")) + return rows + + +def fetch_watchlist(include_archived: bool = False) -> list[dict[str, Any]]: + with db_cursor() as (_, cursor): + cursor.execute( + """ + SELECT + stock_code, + stock_name, + source_trade_date, + source_trader_name, + status, + added_at, + archived_at, + updated_at + FROM watchlist_entries + WHERE (%s = 1 OR status = 'active') + ORDER BY added_at DESC, updated_at DESC, stock_code + """, + (1 if include_archived else 0,), + ) + return [_normalize_row(row) for row in cursor.fetchall()] + + +def upsert_watchlist_item( + stock_code: str, + stock_name: str, + source_trade_date: str | None, + source_trader_name: str | None, +) -> dict[str, Any]: + with db_cursor() as (_, cursor): + cursor.execute( + """ + INSERT INTO watchlist_entries ( + stock_code, + stock_name, + source_trade_date, + source_trader_name, + status, + archived_at + ) + VALUES (%s, %s, %s, %s, 'active', NULL) + ON DUPLICATE KEY UPDATE + stock_name = VALUES(stock_name), + source_trade_date = VALUES(source_trade_date), + source_trader_name = VALUES(source_trader_name), + status = 'active', + archived_at = NULL, + added_at = CURRENT_TIMESTAMP + """, + (stock_code, stock_name, source_trade_date, source_trader_name), + ) + cursor.execute( + """ + SELECT + stock_code, + stock_name, + source_trade_date, + source_trader_name, + status, + added_at, + archived_at, + updated_at + FROM watchlist_entries + WHERE stock_code = %s + LIMIT 1 + """, + (stock_code,), + ) + row = cursor.fetchone() + return _normalize_row(row) if row else {} + + +def delete_watchlist_item(stock_code: str) -> bool: + with db_cursor() as (_, cursor): + cursor.execute( + """ + DELETE FROM watchlist_entries + WHERE stock_code = %s + """, + (stock_code,), + ) + return cursor.rowcount > 0 + + +def fetch_trader_actions( + trade_date: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + trader_name: str | None = None, + limit: int = 200, +) -> dict[str, Any]: + with db_cursor() as (_, cursor): + selected_trade_date = trade_date + resolved_date_from = date_from + resolved_date_to = date_to + + if not selected_trade_date and not (resolved_date_from and resolved_date_to): + cursor.execute( + """ + SELECT MAX(trade_date) AS latest_trade_date + FROM lhb_detail_seats + WHERE matched_trader_name IS NOT NULL + """ + ) + latest = cursor.fetchone() + selected_trade_date = latest["latest_trade_date"] if latest else None + + if selected_trade_date: + resolved_date_from = selected_trade_date + resolved_date_to = selected_trade_date + + if not resolved_date_from or not resolved_date_to: + return {"trade_date": None, "date_from": None, "date_to": None, "actions": []} + + trader_filter = "" + params: list[Any] = [resolved_date_from, resolved_date_to] + if trader_name: + trader_filter = "AND d.matched_trader_name = %s" + params.append(trader_name) + params.append(limit) + + cursor.execute( + f""" + SELECT + d.trade_date, + d.stock_code, + COALESCE(o.stock_name, d.stock_name) AS stock_name, + d.matched_trader_name AS trader_name, + d.table_title, + d.seat_name, + d.buy_amount_wan, + d.sell_amount_wan, + d.net_amount_wan, + o.price AS current_price, + o.pct_chg, + s.industry, + s.market, + s.total_market_value, + s.circulating_market_value, + CASE + WHEN CAST(COALESCE(NULLIF(d.buy_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 + AND CAST(COALESCE(NULLIF(d.sell_amount_wan, ''), '0') AS DECIMAL(18,2)) = 0 + THEN 'buy' + WHEN CAST(COALESCE(NULLIF(d.sell_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 + AND CAST(COALESCE(NULLIF(d.buy_amount_wan, ''), '0') AS DECIMAL(18,2)) = 0 + THEN 'sell' + WHEN CAST(COALESCE(NULLIF(d.net_amount_wan, ''), '0') AS DECIMAL(18,2)) >= 0 + THEN 'net_buy' + ELSE 'net_sell' + END AS action_side + FROM lhb_detail_seats d + LEFT JOIN ( + SELECT + trade_date, + stock_code, + MAX(stock_name) AS stock_name, + MAX(price) AS price, + MAX(pct_chg) AS pct_chg + FROM lhb_overview + GROUP BY trade_date, stock_code + ) o + ON o.trade_date = d.trade_date AND o.stock_code = d.stock_code + LEFT JOIN stocks s + ON s.stock_code = d.stock_code + WHERE d.trade_date BETWEEN %s AND %s + AND d.matched_trader_name IS NOT NULL + {trader_filter} + ORDER BY d.trade_date DESC, d.matched_trader_name, d.stock_code, d.table_title, d.id + LIMIT %s + """, + params, + ) + actions = [_normalize_row(row) for row in cursor.fetchall()] + + for action in actions: + action["market"] = action.get("market") or _infer_market_label(action["stock_code"]) + action["board_label"] = _infer_board_label(action["stock_code"]) + + return { + "trade_date": _normalize_value(selected_trade_date or resolved_date_to), + "date_from": _normalize_value(resolved_date_from), + "date_to": _normalize_value(resolved_date_to), + "actions": actions, + } + + +def fetch_trader_detail(trader_id: int) -> dict[str, Any]: + with db_cursor() as (_, cursor): + cursor.execute( + """ + SELECT id, name, alias_name, warning_weight, style_tags + FROM traders + WHERE id = %s + """, + (trader_id,), + ) + trader = cursor.fetchone() + if not trader: + return {} + + trader_name = trader["name"] + trader = _normalize_row(trader) + trader["style_tags"] = _parse_json_list(trader.get("style_tags")) + + cursor.execute( + """ + SELECT seat_name, seat_level + FROM trader_seats ts + JOIN traders t ON ts.trader_id = t.id + WHERE t.id = %s + ORDER BY seat_level, seat_name + """, + (trader_id,), + ) + seats = [_normalize_row(row) for row in cursor.fetchall()] + + cursor.execute( + """ + SELECT + d.stock_code, + MAX(COALESCE(o.stock_name, d.stock_name)) AS stock_name, + MAX(o.price) AS latest_price, + MAX(o.pct_chg) AS pct_chg, + COUNT(*) AS action_count, + MAX(d.trade_date) AS last_trade_date, + SUM(CASE WHEN CAST(COALESCE(NULLIF(d.buy_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 THEN 1 ELSE 0 END) AS buy_action_count, + SUM(CASE WHEN CAST(COALESCE(NULLIF(d.sell_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 THEN 1 ELSE 0 END) AS sell_action_count, + MAX(CASE WHEN w.warning_type = 'sell_alert' THEN 1 ELSE 0 END) AS has_sell_alert, + MAX(CASE WHEN w.warning_type = 'slow_exit_watch' THEN 1 ELSE 0 END) AS has_slow_exit + FROM lhb_detail_seats d + LEFT JOIN lhb_overview o + ON o.stock_code = d.stock_code AND o.trade_date = d.trade_date + LEFT JOIN warning_events w + ON w.stock_code = d.stock_code AND w.trader_name = d.matched_trader_name + WHERE d.matched_trader_name = %s + GROUP BY d.stock_code + ORDER BY last_trade_date DESC, action_count DESC + LIMIT 100 + """, + (trader_name,), + ) + stocks = [_normalize_row(row) for row in cursor.fetchall()] + + cursor.execute( + """ + SELECT trade_date, stock_code, stock_name, warning_type, warning_level, trigger_reason + FROM warning_events + WHERE trader_name = %s + ORDER BY trade_date DESC, created_at DESC + LIMIT 20 + """, + (trader_name,), + ) + warnings = [_normalize_row(row) for row in cursor.fetchall()] + + return { + "trader": trader, + "seats": seats, + "stocks": stocks, + "warnings": warnings, + } + + +def fetch_stock_detail(stock_code: str) -> dict[str, Any]: + market_daily: list[dict[str, Any]] = [] + quote_snapshot: dict[str, Any] = {} + eastmoney = EastMoneyClient() + try: + market_daily = eastmoney.fetch_daily_kline(stock_code) + except Exception: + market_daily = [] + try: + quote_snapshot = eastmoney.fetch_quote_snapshot(stock_code) + except Exception: + quote_snapshot = {} + if not market_daily: + try: + market_daily = SinaClient().fetch_daily_kline(stock_code) + except Exception: + market_daily = [] + + with db_cursor() as (_, cursor): + cursor.execute( + """ + SELECT stock_code, stock_name, market, industry, total_market_value, circulating_market_value + FROM stocks + WHERE stock_code = %s + """, + (stock_code,), + ) + stock = cursor.fetchone() + if not stock: + return {} + stock = _normalize_row(stock) + if quote_snapshot: + if not stock.get("stock_name"): + stock["stock_name"] = quote_snapshot.get("stock_name") + if not stock.get("market"): + stock["market"] = _infer_market_label(stock_code) + if not stock.get("industry"): + stock["industry"] = quote_snapshot.get("industry") + if not stock.get("total_market_value"): + stock["total_market_value"] = quote_snapshot.get("total_market_value") + if not stock.get("circulating_market_value"): + stock["circulating_market_value"] = quote_snapshot.get("circulating_market_value") + if not stock.get("market"): + stock["market"] = _infer_market_label(stock_code) + + cursor.execute( + """ + SELECT trade_date, price, pct_chg, amount, net_buy, flag + FROM lhb_overview + WHERE stock_code = %s + ORDER BY trade_date DESC + """, + (stock_code,), + ) + overview = [_normalize_row(row) for row in cursor.fetchall()] + + cursor.execute( + """ + SELECT + trade_date, + matched_trader_name, + table_title, + seat_name, + buy_amount_wan, + sell_amount_wan, + net_amount_wan + FROM lhb_detail_seats + WHERE stock_code = %s + AND matched_trader_name IS NOT NULL + ORDER BY trade_date DESC, id DESC + LIMIT 120 + """, + (stock_code,), + ) + trader_actions = [_normalize_row(row) for row in cursor.fetchall()] + + cursor.execute( + """ + SELECT + matched_trader_name, + COUNT(*) AS action_count, + SUM(CASE WHEN CAST(COALESCE(NULLIF(buy_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 THEN 1 ELSE 0 END) AS buy_count, + SUM(CASE WHEN CAST(COALESCE(NULLIF(sell_amount_wan, ''), '0') AS DECIMAL(18,2)) > 0 THEN 1 ELSE 0 END) AS sell_count, + MAX(trade_date) AS last_trade_date, + SUM(CAST(COALESCE(NULLIF(buy_amount_wan, ''), '0') AS DECIMAL(18,2))) AS total_buy_amount_wan, + SUM(CAST(COALESCE(NULLIF(sell_amount_wan, ''), '0') AS DECIMAL(18,2))) AS total_sell_amount_wan, + SUM(CAST(COALESCE(NULLIF(net_amount_wan, ''), '0') AS DECIMAL(18,2))) AS total_net_amount_wan + FROM lhb_detail_seats + WHERE stock_code = %s + AND matched_trader_name IS NOT NULL + GROUP BY matched_trader_name + ORDER BY action_count DESC, last_trade_date DESC + """, + (stock_code,), + ) + trader_summary = [_normalize_row(row) for row in cursor.fetchall()] + + cursor.execute( + """ + SELECT trade_date, trader_name, warning_type, warning_level, trigger_reason, suggestion + FROM warning_events + WHERE stock_code = %s + ORDER BY trade_date DESC, created_at DESC + LIMIT 30 + """, + (stock_code,), + ) + warnings = [_normalize_row(row) for row in cursor.fetchall()] + + circulating_shares = quote_snapshot.get("circulating_shares") + for row in market_daily: + close_price = float(row.get("close") or 0) + volume = float(row.get("volume") or 0) + if row.get("amount") in (None, "", "-") and close_price and volume: + row["amount"] = f"{close_price * volume / 100000000:.2f}亿" + if row.get("turnover") in (None, "", "-") and circulating_shares: + try: + turnover_pct = volume / float(circulating_shares) * 100 + row["turnover"] = f"{turnover_pct:.2f}%" + except Exception: + pass + + return { + "stock": stock, + "market_snapshot": {key: _normalize_value(value) for key, value in quote_snapshot.items()}, + "overview": overview, + "market_daily": market_daily, + "trader_actions": trader_actions, + "trader_summary": trader_summary, + "warnings": warnings, + } diff --git a/backend/src/lhbfx/schema.sql b/backend/src/lhbfx/schema.sql new file mode 100644 index 0000000..6f44344 --- /dev/null +++ b/backend/src/lhbfx/schema.sql @@ -0,0 +1,112 @@ +CREATE TABLE IF NOT EXISTS traders ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(64) NOT NULL UNIQUE, + alias_name VARCHAR(128) NULL, + warning_weight VARCHAR(16) NOT NULL DEFAULT 'medium', + style_tags JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS trader_seats ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + trader_id BIGINT NOT NULL, + seat_name VARCHAR(255) NOT NULL, + seat_level VARCHAR(32) NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_trader_seat (trader_id, seat_name), + CONSTRAINT fk_trader_seats_trader FOREIGN KEY (trader_id) REFERENCES traders(id) +); + +CREATE TABLE IF NOT EXISTS stocks ( + stock_code VARCHAR(16) PRIMARY KEY, + stock_name VARCHAR(64) NOT NULL, + market VARCHAR(32) NULL, + industry VARCHAR(128) NULL, + concept_tags JSON NULL, + total_market_value DECIMAL(20, 2) NULL, + circulating_market_value DECIMAL(20, 2) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS lhb_overview ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + trade_date DATE NULL, + stock_code VARCHAR(16) NOT NULL, + stock_name VARCHAR(64) NOT NULL, + flag VARCHAR(32) NULL, + rid VARCHAR(32) NULL, + price VARCHAR(32) NULL, + pct_chg VARCHAR(32) NULL, + amount VARCHAR(32) NULL, + net_buy VARCHAR(32) NULL, + detail_url VARCHAR(255) NULL, + source VARCHAR(32) NOT NULL DEFAULT 'tonghuashun', + raw_payload JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_lhb_overview_code (stock_code), + KEY idx_lhb_overview_date (trade_date), + UNIQUE KEY uniq_lhb_overview_record (trade_date, stock_code, rid) +); + +CREATE TABLE IF NOT EXISTS lhb_detail_seats ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + trade_date DATE NULL, + stock_code VARCHAR(16) NOT NULL, + stock_name VARCHAR(64) NULL, + rid VARCHAR(32) NULL, + section_title VARCHAR(255) NULL, + table_title VARCHAR(128) NOT NULL, + seat_name VARCHAR(255) NOT NULL, + buy_amount_wan VARCHAR(32) NULL, + buy_ratio VARCHAR(32) NULL, + sell_amount_wan VARCHAR(32) NULL, + sell_ratio VARCHAR(32) NULL, + net_amount_wan VARCHAR(32) NULL, + matched_trader_name VARCHAR(64) NULL, + matched_seat VARCHAR(255) NULL, + detail_url VARCHAR(255) NULL, + source VARCHAR(32) NOT NULL DEFAULT 'tonghuashun', + raw_payload JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_lhb_detail_code (stock_code), + KEY idx_lhb_detail_trade_date (trade_date), + KEY idx_lhb_detail_trader (matched_trader_name), + UNIQUE KEY uniq_lhb_detail_record (trade_date, stock_code, rid, table_title, seat_name) +); + +CREATE TABLE IF NOT EXISTS warning_events ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + trade_date DATE NULL, + stock_code VARCHAR(16) NOT NULL, + stock_name VARCHAR(64) NULL, + trader_name VARCHAR(64) NULL, + warning_type VARCHAR(64) NOT NULL, + warning_level VARCHAR(32) NOT NULL, + trigger_reason TEXT NOT NULL, + current_price VARCHAR(32) NULL, + pct_chg VARCHAR(32) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'new', + suggestion VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_warning_events_code (stock_code), + KEY idx_warning_events_trade_date (trade_date), + KEY idx_warning_events_trader (trader_name) +); + +CREATE TABLE IF NOT EXISTS watchlist_entries ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stock_code VARCHAR(16) NOT NULL, + stock_name VARCHAR(64) NOT NULL, + source_trade_date DATE NULL, + source_trader_name VARCHAR(64) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + archived_at TIMESTAMP NULL DEFAULT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_watchlist_stock (stock_code), + KEY idx_watchlist_status_added (status, added_at) +); diff --git a/backend/src/lhbfx/seed.py b/backend/src/lhbfx/seed.py new file mode 100644 index 0000000..d04d221 --- /dev/null +++ b/backend/src/lhbfx/seed.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import json + +from .config import AppConfig, load_config +from .db import db_cursor + + +def seed_traders(config: AppConfig | None = None) -> None: + cfg = config or load_config() + with db_cursor(config=cfg) as (_, cursor): + for trader in cfg.traders: + cursor.execute( + """ + INSERT INTO traders (name, alias_name, warning_weight, style_tags) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + alias_name = VALUES(alias_name), + warning_weight = VALUES(warning_weight), + style_tags = VALUES(style_tags) + """, + ( + trader["name"], + trader.get("alias") or None, + trader.get("warning_weight", "medium"), + json.dumps(trader.get("style_tags", []), ensure_ascii=False), + ), + ) + + cursor.execute("SELECT id FROM traders WHERE name = %s", (trader["name"],)) + trader_id = cursor.fetchone()["id"] + + seat_groups = trader.get("seats", {}) + for seat_level, seats in seat_groups.items(): + for seat in seats or []: + cursor.execute( + """ + INSERT INTO trader_seats (trader_id, seat_name, seat_level) + VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE + seat_level = VALUES(seat_level), + is_active = 1 + """, + (trader_id, seat, seat_level), + ) + diff --git a/backend/src/lhbfx/sources/__init__.py b/backend/src/lhbfx/sources/__init__.py new file mode 100644 index 0000000..ca75a57 --- /dev/null +++ b/backend/src/lhbfx/sources/__init__.py @@ -0,0 +1,2 @@ +"""Data sources.""" + diff --git a/backend/src/lhbfx/sources/eastmoney.py b/backend/src/lhbfx/sources/eastmoney.py new file mode 100644 index 0000000..04a177f --- /dev/null +++ b/backend/src/lhbfx/sources/eastmoney.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from typing import Any + +import requests + + +DEFAULT_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/135.0.0.0 Safari/537.36" + ), + "Referer": "https://quote.eastmoney.com/", +} + + +def infer_secid(stock_code: str) -> str: + if stock_code.startswith(("6", "9", "5", "688")): + return f"1.{stock_code}" + return f"0.{stock_code}" + + +class EastMoneyClient: + def fetch_quote_snapshot(self, stock_code: str) -> dict[str, Any]: + secid = infer_secid(stock_code) + url = "https://push2.eastmoney.com/api/qt/stock/get" + params = { + "secid": secid, + "fields": "f43,f44,f45,f46,f47,f48,f57,f58,f60,f84,f85,f116,f117,f127,f168,f169,f170,f171", + } + response = requests.get(url, params=params, headers=DEFAULT_HEADERS, timeout=20) + response.raise_for_status() + payload = response.json() + data = payload.get("data") or {} + return { + "stock_code": data.get("f57") or stock_code, + "stock_name": data.get("f58"), + "industry": data.get("f127"), + "circulating_shares": data.get("f84"), + "circulating_market_value": data.get("f117"), + "total_market_value": data.get("f116"), + "latest_price": data.get("f43"), + "high_price": data.get("f44"), + "low_price": data.get("f45"), + "open_price": data.get("f46"), + "volume": data.get("f47"), + "amount": data.get("f48"), + "previous_close": data.get("f60"), + "turnover": data.get("f168"), + "price_chg": data.get("f169"), + "pct_chg": data.get("f170"), + "amplitude": data.get("f171"), + } + + def fetch_daily_kline(self, stock_code: str, limit: int = 240) -> list[dict[str, Any]]: + secid = infer_secid(stock_code) + url = "https://push2his.eastmoney.com/api/qt/stock/kline/get" + params = { + "secid": secid, + "klt": "101", + "fqt": "1", + "lmt": str(limit), + "end": "20500000", + "fields1": "f1,f2,f3,f4,f5,f6", + "fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61", + } + response = requests.get(url, params=params, headers=DEFAULT_HEADERS, timeout=20) + response.raise_for_status() + payload = response.json() + data = payload.get("data") or {} + klines = data.get("klines") or [] + + rows: list[dict[str, Any]] = [] + for item in klines: + parts = item.split(",") + if len(parts) < 11: + continue + rows.append( + { + "trade_date": parts[0], + "open": parts[1], + "close": parts[2], + "high": parts[3], + "low": parts[4], + "volume": parts[5], + "amount": parts[6], + "amplitude": parts[7], + "pct_chg": parts[8], + "price_chg": parts[9], + "turnover": parts[10], + } + ) + return rows diff --git a/backend/src/lhbfx/sources/sina.py b/backend/src/lhbfx/sources/sina.py new file mode 100644 index 0000000..6679cc7 --- /dev/null +++ b/backend/src/lhbfx/sources/sina.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Any + +import requests + + +DEFAULT_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/135.0.0.0 Safari/537.36" + ), + "Referer": "https://finance.sina.com.cn/", +} + + +def infer_symbol(stock_code: str) -> str: + if stock_code.startswith(("6", "9", "5", "688")): + return f"sh{stock_code}" + return f"sz{stock_code}" + + +class SinaClient: + def fetch_daily_kline(self, stock_code: str, limit: int = 240) -> list[dict[str, Any]]: + symbol = infer_symbol(stock_code) + url = "https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData" + params = { + "symbol": symbol, + "scale": "240", + "ma": "no", + "datalen": str(limit), + } + response = requests.get(url, params=params, headers=DEFAULT_HEADERS, timeout=20) + response.raise_for_status() + payload = response.json() + + rows: list[dict[str, Any]] = [] + previous_close: float | None = None + for item in payload or []: + open_price = float(item.get("open", 0) or 0) + close_price = float(item.get("close", 0) or 0) + high_price = float(item.get("high", 0) or 0) + low_price = float(item.get("low", 0) or 0) + amplitude = "-" + pct_chg = "-" + price_chg = "-" + + if previous_close and previous_close != 0: + price_diff = close_price - previous_close + pct_diff = price_diff / previous_close * 100 + amp = (high_price - low_price) / previous_close * 100 + price_chg = f"{price_diff:.3f}" + pct_chg = f"{pct_diff:.2f}%" + amplitude = f"{amp:.2f}%" + + rows.append( + { + "trade_date": item.get("day", ""), + "open": item.get("open", ""), + "close": item.get("close", ""), + "high": item.get("high", ""), + "low": item.get("low", ""), + "volume": item.get("volume", ""), + "amount": "-", + "amplitude": amplitude, + "pct_chg": pct_chg, + "price_chg": price_chg, + "turnover": "-", + } + ) + previous_close = close_price or previous_close + return rows diff --git a/backend/src/lhbfx/sources/tonghuashun.py b/backend/src/lhbfx/sources/tonghuashun.py new file mode 100644 index 0000000..1fc56e5 --- /dev/null +++ b/backend/src/lhbfx/sources/tonghuashun.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import requests +from bs4 import BeautifulSoup + +from ..config import AppConfig, load_config + + +DEFAULT_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/135.0.0.0 Safari/537.36" + ), + "Referer": "http://data.10jqka.com.cn/", +} + + +class TongHuaShunClient: + def __init__(self, config: AppConfig | None = None) -> None: + self.config = config or load_config() + + def fetch_html(self, url: str) -> str: + response = requests.get(url, headers=DEFAULT_HEADERS, timeout=20) + response.raise_for_status() + response.encoding = "gbk" + return response.text + + def fetch_overview(self) -> list[dict[str, Any]]: + overview_url = "http://data.10jqka.com.cn/market/lhbcjmx/" + html = self.fetch_html(overview_url) + return self._parse_overview(html) + + def fetch_daily_overview(self, trade_date: str) -> list[dict[str, Any]]: + table_url = ( + "http://data.10jqka.com.cn/ifmarket/lhbtable/" + f"stock/all/report/{trade_date}/tab/all/field/STOCKCODE/sort/asc/" + ) + html = self.fetch_html(table_url) + return self._parse_overview(html, trade_date=trade_date) + + def fetch_stock_detail(self, stock_code: str) -> dict[str, Any]: + detail_url = f"http://data.10jqka.com.cn/market/longhu/code/{stock_code}/" + html = self.fetch_html(detail_url) + overview_rows = self._parse_overview(html) + detail_tables = self._parse_detail_tables(html) + return { + "fetched_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "stock_code": stock_code, + "detail_url": detail_url, + "overview_rows": overview_rows, + "detail_tables": detail_tables, + } + + def fetch_record_detail(self, stock_code: str, trade_date: str, rid: str) -> dict[str, Any]: + rid = self._normalize_rid(rid) + detail_url = ( + "http://data.10jqka.com.cn/ifmarket/getnewlh/" + f"code/{stock_code}/date/{trade_date}/rid/{rid}/" + ) + html = self.fetch_html(detail_url) + return self._parse_record_detail(html, stock_code=stock_code, trade_date=trade_date, rid=rid, detail_url=detail_url) + + def _parse_overview(self, html: str, limit: int = 50, trade_date: str | None = None) -> list[dict[str, Any]]: + soup = BeautifulSoup(html, "html.parser") + rows = [] + target_table = None + for table in soup.select("table"): + headers = [th.get_text(" ", strip=True) for th in table.select("th")] + if {"代码", "名称", "现价", "涨跌幅", "成交金额", "净买入额"}.issubset(set(headers)): + next_table = table.find_next("table") + if next_table: + target_table = next_table + break + if target_table is None: + return rows + + for tr in target_table.select("tbody tr")[:limit]: + tds = tr.select("td") + if len(tds) < 7: + continue + stock_link = tds[2].select_one("a.stock") + rid = stock_link.get("rid", "") if stock_link else "" + normalized_rid = self._normalize_rid(rid) + stock_code = tds[1].get_text(" ", strip=True) + detail_url = ( + f"http://data.10jqka.com.cn/market/lhbgg/code/{stock_code}/date/{trade_date}/rid/{normalized_rid}/" + if trade_date and normalized_rid + else (stock_link.get("href", "") if stock_link else "") + ) + rows.append( + { + "flag": tds[0].get_text(" ", strip=True), + "stock_code": stock_code, + "stock_name": tds[2].get_text(" ", strip=True), + "price": tds[3].get_text(" ", strip=True), + "pct_chg": tds[4].get_text(" ", strip=True), + "amount": tds[5].get_text(" ", strip=True), + "net_buy": tds[6].get_text(" ", strip=True), + "rid": normalized_rid, + "trade_date": trade_date, + "detail_url": detail_url, + } + ) + return rows + + def _parse_detail_tables(self, html: str) -> list[dict[str, Any]]: + soup = BeautifulSoup(html, "html.parser") + tables = [] + for table in soup.select("table"): + headers = [th.get_text(" ", strip=True) for th in table.select("th")] + if not headers: + continue + if "买入金额最大的前5名营业部" not in headers and "卖出金额最大的前5名营业部" not in headers: + continue + rows = [] + for tr in table.select("tr")[1:]: + tds = [td.get_text(" ", strip=True) for td in tr.select("td")] + if len(tds) < 4: + continue + rows.append( + { + "seat_name": tds[0], + "buy_amount_wan": tds[1], + "sell_amount_wan": tds[2], + "net_amount_wan": tds[3], + } + ) + tables.append({"table_title": headers[0], "rows": rows}) + return tables + + def _parse_record_detail( + self, + html: str, + stock_code: str, + trade_date: str, + rid: str, + detail_url: str, + ) -> dict[str, Any]: + soup = BeautifulSoup(html, "html.parser") + title = soup.select_one(".lhb-tipbox-hd-title") + date_node = soup.select_one(".lhb-tipbox-hd-date") + desc_node = soup.select_one(".lhb-tipbox-hd-desc") + table = soup.select_one("table") + headers = [th.get_text(" ", strip=True) for th in table.select("th")] if table else [] + + sections: list[dict[str, Any]] = [] + current_section: dict[str, Any] | None = None + + if table: + for tr in table.select("tr")[1:]: + classes = tr.get("class") or [] + cells = [td.get_text(" ", strip=True) for td in tr.select("td")] + if "lhb_td_desc" in classes: + current_section = { + "section_title": cells[0] if cells else "", + "rows": [], + } + sections.append(current_section) + continue + + if len(cells) >= 7: + row = { + "rank_no": cells[0], + "seat_name": cells[1], + "buy_amount_wan": cells[2], + "buy_ratio": cells[3], + "sell_amount_wan": cells[4], + "sell_ratio": cells[5], + "net_amount_wan": cells[6], + } + if current_section is None: + current_section = {"section_title": "unknown", "rows": []} + sections.append(current_section) + current_section["rows"].append(row) + + return { + "stock_code": stock_code, + "trade_date": trade_date, + "rid": rid, + "detail_url": detail_url, + "title": title.get_text(" ", strip=True) if title else "", + "date_text": date_node.get_text(" ", strip=True) if date_node else trade_date, + "summary_text": desc_node.get_text(" ", strip=True) if desc_node else "", + "headers": headers, + "sections": sections, + } + + @staticmethod + def _normalize_rid(rid: str) -> str: + if "_" in rid: + return rid.split("_")[-1] + return rid diff --git a/docs/UI设计说明.md b/docs/UI设计说明.md new file mode 100644 index 0000000..bd1e984 --- /dev/null +++ b/docs/UI设计说明.md @@ -0,0 +1,541 @@ +# 顶级游资监控系统 UI 设计说明 + +## 1. 设计目标 + +本系统的 UI 目标不是做成普通后台表格,而是做成一套“交易指挥台”风格的专业监控界面,突出以下感受: + +1. 先看到风险,再看到机会。 +2. 先看到游资状态,再看到股票细节。 +3. 重点信息高亮,次要信息收起。 +4. 图表和列表联动,减少来回跳转成本。 + +## 2. 视觉方向 + +### 2.1 整体风格 +采用“深色专业交易终端”风格,强调稳重、克制、强信号感。 + +关键词: + +1. 深色背景 +2. 琥珀金高亮 +3. 红绿状态强对比 +4. 高密度但有秩序 +5. 接近盘面终端而不是通用管理后台 + +### 2.2 视觉气质 +界面应接近“机构交易看板 + 作战态势图”的感觉: + +1. 首页像总控台。 +2. 游资页像资金操盘面板。 +3. 个股页像单票作战图。 +4. 预警页像风险雷达。 + +## 3. 设计系统 + +### 3.1 色彩规范 + +建议主色变量: + +1. `--bg-main: #0d1117` +2. `--bg-panel: #141a22` +3. `--bg-soft: #1a2330` +4. `--text-main: #f3efe6` +5. `--text-sub: #9aa6b2` +6. `--accent-gold: #d6a85f` +7. `--accent-blue: #53b7ff` +8. `--rise-red: #ff5b57` +9. `--fall-green: #2fbf71` +10. `--warning-orange: #ff9f43` +11. `--danger-red: #ff4d4f` + +配色原则: + +1. 金色用于游资身份、高价值信号、核心标题。 +2. 红色用于涨幅、买入点、高风险预警。 +3. 绿色用于下跌、风险释放、冷却状态。 +4. 蓝色用于辅助图层、详情跳转、次级信息。 +5. 不使用花哨渐变,重点靠对比和层次建立专业感。 + +### 3.2 字体规范 + +建议中文字体组合: + +1. 标题字体:`优设标题黑` 或 `阿里妈妈方圆体` +2. 正文字体:`思源黑体` +3. 数字字体:`DIN Alternate` 或 `Bahnschrift` + +规则: + +1. 大标题有牌面感。 +2. 表格数字必须整齐、易比对。 +3. 财务和金额信息使用等宽或接近等宽数字字体。 + +### 3.3 圆角与阴影 + +建议风格: + +1. 卡片圆角 14px +2. 图表容器圆角 18px +3. 不做悬浮发光,改用轻阴影和描边 +4. 核心卡片可加细金色描边 + +### 3.4 间距规范 + +建议: + +1. 页面外边距 24px +2. 卡片内边距 16px / 20px +3. 模块间距 16px +4. 标题与内容间距 12px + +## 4. 页面结构 + +## 4.1 首页:总控台 + +### 4.1.1 页面目标 +首页用于盘后快速总览,不展开细节,核心是“今天要先处理什么”。 + +### 4.1.2 页面布局 +建议采用 `12 栏栅格 + 多卡片拼接` 布局。 + +从上到下结构: + +1. 顶部状态栏 +2. 核心指标卡 +3. 今日预警区 +4. 今日机会区 +5. 三大游资动态概览 +6. 最近异动股票列表 + +### 4.1.3 顶部状态栏 +展示: + +1. 当前交易日 +2. 数据更新时间 +3. 龙虎榜采集状态 +4. 预警数量 +5. 系统运行状态 + +样式建议: + +1. 顶栏高度 64px +2. 左侧产品名,右侧状态标签 +3. 状态标签用胶囊标签样式 + +### 4.1.4 核心指标卡 +建议 4 张卡横排: + +1. 今日卖出预警数 +2. 今日慢流出观察数 +3. 今日共振标的数 +4. 今日可关注标的数 + +卡片规则: + +1. 卖出预警卡用红色强调 +2. 慢流出用橙色强调 +3. 共振用金色强调 +4. 可关注用蓝色强调 + +### 4.1.5 今日预警区 +建议放在首页第一视觉区。 + +模块结构: + +1. 区域标题:`今日高优先级预警` +2. 左侧预警列表 +3. 右侧预警详情摘要 + +列表字段: + +1. 股票名称 +2. 游资名称 +3. 预警类型 +4. 当前价格 +5. 当日涨跌幅 +6. 预警等级 +7. 建议动作 + +交互: + +1. 点击一条预警,右侧同步展示摘要 +2. 可直接跳入股票详情页 + +### 4.1.6 今日机会区 +模块名称建议:`今日可关注` + +展示字段: + +1. 股票名称 +2. 所属板块 +3. 参与游资 +4. 累计净额 +5. 当前状态 +6. 是否接近 5 日线 / Fib 支撑位 +7. 风险等级 + +### 4.1.7 游资动态概览 +展示三个游资的状态卡: + +1. 章盟主 +2. 炒股养家 +3. 欢乐海岸 + +每张卡展示: + +1. 今日参与股票数 +2. 当前观察中股票数 +3. 卖出预警数 +4. 慢流出数 +5. 主要参与板块 + +点击卡片进入游资详情页。 + +## 4.2 游资详情页 + +### 4.2.1 页面目标 +用于查看某位游资最近到底做了哪些票,以及当前哪些票最值得盯。 + +### 4.2.2 页面结构 +从上到下: + +1. 游资头部信息卡 +2. 近期参与股票列表 +3. 板块分布区 +4. 最近动作时间轴 + +### 4.2.3 游资头部信息卡 +内容包括: + +1. 游资名称 +2. 风格标签 +3. 核心席位 +4. 活跃度 +5. 当前预警数 +6. 当前共振数 + +UI 表现: + +1. 左侧大标题和标签 +2. 右侧 3-4 个数字指标 +3. 核心席位用标签云展示 + +### 4.2.4 近期参与股票列表 +这是本页的主区块。 + +列表字段建议: + +1. 股票代码 +2. 股票名称 +3. 所属板块 +4. 最新股价 +5. 当日涨跌幅 +6. 总市值 +7. 首次参与日期 +8. 最近动作日期 +9. 当日净额 +10. 累计净额 +11. 当前状态 +12. 预警状态 +13. 是否共振 + +列表样式建议: + +1. 表头固定 +2. 支持按列排序 +3. 当前有预警的行使用弱红底 +4. 共振行加金色左边条 +5. 累计净额大的行字体加粗 + +### 4.2.5 板块分布区 +展示该游资近期重点参与的板块。 + +推荐表现方式: + +1. 横向热度条 +2. 小型 treemap +3. 排名列表 + +展示字段: + +1. 板块名称 +2. 股票数量 +3. 累计净额总和 +4. 当前预警数 + +### 4.2.6 最近动作时间轴 +显示最近 7-10 条动作: + +1. 新建仓 +2. 加仓 +3. 卖出 +4. 慢流出 +5. 共振形成 + +形式建议: + +1. 左侧时间 +2. 中间状态点 +3. 右侧动作文字 + +## 4.3 股票详情页 + +### 4.3.1 页面目标 +用于看一只股票的“价格走势 + 龙虎榜参与 + 游资关系 + 当前风险”。 + +### 4.3.2 页面结构 +建议采用左右结构: + +1. 左侧主内容区占 8 栏 +2. 右侧信息侧栏占 4 栏 + +### 4.3.3 左侧主内容区 + +#### A. 股票基础信息头部 +展示: + +1. 股票名称和代码 +2. 所属行业 +3. 所属概念板块 +4. 最新股价 +5. 当日涨跌幅 +6. 总市值 / 流通市值 +7. 当前状态 + +#### B. 主图:价格走势 + 龙虎榜买卖点 +图上应展示: + +1. K 线 +2. 5 日线 +3. 关键支撑位 +4. 买入点红色标记 +5. 卖出点蓝色标记 +6. 成本区或成本线 + +交互要求: + +1. 鼠标悬浮显示日期、价格、龙虎榜席位、净额 +2. 可切换仅看章盟主 / 仅看欢乐海岸 / 看全部 + +#### C. 副图:累计净额柱状图 +作用是让用户快速看到: + +1. 是否持续加仓 +2. 是否锁仓 +3. 是否开始撤退 + +样式建议: + +1. 正向净额用暖红 +2. 负向净额用冷绿或深灰 +3. 当前观察期用淡金色背景框 + +#### D. 龙虎榜整体参与图 +这是股票页的核心差异化图表。 + +展示目标: + +1. 看谁先进入 +2. 看谁后续加仓 +3. 看谁开始卖出 +4. 看多游资是否形成接力 + +推荐图形: + +1. 时间轴泳道图 + +泳道设计: + +1. 第一行:章盟主 +2. 第二行:炒股养家 +3. 第三行:欢乐海岸 +4. 第四行:股价阶段标签 + +节点颜色: + +1. 买入:红点 +2. 加仓:金点 +3. 卖出:蓝点 +4. 慢流出:橙点 + +### 4.3.4 右侧信息侧栏 + +#### A. 当前预警卡 +内容包括: + +1. 是否卖出预警 +2. 是否慢流出观察 +3. 是否破位 +4. 预警等级 +5. 建议动作 + +#### B. 参与游资卡 +展示参与该股的目标游资: + +1. 游资名称 +2. 当前状态 +3. 累计净额 +4. 最近动作时间 + +#### C. 财务状况摘要卡 +展示: + +1. PE / PB +2. 营收增速 +3. 净利润增速 +4. ROE +5. 资产负债率 +6. 财务总结文案 + +#### D. 系统建议卡 +建议分四种: + +1. 可关注 +2. 继续观察 +3. 谨慎 +4. 规避 + +## 4.4 预警与监控页 + +### 4.4.1 页面目标 +把所有风险类信息单独集中,让用户快速处理。 + +### 4.4.2 页面结构 +建议采用“三栏式”: + +1. 左栏:预警筛选器 +2. 中栏:预警列表 +3. 右栏:预警详情 + +### 4.4.3 筛选器 +支持按以下条件筛选: + +1. 游资 +2. 股票 +3. 预警等级 +4. 预警类型 +5. 日期范围 + +### 4.4.4 预警列表字段 + +1. 时间 +2. 股票名称 +3. 游资 +4. 预警类型 +5. 等级 +6. 当前价格 +7. 当日涨跌幅 +8. 建议动作 + +### 4.4.5 预警详情 +展示: + +1. 触发原因 +2. 关联龙虎榜记录 +3. 最近 5 个交易日动作摘要 +4. 技术位置状态 +5. 风险建议 + +## 5. 核心组件设计 + +### 5.1 状态标签 +标签颜色建议: + +1. 建仓:金色 +2. 加仓:红色 +3. 锁仓:深金色 +4. 慢流出:橙色 +5. 撤退:灰红色 +6. 共振:亮金色 + +### 5.2 预警徽章 +分三级: + +1. 普通提醒:橙色描边 +2. 重点预警:橙红实底 +3. 高风险预警:纯红实底 + +### 5.3 数字卡 +用于首页和头部概览。 + +要求: + +1. 数值大 +2. 标题小 +3. 趋势箭头明确 +4. 可点击进入详情 + +### 5.4 表格 +本系统的表格不是普通后台表格,要更像交易终端: + +1. 重点字段固定靠左 +2. 金额字段右对齐 +3. 涨跌幅颜色化 +4. 预警状态直接可见 +5. 行 hover 时显示更深底色 + +## 6. 动效建议 + +### 6.1 原则 +动效要克制,不做花哨炫技,只服务于信息判断。 + +### 6.2 建议动效 + +1. 页面加载时卡片淡入上移 +2. 高风险预警卡轻微闪烁 +3. 表格 hover 行高亮 +4. 图表切换时平滑过渡 +5. 新预警出现时从右上角滑入 + +## 7. 响应式建议 + +### 7.1 桌面端 +优先支持宽屏桌面,建议设计基准宽度 1440px。 + +### 7.2 笔记本端 +在 1280px 左右时: + +1. 首页改为两列布局 +2. 股票详情右侧栏可折叠 + +### 7.3 移动端 +移动端只建议保留轻量查看功能: + +1. 预警列表 +2. 游资列表 +3. 股票基础信息 +4. 简化图表 + +不建议移动端承载完整复杂图表分析。 + +## 8. 原型优先级 + +### 8.1 第一优先级 + +1. 首页总控台 +2. 游资详情页 +3. 股票详情页 +4. 预警页 + +### 8.2 第二优先级 + +1. 财务摘要组件 +2. 板块分布组件 +3. 龙虎榜整体参与图 + +## 9. 交付建议 + +UI 第一阶段建议先输出以下内容: + +1. 高保真首页 +2. 高保真游资详情页 +3. 高保真股票详情页 +4. 高保真预警页 +5. 一套颜色、字体、标签和卡片规范 + +如果进入前端实现阶段,建议优先做: + +1. 首页 + 游资详情页联动 +2. 股票详情页主图和副图 +3. 预警页筛选与详情联动 + diff --git a/docs/lhbfx.txt b/docs/lhbfx.txt new file mode 100644 index 0000000..ee81359 --- /dev/null +++ b/docs/lhbfx.txt @@ -0,0 +1,808 @@ +我想开发一个通过龙虎榜,然后找到有章盟主参与的票,然后监控起来 一旦满足条件 我就可以参与进去和游资一起赚钱,这个思路是否可以 你帮我评估一下 + +这个思路在量化交易和超短线博弈中非常经典,但也存在极高的“降维打击”风险。作为一个正在开发多代理(Multi-agent)系统的开发者,你的切入点很敏锐,但要真正落地并获利,需要处理好逻辑闭环和博弈滞后性的问题。 + +以下是对你这个思路的详细评估和建议: + +1. 核心逻辑评估:可行性 vs. 陷阱 +可行性: 龙虎榜是 A 股市场少数透明的“资金明牌”,章盟主(常驻席位如国泰君安上海江苏路等)属于顶级游资,其逻辑通常是大格局、锁仓、偏好中大型趋势票。通过 Python 爬取东方财富或同花顺的龙虎榜数据,确实可以建立监控池。 + +最大的陷阱: 滞后性。龙虎榜是收盘后才公布的数据,你看到的“章盟主买入”是已经发生的行为。 + +Day 0: 章盟主买入,股价大涨或涨停。 + +收盘后: 龙虎榜公布,你发现了信号。 + +Day 1: 市场一致预期形成,该票大概率高开。此时你参与,买入成本可能比游资高 5%-10%。 + +2. 技术实现的建议 (基于你现有的 Cursor/Python 背景) +既然你正在开发多代理分析系统,可以将此模块作为一个特定的 "Super Player Tracker" 插件: + +数据层 (Data Source): + +利用 WindPy 或开源的 AkShare 获取每日龙虎榜(Billing Board)数据。 + +建立席位数据库:章盟主不止一个席位(如:国泰君安上海江苏路、中信证券杭州延安路、华泰证券上海武定路等),需要维护一个动态关联列表。 + +监控层 (Monitoring): + +不要只看“买入”,要看**“协同效应”**。如果章盟主买入的同时,还有其他知名游资(如小鳄鱼、呼家楼)或者机构席位大笔买入,该票的胜率会指数级上升。 + +多代理协同: 你的 Agent A(新闻评分)此时至关重要。游资从不无缘无故买入,必然伴随重磅题材。如果龙虎榜显示买入,且 Agent A 给出的题材评分极高,这才是“确定性”信号。 + +执行条件 (Trigger): + +条件 A (二波逻辑): 游资进场后,股价回踩重要均线(如 5 日线)或 Fibonacci 0.382 位但不破位。 + +条件 B (分时共振): 次日开盘后的前 15 分钟,量价配合是否显示主力在持续锁仓或二次拉升。 + +3. 潜在风险与挑战 +假席位/对倒: 现在的市场环境复杂,有些资金会利用席位名声进行溢价出货。 + +现金流压力: 游资票波动极大(往往是 +10% 到 -10% 的瞬间转换)。你提到的现金流问题在超短线博弈中是致命伤,因为这种策略对止损的要求极高,一旦被套,容易产生归零心态。 + +风格演变: 章盟主近年来也开始参与一些趋势大票(如赛道股、AI 算力),操作周期拉长。如果你的监控逻辑还停留在“打板”,可能会产生错位。 + +4. 评估结论与操作建议 +结论:思路可行,但不能作为单一决策标准,必须配合“题材强度”和“技术面共振”。 + +建议下一步: + +回测: 统计过去半年内,章盟主上榜后,次日、三日、五日的表现,计算胜率。 + +集成: 将龙虎榜监控逻辑集成到你正在开发的 Multi-agent 系统中,作为 Agent B(技术推演)的一个高权重因子,而不是唯一的指挥官。 + +风控: 鉴于你目前的现金流压力,严禁盲目实盘这种高波动策略。建议先在系统中跑模拟盘,重点观察你的 "Death Hard Support"(死亡硬支撑) 逻辑在游资票中是否依然有效。 + +游资是森林里的狮子,我们可以跟着狮子捡肉吃,但一定要在狮子还没吃饱离开之前(也就是散户一致性预期达到顶点前)先撤。 + +你打算把这个监控逻辑整合进你目前那个多代理系统里吗?我们可以聊聊具体的 Python 实现细节。 + +对,我只是要记录他买入卖出的总计,然后跟踪起来,章盟主进来肯定是有动作的,一旦股价回调,就可以小仓位进行博弈试错,你看看是否可以,我对预期不高,只想博弈很小的仓位 + +这个思路在逻辑上是闭环的,而且非常符合“跟随顶级猎手”的策略。章盟主(章建平)的操作风格与许多一日游资不同,他更偏向于大题材、大格局、趋势性博弈。他进场后,往往会吸引大量跟风盘,导致短期情绪过热,而你提到的“回调博弈”恰恰是规避情绪溢价、寻找安全边际的聪明做法。 + +既然你只想用小仓位博弈,我们可以把这个逻辑细化为一套**“游资足迹+技术面共振”**的可执行模型: + +1. 监控目标:章盟主“明牌”席位 +要监控他,首先要锁定他的常用席位(这些通常会出现在龙虎榜): + +核心席位: 国泰君安上海江苏路、海通证券上海江苏路。 + +关联/演变席位: 国泰海通证券上海海阳西路、中信证券杭州延安路。 + +动作识别: 重点关注他首日净买入金额超过 5000 万甚至亿元以上的票,这类票通常具备板块龙头的潜质。 + +2. 博弈逻辑:利用“大佬锁仓”与“情绪分歧” +章盟主的风格经常是**“上涨中继买入 -> 锁仓/加仓 -> 高位分批卖出”**。 + +为什么选回调: 顶级游资买入后的次日,往往会因为一致性预期太强而高开。如果你次日追入,成本极高。 + +入场点(你的思路): 1. 缩量回调: 龙虎榜显示他大举买入且没有明显卖出(锁仓),随后 1-3 天股价出现缩量回踩。 +2. 硬支撑位: 结合你关注的 Fibonacci 0.382 或 0.5 位,或者是 10 日均线。如果股价在这些位置企稳,就是你说的“试错点”。 + +3. 给你的 Multi-agent 系统增加“补丁”建议 +既然你手里有开发中的多代理系统,可以这样设计监控逻辑: + +Agent C (LHB Tracker): 每日 16:30 自动爬取龙虎榜,识别章盟主席位。 + +Agent D (Trend Scrutinizer): * 计算偏离度: 计算当前股价离章盟主买入均价的距离。 + +压力测试: 监控回调时的成交量。如果回调是放量的,说明“大佬”可能在对倒或者撤退,此时不参与。 + +预警推送: 当“章盟主票”触及你的 "Death Hard Support" 且成交量萎缩至前一日 1/2 以下时,系统推送“试错建议”。 + +4. 风险控制(针对你提到的现金流情况) +因为你对手上现金流有顾虑,这个策略有三个必须遵守的底线: + +不参与“一日游”票: 如果该票没有大题材支持(你的 Agent A 评分低),即使章盟主进了也不要跟,容易被砸。 + +严格止损: 游资票一旦破位(比如跌破 20 日线或 0.618 位),说明逻辑走坏,必须果断切断,不能产生幻想。 + +仓位分散: 既然是试错,单笔仓位建议控制在总资金的 5%-10% 以内,防止单点爆雷。 + +评估结论: +你的思路非常适合作为你多代理系统的**“高价值信号模块”。它利用了信息的不对称(龙虎榜)和情绪的周期(回调)**。 + +建议下一步: 你可以先让系统跑一组“影子测试”:记录下最近一个月章盟主上榜的所有票,看看它们在回踩 0.382 或 0.5 位置后的反弹概率。如果有数据支撑,你的博弈底气会足很多。 + +你现在龙虎榜的数据源是用 WindPy 还是 AkShare?如果是 AkShare,我可以帮你写一段提取特定席位的代码逻辑。 + +当前的游资以及他的席位你帮我列清楚,我重点关注章盟主、欢乐海岸的,炒股养家的,别的游资先不做 + +要开发监控系统,席位(Seat IDs)的准确性是第一步。游资的席位会随时间演变,有些老席位会逐渐弃用,而新席位会根据其大本营搬迁而产生。 + +根据 2026 年最新的市场数据和龙虎榜特征,为你整理了这三位顶级游资的核心“据点”: + +1. 章盟主(章建平):大格局、趋势中军推土机 +章盟主是殿堂级游资,资金量在 50 亿级以上,他的操作特点是偏好大容量票(如 AI 算力、白马科技),喜欢在大题材中做波段锁仓。 + +核心席位(必监控): + +国泰君安上海江苏路(绝对的老巢,也是 A 股最著名的席位之一) + +中信证券杭州延安路(由于章盟主本身是杭州人,这是其核心重仓席位) + +国泰君安上海海阳西路(近年活跃度极高的新主战场) + +中信证券杭州富春路 + +辅助席位: + +浙商证券杭州五星路 + +海通证券上海建国西路 + +2. 炒股养家(养家老师):一字板鼻祖、情绪周期大师 +养家老师的核心逻辑是**“情绪周期”和“通道优势”**。他非常擅长在题材爆发初期通过一字板或强力封板确立地位,是全市场对“人心”把握最准的资金。 + +核心席位(必监控): + +华鑫证券上海宛平南路(养家最著名的席位,通常代表他的核心意志) + +华鑫证券上海茅台路 + +华鑫证券上海分公司(量化与手工混杂,但养家经常出没) + +关联席位: + +华鑫证券宁波分公司 + +华鑫证券西安分公司 + +3. 欢乐海岸(大格局、妖股制造者) +欢乐海岸近年来虽然不如 2018-2020 年那样频繁出现,但只要出动,目标通常都是**“跨年度大妖股”**。他的逻辑是:不吃独食,锁仓直到天荒地老,给全市场资金留出获利空间。 + +核心席位(必监控): + +中信证券深圳总部(由于深圳总部席位较大,需配合成交金额和逻辑判断) + +中信证券深圳后海 + +广发证券深圳光彩路 + +特征识别: + +欢乐海岸的操作极少是一日游。如果你在龙虎榜看到他买入,且次日、后日即便大跌他也没有卖出记录,这就是典型的“欢乐海岸锁仓”。 + +⚠️ 开发监控系统的技术建议: +席位权重(Weighting): +在你的多代理系统中,给这三个人的席位分配 Highest Priority (最高权重)。例如:章盟主 + 养家老师同时出现在一只票上,该票的“博弈分值”应直接翻倍。 + +过滤干扰: +中信证券深圳总部、华鑫上海分公司这类席位有时会有其他杂牌游资或量化资金。你需要增加一个金额阈值:比如,章盟主在江苏路买入金额必须 > 3000 万,系统才触发监控。 + +跟踪“总计”逻辑: +你提到的“记录买入卖出总计”非常关键。你需要建立一个 JSON 或 Database 结构: + +Python +{ + "stock_code": "000688", + "trader": "章盟主", + "net_position": 1.2, # 累计持仓1.2亿 + "last_action_date": "2026-04-16", + "status": "Holding" # 锁仓中 +} +关注“回调”: +结合你之前的 Fibonacci 0.382/0.5 逻辑,当这些席位锁仓(没在龙虎榜看到卖出),而股价缩量跌到 0.382 支撑时,这才是你“小仓位博弈”的黄金点。 + +你现在的系统是准备用 Python 定时任务(Crontab)每天收盘后跑一次数据清洗吗? + +不用金额大于3000万,我是做的一个监控,我要知道游资到底操作了那些票,以及他们的管理过程,一旦符合我的要求,我就可以及时进入 + +明白了,你的核心需求是**“全量记录、深度跟踪、逻辑验证”。既然不做金额筛选,那么重点就在于席位识别的广度和管理过程的颗粒度**。 + +根据 2026 年最新的龙虎榜活跃度和席位异动,为你列出这三大家族最完整的监控清单。 + +1. 章盟主(章建平家族) +核心逻辑: 大格局、大题材中军、波段锁仓。他的进场通常预示着一个中线题材的爆发,他操作的票往往“回踩支撑位”后的第二波更有确定性。 + +一级核心席位(必看): + +国泰君安上海江苏路(灵魂席位,几乎只做大行情) + +国泰君安上海海阳西路(2025-2026 年最活跃的进攻席位) + +中信证券杭州延安路(老巢,资金量巨大) + +中信证券杭州富春路 + +二级协同席位(常用于侧翼助攻): + +海通证券上海建国西路 + +华泰证券浙江分公司 + +浙商证券杭州五星路 + +2. 炒股养家(养家老师) +核心逻辑: 情绪流鼻祖。他买入代表了“市场最高板”的认可或“新题材一字板”的确认。监控他的逻辑过程,重点在于看他是否在分歧日(炸板或大跌时)反向加仓。 + +一级核心席位(必看): + +华鑫证券上海宛平南路(养家本尊,象征意义极强) + +华鑫证券上海茅台路 + +华鑫证券上海分公司(量化与手工混杂,需配合题材判断) + +二级协同席位: + +华鑫证券宁波分公司 + +华鑫证券西安分公司 + +3. 欢乐海岸(深南哥/大格局资金) +核心逻辑: 妖股推手。他的管理过程非常单纯:连续买入 -> 暴力锁仓 -> 配合缩量涨停。一旦他撤退,基本就是行情终结。 + +一级核心席位(必看): + +中信证券深圳总部(由于该席位也有机构和其他游资,建议配合“单笔买入过亿”特征识别) + +中信证券深圳后海 + +二级协同席位: + +广发证券深圳光彩路 + +中国中金财富深圳深南大道 + +建议你的监控系统(Management Process)这样设计: +既然要看“管理过程”,你的数据库表结构或 Agent 记录需要涵盖以下几个关键动作点: + +进场日 (Entry): 记录上榜当天的买入净额和占比。 + +锁仓期 (Holding): 之后几天,股价震荡但龙虎榜(如有)未见其卖出。 + +加仓/减仓 (Re-balancing): 这是你最需要的“管理”逻辑。比如章盟主在回调到 0.382 位置时是否出现了补仓动作。 + +离场日 (Exit): 记录其大幅卖出的信号。 + +评估与试错建议: +跟踪过程比进入更重要: 你关注的是“回调进入”。如果章盟主买入后,股价回调到 Fibonacci 0.5 且养家老师还在锁仓,这种“多游资共振回调”就是你小仓位博弈的高胜率点。 + +现金流风控: 既然你想博弈小仓位,建议设置一个**“时空约束”**。例如:章盟主买入后 5 天内有效,超过 5 天若还没启动,说明该管理过程失败,及时放弃。 + +你现在的多代理系统,是否已经能实现对龙虎榜数据的自动解析并归档到特定游资名下了?如果需要,我可以帮你写一段根据上述席位名称进行分类标记的 Python 伪代码。 + +对,我要记录的就是买入以及卖出的净额,如果净额变大,我觉得就要重点关注了你说的,不同的游资到一起了 要重点展示 + +这个思路非常清晰,你关注的是**“筹码密集度”和“大佬合力”**。在龙虎榜博弈中,当多个顶级游资(尤其是你选的这三位风格互补的)同时出现在一只票上,且净额持续增加时,这只票就具备了成为“阶段妖股”或“趋势核心”的潜质。 + +为了让你的多代理系统更具实战价值,建议你在监控逻辑中加入**“合力指数”和“成本估算”**两个核心模块。 + +1. 核心席位监控清单(代码层面的映射) +你可以直接将以下字典用于你的 Python 逻辑中,进行归类统计: + +游资名称 监控席位关键字 操作风格特征 +章盟主 国泰君安上海江苏路、国泰君安上海海阳西路、中信证券杭州延安路、中信证券杭州富春路 大格局: 倾向于中军票,进场净额大,回调不轻易离场。 +炒股养家 华鑫证券上海宛平南路、华鑫证券上海茅台路、华鑫证券上海分公司 情绪标杆: 他的进场代表题材确立,他的净额增加代表信心增强。 +欢乐海岸 中信证券深圳总部、中信证券深圳后海、广发证券深圳光彩路 妖股信仰: 极少参与杂毛,只要净额大且锁仓,就是奔着翻倍去的。 +2. 重点展示:多游资“共振”模型 +当你的系统扫到数据时,应该触发一个**“高优预警”**,重点展示以下情况: + +双雄/三雄会: 比如“章盟主”买入 1 个亿,“炒股养家”入场 3000 万。这种“大体量+高辨识度”的组合,回踩时的承接力极强。 + +净额递增: 第一天上榜净买入 5000 万,第三天回踩时再次上榜且净买入增加到 8000 万。这说明大佬在“管理”过程中进行了加仓,这就是你博弈的**“安全垫”**。 + +买入占比: 如果前五大买入席位中,这三位占据了 30% 以上的成交量,说明筹码高度集中。 + +3. 给你的 Multi-agent 系统设计的逻辑流程 +既然你要记录管理过程,建议 Agent 按照以下逻辑运行: + +数据抓取: 每日 16:30 后,提取包含上述关键词的成交记录。 + +净额计算: * Net_Amount = Buy_Amount - Sell_Amount + +建立 Cumulative_Net(累计净额),如果该值持续变大,系统标记为 “大佬重仓”。 + +支撑位匹配: 自动计算该票的 Fibonacci 0.382 / 0.5 价格。 + +展示看板: * 红色预警: 席位合力 + 净额递增 + 价格回踩支撑区。 + +黄色监控: 单一席位锁仓 + 价格高位震荡。 + +4. 针对“现金流”的实战提醒 +在这种“小仓位博弈”中,你要利用好你的 "Death Hard Support"(硬支撑): + +博弈准则: 只有在大佬净额没变小(说明没跑),且股价回踩到你计算的硬支撑位(风险收益比最高)时,才动用那部分小仓位。 + +这样即使试错失败,因为是在支撑位进场,止损空间也非常小(通常 3%-5% 即可),不会对你的现金流造成伤筋动骨的影响。 + +你需要我帮你写一个简单的 Python 逻辑,用来判断当天龙虎榜里是否出现了这些席位并计算它们的合力净额吗? + +功能展示,你帮我设计一下,在游资、股票角度都要进行跟踪 + +为了实现你“通过大佬动向找回调博弈机会”的目标,你的功能展示需要分为**“游资管理沙盘”和“股票监控画像”**两个维度。 + +这种设计能让你一眼看出:谁在买、买了多少、现在是不是到了你说的 Fibonacci 支撑位。 + +一、 维度一:游资角度(大佬的“管理沙盘”) +这个维度的核心是跟踪大佬的筹码分布和动作连续性。 + +1. 核心看板:大佬持仓追踪 +累计净额曲线: 展示章盟主、养家等人在某只票上的累计投入。 + +管理动作标签: + +[建仓期]: 席位首次出现,净额 > 0。 + +[锁仓期]: 股价震荡,但龙虎榜未见卖出记录。 + +[加仓/倒手]: 累计净额持续变大,或在回调位出现对倒。 + +[离场警告]: 净额大幅减少。 + +2. “合力”雷达 +席位共振监控: 当章盟主(趋势)与炒股养家(情绪)同时出现在同一只票时,系统自动置顶。 + +协同分值: * 3星: 单一游资锁仓。 + +5星: 两大游资共进且净额递增。 + +二、 维度二:股票角度(博弈的“狙击准星”) +这个维度的核心是结合你的技术指标,寻找大佬进场后的“回踩试错点”。 + +1. 股票画像看板 +监控项 展示内容 你的策略触发 +大佬成本区 记录上榜当天的均价区间 股价回落至此区间视为安全区 +技术位置 Fibonacci 0.382 / 0.5 / 0.618 触发博弈: 价格触及支撑 + 大佬锁仓 +量能状态 当前成交量 vs 大佬进场日成交量 理想状态: 缩量回调(说明大佬没跑) +多Agent评分 新闻 Agent 评分 + 游资合力分 综合评分 > 80 开启小仓位提醒 +2. 动态分时追踪 +回踩预警线: 在 K 线图上自动标出大佬买入的“重仓位”和你计算的“死亡硬支撑”。 + +分时异常: 如果股价回踩支撑位时,盘口出现连续大单承接,系统推送:“支撑有效,大佬仍在,建议博弈。” + +三、 交互界面设计建议 (UI Mockup) +你可以要求你的 Multi-agent 系统生成如下结构的监控报告: + +【今日高价值信号:00XXXX 某某股票】 +游资动态: + +章盟主: 累计净买入 1.2亿 (今日加仓 3000万 🚩) + +炒股养家: 累计净买入 4500万 (持续锁仓 🔒) + +博弈评估: + +当前价: 15.20 元 + +硬支撑(Fib 0.382): 15.05 元 (距离 -1%) + +风险评估: 缩量回踩,大佬未见流出,满足小仓位博弈条件。 + +操作指引: + +建议试错位:15.05 - 15.15 元 + +止损位:14.80 元 (跌破 Fib 0.5) + +四、 针对你“小仓位、现金流”的特别设计 +增加一个 “胜率复盘模块”: + +逻辑: 记录每一笔基于“大佬锁仓+回踩”逻辑的模拟/实盘表现。 + +功能: 如果系统发现章盟主在某个板块(如半导体)的回调博弈胜率高达 80%,那么下次该板块再出现类似信号时,系统会给予更高的信心权重。 + +这样设计,你的监控就不再是杂乱的名单,而是一个具有**“实战指向性”**的策略终端。你觉得这个功能布局是否符合你的操作直觉? + +具体的页面设计你可以帮我设计一下,比如游资动态,可以展示某一个游资操作的所有的股票,包括板块、涨幅,净额等信息,我要直观的进行展示,方便我做统一的监控 + +为了满足你“直观、统一监控”的需求,我们需要将复杂的龙虎榜数据抽象成**“雷达式”**的看板。 + +设计核心是:左边看人(游资逻辑),右边看票(博弈机会)。 + +1. 游资全景动态墙 (The Whale Tracker) +这个页面让你以“游资”为第一视角,横向监控他们的兵力部署。 + +设计布局: +顶部切换卡: [章盟主] [炒股养家] [欢乐海岸] [多方合力(ALL)] + +核心统计栏: + +活跃度: 近 5 日上榜次数。 + +偏好板块: 自动统计其持仓最多的行业(如:半导体 60%、低空经济 40%)。 + +胜率预估: 该游资过往 30 天操作股票在 3 日内的平均涨幅。 + +直观数据表: +股票名称/代码 所属板块 累计净额 今日动作 盈亏估算 监控状态 +中科曙光 AI 算力 +2.1亿 锁仓 🔒 +8.5% 破位风险 +工业富联 工业互联 +8500万 加仓 ⬆️ -2.3% 回踩支撑(博弈点) +赛力斯 华为汽车 -1.2亿 减仓 ⬇️ +15% 逻辑终结 +2. 股票维度:个股“画像”监控 (Stock Profiling) +当你点开某只股票时,系统应展示该票被“管理”的过程。 + +可视化组件: +筹码成本线: 在 K 线图上直接标注游资大举买入当天的平均成本价格线。 + +管理进度条: + +[ 建仓 ] ----> [ 锁仓/洗盘 ] ----> [ 主升/加仓 ] ----> [ 派发 ] + +通过算法自动判断当前处于哪个阶段。 + +技术共振区: + +自动绘制 Fibonacci 支撑带。 + +当股价触及 0.382 线,且对应的游资净额依然为正(没跑),背景闪烁绿色信号(低吸机会)。 + +3. 多代理系统(Multi-Agent)集成功能 +既然你用 Cursor 和 Python 开发,建议页面增加以下两个“智能卡片”: + +Agent 深度点评: + +“Agent A 提示:该票对应的 AI 算力板块今日有重大利好,配合章盟主 1.5 亿锁仓,回调至 5 日线是极佳博弈点。” + +风险预警(针对现金流): + +“警报:当前回踩成交量过大,疑似大佬对倒出货,建议放弃此次博弈。” + +4. 统一监控主大屏 (Dashboard) +为了让你“直观展示”,建议主界面采用 “磁贴+红绿预警” 模式: + +红区(进攻): 多个大佬合力、净额持续变大、股价刚突破。 + +蓝区(机会): 大佬重仓锁仓、股价缩量回调至 Fibonacci 支撑位。(这是你的重点关注区) + +黑区(规避): 大佬集体撤退、净额由正转负。 + +技术实现提示 (Python/Streamlit 或 React): +数据刷新: 建议设置为 16:30 自动解析龙虎榜,20:00 自动更新 Agent 研报评分。 + +前端展示: 如果追求快速开发,可以使用 Streamlit(Python 库),它能非常快地把你的 Dataframe 变成这种直观的 Web 画布。 + +你觉得这个布局是否能让你在几分钟内就锁定明天需要“小仓位试错”的目标? 如果需要,我可以为你提供一个简单的 Streamlit 页面框架代码,帮你把这些字段搭起来。 + +你觉得我这种方式怎么样,我主要是要做一个超短线,而且极小仓位,今天进入,最快明天就出去的这种,最慢的话就比游资稍晚一点出去,你帮我评判一下,帮我整体的优化一下展示页面 + +这是一个典型的**“顶级资金流套利”策略。你的逻辑其实是在利用顶级游资的信用背书和护盘预期**来做极短线的博弈。 + +考虑到你目前有现金流压力且希望**超短线(T+1/T+2)**操作,这种“蹭车”策略是合理的,因为你不需要预测长期,只需要捕捉游资进场后的“情绪惯性”和“技术回抽”。 + +以下是对你策略的评判及**“实战型”监控页面**的深度优化设计: + +一、 策略评判:风险与机会并存 +优势: * 高确定性: 章盟主、养家这种级别的资金,进场必有回响,不会无缘无故买入。 + +极简止损: 这种票逻辑极其明确,一旦游资反手卖出或跌破支撑,立刻离场,绝不纠缠。 + +挑战(你需要解决的): * 通道速度: 既然是 T+1,次日的竞价表现至关重要。 + +溢价陷阱: 很多票龙虎榜公布后次日高开太多,如果盲目切入,容易吃“冲高回落”的亏。 + +二、 监控页面优化设计:直观、量化、动作导向 +为了让你一眼看出“谁在里面、我能不能进、什么时候跑”,建议页面按以下结构布局: + +1. 顶部:游资战况总览(Whale Pulse) +以“磁贴”形式横向排列,实时滚动你关注的三个大佬。 + +字段: 游资名 | 今日出击数 | 累计在场头寸 | 情绪温度(红/绿) + +优化: 点击头像,下方列表自动联动该游资的所有持仓。 + +2. 中间:多维监控大表(The Sniper Matrix) +这是你的核心决策区,将“游资”、“股票”、“技术面”三合一。 + +股票 核心游资(净额) 板块属性 涨幅 博弈评分 操作指令 (Action) +中科曙光 章盟主(+1.5亿) AI服务器 +3.5% ⭐⭐⭐⭐ 分歧低吸: 触及 0.382 支撑位 +华鑫股份 炒股养家(+4千万) 证券 +10% ⭐⭐⭐ 观察: 等待明日缩量回调 +赛力斯 欢乐海岸(锁仓) 华为汽车 -2.1% ⭐⭐⭐⭐⭐ 强力博弈: 缩量回踩,大佬未动 +视觉优化: * 净额: 红色代表净买入增加,绿色代表净卖出。 + +博弈评分: 由你的多代理系统根据(游资合力+题材强度+技术支撑)自动打分。 + +3. 右侧/弹窗:个股“生命周期”画像 +当你点击某只票,弹出精简窗口,只看三样东西: + +成本估算: 自动标注大佬进场均价。 + +Fibonacci 动态线: 实时显示当前价离 0.382 和 0.5 位的距离(百分比)。 + +离场信号: 监控这三位大佬的席位是否出现在“卖出榜”。 + +三、 针对“超短线”的逻辑优化 +为了实现你“今日进、明后日出”的极速频率,建议在页面中增加两个**“情绪触点”**: + +竞价强弱(开盘前必看): + +如果大佬进场的票,次日竞价缩量小幅高开(1%-3%),这是最理想的博弈点。 + +如果大幅低开(-5%以下),说明大佬被埋或弃庄,直接放弃。 + +“跟随者”撤退预警: + +既然你最慢要比游资晚一点出,系统需要实时监控龙虎榜的卖出动作。一旦席位出现在卖五,系统弹窗:“领头羊已撤,建议执行退出。” + +四、 核心代码逻辑参考 (Python/Streamlit 伪代码) +Python +# 游资动态展示逻辑 +def display_whale_dynamic(whale_name, data): + st.subheader(f"🔍 {whale_name} 操盘动态") + # 过滤出该游资操作的票 + df = data[data['shixi'] == whale_name] + # 展示核心看板 + col1, col2, col3 = st.columns(3) + col1.metric("今日买入总计", f"{df['buy'].sum()}万") + col2.metric("重仓板块", df['sector'].mode()[0]) + col3.metric("平均涨幅", f"{df['pct_change'].mean()}%") + + # 渲染监控表格 + st.table(df[['code', 'name', 'net_amount', 'fib_support', 'action_advice']]) +总结建议: +直观化: 别看长文本,用颜色(红/绿/灰)和进度条来展示管理过程。 + +自动化: 让 Agent A 每天分析完新闻后,直接在表格里给这只票贴标签(如“国产替代”、“算力龙头”)。 + +克制: 既然现金流有问题,系统要设置**“每日最多试错数”**(例如每天只博弈 1 只最强的),防止摊子铺太大。 + +这个优化后的展示设计,能让你像看“仪表盘”一样监控大佬的每一个动作。你觉得这个方案在视觉上是否足够“直观”? + +卖出的话 其实我有一套逻辑,比如跌破5日线我就走,我主要关心的是买入的逻辑 + +既然你有一套成熟的“跌破 5 日线就走”的离场逻辑,那我们现在就把所有的精力都聚焦在**“买入逻辑”**的量化和展示上。 + +超短线(T+1/T+2)博弈的精髓在于:捕捉顶级资金进场后的“惯性溢价”和“二次接力”。 以下是为你量身定制的**“超短线买入决策看板”**设计,核心目标是让你在收盘后 5 分钟内完成第二天的“猎物”锁定。 + +一、 核心买入逻辑:三个“必须满足” +你的监控页面应该基于以下三个维度进行自动打分: + +身份纯度(Who): 必须是你指定的“章盟主”、“炒股养家”或“欢乐海岸”。 + +管理净额(How Much): 累计净额必须是增加的,或者首日买入占比该股流通盘达到一定比例(比如 > 1%)。 + +技术契合(Where): 股价必须处于“非透支状态”。如果大佬进场当天已经三连板,次日就不符合你的“回调/博弈”逻辑。 + +二、 页面展示设计:买入视角优化 +为了直观,我们将页面分为**“猎物池”和“狙击位”**。 + +1. 猎物池:游资动态墙 (Whale Hunting Wall) +展示过去 3 日内大佬动过的所有票,按**“合力强度”**排序。 + +股票 主导游资 累计净额 题材 Agent 评分 状态 买入逻辑匹配 +中科曙光 章盟主+养家 1.8亿 🔥 95 (AI算力) 锁仓 [ 重点关注 ] 缩量回踩中 +赛力斯 章盟主 5000万 ⚡ 80 (华为汽车) 刚进场 [ 观察 ] 偏离成本区较远 +万丰奥威 欢乐海岸 1.2亿 🌪️ 90 (低空经济) 锁仓 [ 极佳 ] 已回踩 5 日线 +直观设计: 累计净额用进度条表示,如果今天净额比昨天多,进度条变红且闪烁。 + +2. 狙击位:买入执行看板 (Entry Execution) +当你点击某个票,右侧弹出该票的**“博弈参数”**。 + +成本安全垫: 计算游资的预估持仓成本(龙虎榜买入均价)。如果当前价离成本价 < 5%,说明风险极低。 + +Fibonacci 捕获: + +0.382 线 (第一买点): 强力题材的回调位。 + +0.5 线 (最终防御): 也就是你说的博弈底线。 + +竞价预警(次日 9:15-9:25): + +强信号: 竞价小幅高开(+1% ~ +3%),成交量温和。 + +弱信号: 竞价直接跌停或大幅低开(-5%),说明大佬被埋,逻辑失效。 + +三、 功能增强:针对超短线的“动作驱动”设计 +既然你追求快进快出,页面需要增加这两个自动化功能: + +1. “二波”机会提醒 (The Second Wave) +逻辑: 大佬进场后,股价震荡 2-3 天但没有出现在卖出榜(锁仓),且股价跌到 5 日线附近。 + +展示: 这种票在列表里会标一个 “⭐二波狙击” 的金标。 + +2. 现金流防护门 (Cashflow Guard) +逻辑: 鉴于你手上的现金流问题,系统会根据换手率进行过滤。 + +展示: 换手率超过 30% 的“死亡换手”票,系统会标灰,建议你不参与,因为超短线波动太大,容易导致回撤,影响你的周转。 + +四、 总结你的“买入三板斧”看板 +看人: 只要章、家、海这三个,其他的不用看。 + +看钱: 净额变大是核心,说明大佬在“管理”,在引导行情。 + +看位: 回调到 5 日线或 Fib 位。 + +优化后的页面操作流: + +16:35: 刷新页面,看哪个大佬又进场了新票。 + +16:40: 看哪个大佬在老票里加仓了(净额变大)。 + +20:00: 结合 Agent A 的新闻分,选出最强的 3 只。 + +次日 9:20: 看这 3 只的竞价,选出开得最平稳、最符合你“回调试错”要求的那一只。 + +你现在的 Python 爬虫已经能区分这些特定的席位并计算净额了吗?如果需要,我们可以讨论如何写那个“累计净额”的逻辑,确保它能跨天跟踪。 + +我需要获取近2026年的历史数据来做,我要准确的看到一个票在游资进出的整体的操作过程以及股价走势情况, 以及系统给出当时的建议总结 + +这正是提升系统“实战感”的关键——从“数据监控”转向“逻辑复盘”。要实现精准的历史回测和过程复盘,你需要构建一个**“时间轴动态还原”**页面。 + +以下是我为你设计的**“2026游资实战案例复盘系统”**功能模块及展示逻辑: + +1. 核心看板设计:个股操盘生命周期(Full Lifecycle View) +这个页面不再是简单的表格,而是一个**“走势图 + 资金流”**的合体,让你直观看到游资在股价波段中的每一个动作。 + +可视化 K 线图: + +买入点标记: 在 K 线图下方自动用红色箭头标出章盟主、养家等人的上榜买入日。 + +卖出点标记: 用蓝色箭头标出上榜卖出日。 + +持仓成本区: 自动绘出一块半透明的色带,代表大佬的预估持仓成本区间(基于龙虎榜均价)。 + +资金管理副图(柱状图): + +展示**“累计净额”**的变化。如果柱子持续变高,说明游资在“锁仓加仓”;如果柱子骤缩,说明大佬正在大规模撤退。 + +2. 详细过程记录(Historical Action Log) +在 K 线图下方,系统会自动生成一份**“管理日记”**,帮助你理解大佬的操作逻辑。 + +日期 关键动作 净额变化 股价表现 系统当时建议 (AI Insight) +2026-03-12 首次建仓 +8500万 缩量首板 买入逻辑匹配: 大佬首日入场,题材 Agent A 评分 92,建议次日回踩博弈。 +2026-03-15 震荡锁仓 +500万 回调 2.5% 博弈状态: 缩量回踩 Fibonacci 0.382,大佬未走,属于极佳“小仓位试错点”。 +2026-03-18 二次加仓 +1.2亿 放量反包 状态更新: 逻辑得到市场验证,建议上移止损位至 5 日线。 +2026-03-22 大规模撤退 -1.9亿 冲高回落 撤退预警: 核心席位大笔卖出,逻辑终结,必须离场。 +3. 系统建议总结模块(Post-Match Summary) +当一段行情结束后,系统会自动生成一个**“博弈总结报告”**,用于优化你的超短线模型: + +博弈复盘: 该股从大佬进场到出场,最大回撤是多少?是否触及了你的 5 日线止损逻辑? + +支撑有效性验证: 统计这只票在 0.382 和 0.5 位的支撑表现。 + +建议总结: + +“复盘总结:该票属于‘多游资共振型’,在回踩 0.382 位后有明显的反弹逻辑,适合 T+1 套利。下次遇到类似‘低空经济’题材且章盟主锁仓的票,博弈胜率预估为 85%。” + +4. 获取 2026 历史数据的实现建议 +由于你目前在开发多代理系统,你可以增加一个**“回测 Agent”**: + +数据抓取: 使用 Python 脚本从 Wind 或 AkShare 定期抓取 2026 年所有的龙虎榜原始数据。 + +席位清洗: 运行你之前设计的“席位过滤逻辑”,只提取出那三大家族的数据。 + +自动匹配: 将席位数据与当天的 K 线 OHLC 数据、换手率、成交量进行关联。 + +生成快照: 每天收盘后,将分析结果存入你的数据库(如 SQLite 或 PostgreSQL),这样你随时可以调出“2026 年 X 月 X 日”的快照进行学习。 + +针对你“今日进、明日出”的优化评价: +这种“历史还原”对你极其重要。通过看 2026 年前几个月的历史数据,你会发现一个规律:顶级游资买入后的第一波回调,往往是 T+1 成功率最高的时候。 + +你的系统如果能展示出这些“必胜模型”的共性,那么在实盘中,你甚至不需要思考,只需要在价格触及支撑位时机械式地执行那个“小仓位博弈”即可。 + +这种“复盘+建议总结”的页面设计,是否能满足你对“逻辑还原”的精准要求? + +我需要看到游资买入卖出股价走势的关系 + +要直观展示游资进出动作与股价走势的因果关系,设计上必须采用“时间轴对齐”的逻辑。以下我为你构思了一个专门用于超短线复盘的**“操盘轨迹还原看板”**,它能帮你精准捕捉大佬建仓、锁仓、加仓以及撤退的每一个关键时刻。 + +1. 核心视觉设计:K线图与资金流的“合体” +在这个页面中,股价走势是主轴,游资动作是注脚。 + +主图:K线动态标记(Price Action with Signpost) + +进场标记: 在大佬上榜买入的K线下方,标注红色实心圆点,并悬浮显示席位(如:章盟主/江苏路)及净额。 + +卖出标记: 在上榜卖出的K线上方,标注蓝色实心圆点,显示卖出金额。 + +成本安全垫: 系统自动计算该游资当天的买入均价,并在K线上方画出一条水平虚线。只要股价在这个虚线附近回踩,就是你的博弈区。 + +副图:累计净额柱状图(Cumulative Position Logic) + +这个图能让你看到大佬的**“锁仓意志”**。 + +柱子持续上升 = 大佬不断加仓。 + +柱子横盘(股价下跌)= 大佬在锁仓洗盘(这是你最想要的**“博弈入场点”**)。 + +柱子陡降 = 大佬跑了,你必须在明天开盘跟着跑。 + +2. 管理过程复盘表:逻辑还原 +在图形下方,你需要一个详细的**“动作流水账”**,来总结当时的建议: + +日期 股价走势 游资动作(章/家/海) 净额变化 系统当时建议 (实时推理) +D日 首板/强力大涨 章盟主买入1.2亿 +1.2亿 建仓确认: 顶级大佬背书,题材分值90+。建议: 明日若回踩0.382位可小仓位介入。 +D+1日 缩量调整-3% 龙虎榜无显示 1.2亿(持平) 缩量锁仓: 典型大佬锁仓洗盘。建议: 触及5日线,满足博弈试错条件,博弈T+1。 +D+2日 反包涨停 炒股养家入场4千万 +1.6亿 合力形成: 两位大佬共振,逻辑加强。建议: 锁仓待涨,止损线上移至成本价。 +D+5日 冲高回落-5% 章盟主卖出1.5亿 -1.5亿 逻辑终结: 核心席位撤退。建议: 跌破5日线,必须执行退出。 +3. 给你的系统增加“回测Agent” +为了获取2026年的准确历史数据并生成这种展示,你可以给你的多代理系统增加以下逻辑: + +数据清洗(Data Cleansing): 专门提取2026年龙虎榜中包含“江苏路”、“宛平南路”、“深圳总部”等关键词的记录。 + +轨迹重合(Tracking Overlay): 将这些记录与当天的 High/Low/Close/Volume 数据在时间轴上强制对齐。 + +情绪指标(Sentiment Score): Agent A 根据当时的历史新闻(如政策出台、行业大利好)为当天的动作贴上“逻辑标签”。 + +复盘总结: 自动计算如果你在“大佬锁仓+触及0.382位”时进入,持股1天的胜率是多少。 + +4. 针对你“今日进、明日出”的优化建议 +因为你做的是极短线,展示页面需要特别突出**“分时异动”**: + +开盘竞价分析: 历史数据中,大佬买入后的次日竞价,如果是**“平开高走”**且量能温和,胜率最高。 + +5日线压力测试: 在复盘时,系统要重点展示股价在5日线处的反复。通过2026年的数据,你会发现很多票都是在大佬锁仓时跌破5日线瞬间收回,这就是你“小仓位博弈”的核心利润点。 + +这种设计是否满足你对“直观监控”和“逻辑学习”的要求? 它可以让你像看录像回放一样,学习大佬是如何管理筹码的,进而复制他们的获利逻辑。 + +你根据我们聊天的内容生成一份最新的需求文档 + +这份需求文档旨在为你量身定制一套**“顶级游资踪迹监控与超短线博弈系统”**。它结合了你对特定游资(章盟主、炒股养家、欢乐海岸)的关注,以及基于技术面(Fibonacci、5日线)的回调博弈逻辑。 + +📜 需求规格说明书 (PRD):顶级游资共振监控系统 +1. 项目愿景 +开发一套自动化监控与回测系统,通过深度追踪 A 股顶级游资(章盟主、炒股养家、欢乐海岸)的龙虎榜动向,识别其筹码管理过程,并在股价回踩技术支撑位时提供超短线(T+1/T+2)小仓位博弈信号。 + +2. 核心监控目标 (The Big Three) +系统必须精准识别以下三大家族的席位: + +游资家族 核心席位关键字 操作风格 +章盟主 国泰君安上海江苏路、国泰君安上海海阳西路、中信证券杭州延安路/富春路 大题材中军、波段锁仓、资金量级大 +炒股养家 华鑫证券上海宛平南路、华鑫证券上海茅台路、华鑫证券上海分公司 情绪周期大师、题材确认者、一字板引领 +欢乐海岸 中信证券深圳总部、中信证券深圳后海、广发证券深圳光彩路 妖股制造者、暴力锁仓、格局极大 +3. 功能模块需求 +3.1 游资动态墙 (Whale Tracker) +全量记录: 记录目标游资在 2026 年每一笔龙虎榜买入/卖出净额。 + +管理过程可视化: 自动计算累计净额。若净额递增且未见卖出,标记为“锁仓管理中”。 + +共振看板: 当两个或以上目标游资同时出现在一只票上,系统需高亮展示“多方合力”信号。 + +3.2 股价与资金流关系展示 (Price-Capital Correlation) +轨迹对齐: 在 K 线图上自动标注: + +🔴 红色标记:游资上榜买入点(记录买入均价)。 + +🔵 蓝色标记:游资上榜卖出点。 + +管理重心线: 自动生成游资持仓成本区间虚线。 + +副图指标: 实时展示累计净额柱状图,直白观察“价跌量缩(大佬锁仓)”的背离机会。 + +3.3 自动化博弈逻辑 (Entry Logic) +买入点(Sniper Entry): + +目标游资累计净额为正(且最好在递增)。 + +股价缩量回调至 Fibonacci 0.382 / 0.5 支撑位。 + +股价触及或略微跌破 5 日均线 且未放量。 + +离场点(Exit Strategy): + +硬性标准: 收盘跌破 5 日线或回撤达到止损阈值。 + +信号标准: 龙虎榜显示目标游资主力席位大幅流出(净额骤降)。 + +3.4 2026 历史复盘与 Agent 评价 +回放功能: 支持调取 2026 年任意个股的历史区间,还原当时游资动作。 + +AI 总结: 系统自动总结当时环境下的博弈胜率。 + +示例:在该板块利好下,章盟主锁仓+回踩 0.382,历史回测 T+1 胜率 85%。 + +4. 页面设计要求 (UI/UX) +直观优先: 拒绝繁琐数据,多用红/绿颜色状态灯(如:大佬状态:🟢 锁仓 / 🔴 撤退)。 + +双维度切换: * 视角 A (游资控盘): 查看某游资当前管理的所有票、板块分布及盈亏。 + +视角 B (股票画像): 查看某票的所有参与大佬、成本区及当前博弈分值。 + +实时指令: 首页展示“今日博弈备选”,列出最符合回调试错要求的 3-5 只标的。 + +5. 性能与数据要求 +数据源: 整合 WindPy/AkShare,每日 16:30 自动完成龙虎榜数据清洗。 + +时效性: 盘后复盘数据必须在当日 17:00 前处理完毕,并推送 Agent 研报。 + +并发: 支持多代理(Agent A 题材分、Agent B 技术分)并行运算。 + +6. 特殊约束 (针对现金流风险) +极小仓位原则: 系统默认推荐仓位不得超过单笔 5%-10%。 + +换手率过滤: 自动过滤成交量过于散乱、换手率 > 30% 的非逻辑票。 + +这份文档现在可以作为你后续在 Cursor 中编写代码或训练 Agent 的核心蓝图。如果有细节需要补充(比如具体的 Fibonacci 参数调整),我们可以随时迭代。 \ No newline at end of file diff --git a/docs/ui-mockup.html b/docs/ui-mockup.html new file mode 100644 index 0000000..081b44c --- /dev/null +++ b/docs/ui-mockup.html @@ -0,0 +1,669 @@ + + + + + + 顶级游资监控系统 UI Mockup + + + +
+
+
+ +
+

顶级游资监控系统

+

盘后总控台 / 风险优先 / 游资视角 / 单票作战图

+
+
+
+
今日卖出预警
06
+
慢流出观察
11
+
多游资共振
04
+
可关注标的
05
+
+
+ +
+
+
+
01 Home Control
+

首页总控台

+
+
+ 盘后 16:42 已更新 + 龙虎榜采集正常 + 预警优先级:高 +
+
+ +
+
高风险卖出预警
6
核心席位出现净卖出
+
慢流出观察
11
买入后 5 日无强化动作
+
今日共振标的
4
双游资及以上同场
+
可关注标的
5
接近 5 日线 / Fib 支撑
+
+ +
+
+
+

今日高优先级预警

+ 先处理风险,再看机会 +
+
+
+
拓维信息002261 / AI算力
+
章盟主卖出
+
33.42
+
+6.38%
+
高风险
+
建议减仓
+
+
+
四川长虹600839 / 机器人
+
慢流出观察
+
12.67
+
-2.45%
+
重点预警
+
继续观察
+
+
+
常山北明000158 / 鸿蒙
+
共振失效
+
24.08
+
-3.17%
+
高风险
+
规避追高
+
+
+ +
+

今日可关注

+ 5 日线 / Fib 0.382 +
+
+
+
中科曙光603019 / 算力中军
+
章盟主
+
+2.3亿
+
-1.12%
+
接近 5 日线
+
锁仓
+
可关注
+
+
+
银之杰300085 / 金融科技
+
欢乐海岸
+
+8600万
+
+4.21%
+
Fib 0.5
+
共振
+
谨慎关注
+
+
+
+ +
+
+

三大游资动态

+ 点击进入详情 +
+
+
+
章盟主高权重
+
近期参与 14 只,当前 3 只重点观察,卖出预警 2 只,聚焦 AI 算力与大市值中军。
+
+
+
炒股养家情绪确认
+
近期参与 9 只,偏连板与情绪龙头,当前 1 只共振标的,追踪节奏快。
+
+
+
欢乐海岸高溢价
+
近期参与 6 只,高位强势股信号明显,一旦卖出需提高预警等级。
+
+
+ +
+

风险雷达

+ 实时视图 +
+
+ + + + +
+
+
+
+ +
+
+
+
02 Trader Detail
+

游资详情页 · 章盟主

+
+
+ 近期参与股票列表 + 板块分布 + 动作时间轴 +
+
+ +
+
+

章盟主 / 章建平

+

板块中军、权重大票、上亿级别资金体量。更偏趋势强化和大资金推动,不完全等同于情绪打板游资。

+
+ 国泰君安上海江苏路 + 国泰君安上海海阳西路 + 国泰君安宁波彩虹北路 + 中信证券杭州延安路 +
+
+
+
近期参与股票数
14
+
当前观察中
3
+
卖出预警
2
+
慢流出观察
4
+
+
+ +
+
+
+

近期参与股票列表

+ 默认按最近动作排序 +
+
+
+
中科曙光603019
+
AI算力
+
63.80
+
+3.24%
+
932亿
+
2026-04-10
+
+2.3亿
+
锁仓
+
+
+
拓维信息002261
+
鸿蒙算力
+
33.42
+
+6.38%
+
421亿
+
2026-04-16
+
-6800万
+
卖出预警
+
+
+
四川长虹600839
+
机器人
+
12.67
+
-2.45%
+
585亿
+
2026-04-11
+
+1.1亿
+
慢流出
+
+
+
工业富联601138
+
AI服务器
+
31.90
+
+1.78%
+
6328亿
+
2026-04-15
+
+4.7亿
+
共振
+
+
+
+ +
+

板块分布与时间轴

近 20 日
+
+
AI 算力5 只
累计净额 7.8 亿,当前 1 只卖出预警,2 只仍处于锁仓观察。
+
机器人3 只
累计净额 2.1 亿,当前 2 只进入慢流出观察。
+
最近动作7 日
04-16 拓维信息卖出;04-15 工业富联共振;04-11 四川长虹进入慢流出观察。
+
+
+
+
+ +
+
+
+
03 Stock Detail
+

股票详情页 · 中科曙光

+
+
+ 全部游资 + 仅看章盟主 + 仅看欢乐海岸 +
+
+ +
+
+
+
+

股票基础信息

+ 算力中军 +
+
+
+
中科曙光603019 / AI算力 / 信创
+
63.80
+
+3.24%
+
总市值 932亿
+
流通 927亿
+
锁仓
+
共振
+
+
+
+ +
+
+

K线走势 + 龙虎榜买卖点

+ K线 / 成本线 / 5 日线 / 游资操作叠加 +
+ + + + + + + + + + + 章盟主首轮买入区:累计净买入 1.2 亿 + + + 章盟主持续加仓与锁仓区:累计增加 1.1 亿 + + + 欢乐海岸减仓卖出区:净卖出 3100 万 + + + 首日买入区 / +1.2亿 + 持续加仓 / 锁仓区 / +1.1亿 + 减仓卖出区 / -3100万 + + 04-01 高 58.1 低 54.2 收 56.6 + 04-01 开 57.8 收 56.6 + + 04-02 高 60.4 低 55.6 收 59.2 + 04-02 开 56.9 收 59.2 + + 04-03 高 62.6 低 57.7 收 61.8 + 04-03 开 58.8 收 61.8 + + 04-04 高 63.4 低 59.6 收 60.7 + 04-04 开 62.3 收 60.7 + + 04-07 高 64.8 低 60.1 收 63.2 + 04-07 开 60.9 收 63.2 + + 04-08 高 65.4 低 61.7 收 64.0 + 04-08 开 62.1 收 64.0 + + 04-09 高 66.2 低 62.8 收 63.5 + 04-09 开 64.9 收 63.5 + + 04-10 高 67.8 低 63.8 收 66.9 + 04-10 开 64.2 收 66.9 + + 04-11 高 68.1 低 64.4 收 67.5 + 04-11 开 65.1 收 67.5 + + 04-14 高 67.9 低 64.9 收 66.0 + 04-14 开 67.2 收 66.0 + + 04-15 高 69.1 低 65.8 收 68.2 + 04-15 开 66.1 收 68.2 + + 04-16 高 69.4 低 66.2 收 67.4 + 04-16 开 68.7 收 67.4 + + 04-17 高 68.4 低 64.6 收 65.9 + 04-17 开 67.3 收 65.9 + + 04-18 高 67.1 低 63.9 收 64.5 + 04-18 开 66.3 收 64.5 + + 04-21 高 68.0 低 65.1 收 66.8 + 04-21 开 65.4 收 66.8 + + 04-22 高 68.7 低 65.9 收 67.9 + 04-22 开 66.2 收 67.9 + + + 游资预估成本线:64.8 + + + 5 日线:63.6 + + + 章盟主买入:净买入 +1.2 亿 + + 章盟主买入 +1.2亿 + + 章盟主加仓:净买入 +7800 万 + + 章盟主加仓 +7800万 + + 欢乐海岸卖出:净卖出 -3100 万 + + 欢乐海岸卖出 -3100万 + + 04-01 + 04-04 + 04-09 + 04-14 + 04-18 + 04-22 + +
+ 买入操作 + 加仓 / 锁仓操作 + 卖出操作 + 阴线 K + 阳线 K + 成本线 +
+
+ 图上不仅标单日信号,还用半透明操作区展示游资阶段动作:红色区代表首次集中买入,金色区代表持续加仓与锁仓,蓝色区代表减仓卖出阶段。悬停蜡烛、操作区、箭头时可查看价格与买卖金额说明。 +
+
+
+ +
+
+
+

预警判断结果

+ 重点观察 +
+
当前结论:继续观察,防慢流出
+
+ 章盟主累计净额仍为正,价格尚未跌破成本线和 5 日线,但买入后近 5 个交易日未出现明显强化动作,系统将该票判定为“重点观察”而非“直接参与”。 +
+
+ +
+

当前预警

+
卖出预警
+
慢流出观察观察中
+
破位状态未破位
+
建议动作继续观察
+
+
+

参与游资

+
章盟主累计 +2.3亿
+
炒股养家累计 +4200万
+
欢乐海岸累计 +3100万
+
+
+

财务摘要

+
PE / PB48.2 / 5.8
+
营收增速+17.6%
+
净利润增速+22.1%
+
ROE10.4%
+
资产负债率41.8%
+
+
+
+
+ +
+
+
+
04 Warning Center
+

预警与监控页

+
+
+ 高风险 + 重点预警 + 普通提醒 +
+
+ +
+
+
+

游资筛选

+
章盟主14
+
炒股养家9
+
欢乐海岸6
+
+
+

预警类型

+
卖出预警6
+
慢流出观察11
+
破位预警3
+
共振失效2
+
+
+

时间范围

+
今日22
+
近 3 日49
+
近 7 日87
+
+
+ +
+

预警列表

按等级排序
+
+
拓维信息 / 章盟主高风险卖出
+
江苏路席位净卖出 6800 万33.42 / +6.38%
+
+
+
四川长虹 / 章盟主慢流出观察
+
买入后 5 日未见强化动作12.67 / -2.45%
+
+
+
常山北明 / 欢乐海岸共振失效
+
高位强势股核心席位开始流出24.08 / -3.17%
+
+
+
银之杰 / 欢乐海岸破位提醒
+
价格下穿短期成本区18.23 / -1.88%
+
+
+ +
+

预警详情

拓维信息
+
+

触发原因

+

章盟主核心席位出现明确净卖出,累计净额由正向下降,同时价格处于高位波动阶段,预警等级提升为高风险。

+
+
+

最近 5 日动作摘要

+
D-5首次建仓
+
D-3加仓
+
D-1锁仓观察
+
D出现卖出
+
+
+

建议动作

+
当前判断风险优先
+
执行建议减仓 / 规避追高
+
备注等待重新站稳成本区
+
+
+
+
+ + +
+ + diff --git a/docs/技术文档.md b/docs/技术文档.md new file mode 100644 index 0000000..c8ce7b7 --- /dev/null +++ b/docs/技术文档.md @@ -0,0 +1,247 @@ +# lhbfx 技术文档 + +## 1. 技术栈 + +### 1.1 前端 + +- Vue 3 +- TypeScript +- Vite + +### 1.2 后端 + +- FastAPI +- PyMySQL +- PyYAML +- Requests / BeautifulSoup + +### 1.3 数据库 + +- MySQL + +## 2. 工程结构 + +```text +longhubang/ +├─ backend/ +│ ├─ config.example.yaml +│ ├─ pyproject.toml +│ ├─ scripts/ +│ └─ src/lhbfx/ +├─ frontend/ +│ ├─ package.json +│ ├─ vite.config.ts +│ └─ src/ +├─ docs/ +├─ start-dev.ps1 +├─ .gitignore +└─ README.md +``` + +## 3. 后端架构 + +### 3.1 入口 + +- `backend/scripts/run_api.py` +- `backend/src/lhbfx/app.py` + +`app.py` 提供 API 路由与前端静态文件挂载能力。 + +### 3.2 配置 + +- 运行时配置文件:`backend/config.yaml` +- 示例配置文件:`backend/config.example.yaml` + +为避免泄露本地敏感信息,实际 `config.yaml` 已加入 `.gitignore`。 + +### 3.3 数据层 + +数据库相关逻辑集中在: + +- `backend/src/lhbfx/db.py` +- `backend/src/lhbfx/schema.sql` +- `backend/src/lhbfx/queries.py` + +当前主要表: + +- `stocks` +- `lhb_overview` +- `lhb_detail_seats` +- `warning_events` +- `watchlist_entries` +- `traders` +- `trader_seats` + +### 3.4 数据处理管线 + +`backend/src/lhbfx/pipeline.py` 负责: + +- 龙虎榜日度导入 +- 席位匹配 +- 预警生成 +- 管线状态统计 + +配套脚本位于 `backend/scripts/`: + +- `init_db.py` +- `import_ths_daily.py` +- `import_ths_year.py` +- `generate_warnings.py` +- `rematch_traders.py` + +### 3.5 首页关键接口 + +#### `/api/actions` + +用途: + +- 为首页左侧关注池流水和右侧候选区提供原始操作数据 + +当前返回字段包括: + +- 股票代码、股票名称 +- 游资名称 +- 买入、卖出、净额 +- 当前价格、涨跌幅 +- 行业、市场、总市值、流通市值 +- 操作方向 + +#### `/api/watchlist` + +用途: + +- 保存和读取用户关注池 + +行为: + +- `GET /api/watchlist`:获取当前关注池 +- `POST /api/watchlist`:加入关注 +- `DELETE /api/watchlist/{stock_code}`:取消关注并删除记录 + +## 4. 前端架构 + +### 4.1 前端入口 + +- `frontend/src/main.ts` +- `frontend/src/App.vue` + +`App.vue` 负责页面切换、初始化数据和页面级事件绑定。 + +### 4.2 数据管理 + +`frontend/src/composables/useDashboardData.ts` 是首页和多页面共享数据的核心数据层,负责: + +- 获取摘要数据 +- 获取预警数据 +- 获取游资列表 +- 获取首页操作流水 +- 获取和维护关注池 + +### 4.3 页面组件 + +- `HomeControlScreen.vue`:首页总控台 +- `TraderDetailScreen.vue`:游资详情 +- `StockDetailScreen.vue`:个股详情 +- `WarningCenterScreen.vue`:预警中心 + +首页当前拆分重点组件: + +- `frontend/src/components/home/CandidateWatchCard.vue` + +### 4.4 首页展示逻辑 + +#### 左侧关注池与操作流水 + +在前端中按以下维度合并: + +- 股票 +- 日期 +- 游资 +- 标准化席位名 + +用于解决同一席位同时出现在买入榜与卖出榜时被拆成两条的问题。 + +#### 右侧待加入关注 + +候选列表卡片设计目标: + +- 固定高度 +- 同屏显示更多 +- 信息完整 +- 净额优先 + +## 5. 启动与运行 + +### 5.1 一键启动 + +使用根目录脚本: + +```powershell +powershell -ExecutionPolicy Bypass -File .\start-dev.ps1 +``` + +### 5.2 手动启动 + +后端: + +```powershell +python backend/scripts/run_api.py +``` + +前端: + +```powershell +cd frontend +npm install +npm run dev -- --host 127.0.0.1 --port 5173 +``` + +## 6. 仓库管理建议 + +### 6.1 已加入忽略项 + +- `frontend/node_modules/` +- `frontend/dist/` +- `logs/` +- `output/` +- `.playwright-cli/` +- `backend/config.yaml` + +### 6.2 行尾与文本规则 + +仓库已添加 `.gitattributes`: + +- 默认文本文件统一由 git 管理行尾 +- Windows 脚本保留 `crlf` + +## 7. 测试与验证建议 + +当前建议至少执行三类验证: + +### 7.1 静态验证 + +- `npm run build` +- `python -m compileall backend/src` + +### 7.2 接口验证 + +- `GET /api/actions` +- `GET /api/watchlist` +- `GET /api/stocks/{stock_code}` + +### 7.3 页面验证 + +建议引入或固化以下检查: + +- 首页强制刷新后布局是否完整 +- 关注池流水是否正确合并 +- 候选卡片是否在固定高度内完整显示 +- 关键页面至少保留一份 Playwright 截图回归 + +## 8. 当前技术债 + +目前仍需持续处理的问题: + +- 历史文档与部分配置存在中文乱码 +- 部分来源数据原始字段编码不稳定 +- 页面样式近期经历多轮快速调整,仍建议补视觉回归测试 diff --git a/docs/需求文档.md b/docs/需求文档.md new file mode 100644 index 0000000..bf3111a --- /dev/null +++ b/docs/需求文档.md @@ -0,0 +1,161 @@ +# lhbfx 需求文档 + +## 1. 项目目标 + +`lhbfx` 用于盘后跟踪顶级游资席位行为,帮助用户快速判断: + +- 今天目标游资买了什么、卖了什么 +- 哪些股票值得加入后续关注池 +- 哪些关注股已经出现卖出或慢流出风险 +- 某只股票在目标游资维度上的完整参与轨迹 + +系统定位是“盘后分析与预警辅助”,不是自动交易系统。 + +## 2. 当前功能范围 + +### 2.1 首页总控台 + +首页当前聚焦两个核心区域: + +- 关注池与操作流水 +- 待加入关注 + +首页支持: + +- 按日期范围筛选 +- 按游资筛选 +- 按方向筛选 +- 关注股票加入/移出数据库关注池 +- 已关注股票展示对应操作流水 +- 待关注候选展示重点净额、价格与板块信息 + +### 2.2 关注池 + +关注池规则: + +- 用户点击“关注”后,股票进入数据库关注池 +- 用户点击“取消关注”后,对应数据库记录删除 +- 首页左侧仅展示当前关注池中的股票 +- 关注池流水按“股票 + 日期 + 游资 + 席位”合并展示 + +### 2.3 待加入关注 + +候选区规则: + +- 候选来自当前筛选条件下、尚未进入关注池的操作记录 +- 候选卡片固定高度展示 +- 重点突出净额 +- 展示股票代码、日期、游资、板块标签、股价、涨跌幅、总市值 +- 买卖结构使用不同颜色表达 + +### 2.4 个股详情 + +个股详情页支持: + +- 基础资料展示 +- 日 K 线图 +- MA5 图例 +- 买卖点标注 +- 游资参与概览 +- 预警信息展示 + +### 2.5 游资详情 + +游资详情页支持: + +- 游资档案 +- 核心席位 +- 近期参与股票 +- 风格标签 + +### 2.6 预警中心 + +当前预警中心支持: + +- 卖出预警 +- 慢流出观察 +- 按股票、游资查看风险条目 + +## 3. 用户核心流程 + +### 3.1 盘后筛选 + +用户进入首页后: + +1. 先看关注池中今天是否有新增动作 +2. 再看待加入关注中的候选 +3. 对感兴趣的股票加入关注池 +4. 对已有关注股继续跟踪或取消关注 + +### 3.2 候选转关注 + +1. 在右侧候选区看到符合预期的股票 +2. 点击“关注” +3. 股票立即进入左侧关注池 +4. 后续首页统计和流水只围绕关注池展开 + +### 3.3 风险排查 + +1. 在首页左侧看到关注股的最新流水 +2. 若出现卖出或净流出,进入个股详情进一步确认 +3. 在预警中心查看系统风险提示 + +## 4. 数据需求 + +系统需要至少具备以下数据: + +- 龙虎榜概览数据 +- 龙虎榜席位明细 +- 股票基础资料 +- 股票日线行情 +- 预警事件 +- 用户关注池数据 + +## 5. 接口需求 + +当前已使用接口: + +- `GET /api/summary` +- `GET /api/pipeline/status` +- `GET /api/traders` +- `GET /api/traders/{trader_id}` +- `GET /api/stocks/{stock_code}` +- `GET /api/actions` +- `GET /api/watchlist` +- `POST /api/watchlist` +- `DELETE /api/watchlist/{stock_code}` +- `GET /api/warnings` + +其中: + +- `/api/actions` 为首页主要数据源 +- `/api/watchlist` 为关注池持久化入口 + +## 6. 当前确认的展示规则 + +### 6.1 左侧关注池流水 + +- 同股票、同日期、同游资、同席位合并展示 +- 同一席位若同时出现在买入榜和卖出榜,合并后展示为一条 +- 净额重新按买卖合计计算 + +### 6.2 右侧待加入关注 + +- 卡片固定高度 +- 去掉流通市值 +- 文案与数字缩小,保证同屏显示更多条目 +- 净额优先显示 + +## 7. 非功能要求 + +- 盘后页面打开速度要快 +- 关键页面在 1440px 及以上宽度下保持清晰稳定 +- 文档、配置与代码要支持团队继续接手迭代 + +## 8. 后续建议 + +后续可以继续迭代: + +- 统一修正当前部分历史数据的中文乱码问题 +- 对首页增加截图回归检查 +- 为关键页面补充 Playwright 可视化回归测试 diff --git a/docs/需求梳理-今日游资操作优先.md b/docs/需求梳理-今日游资操作优先.md new file mode 100644 index 0000000..593232f --- /dev/null +++ b/docs/需求梳理-今日游资操作优先.md @@ -0,0 +1,208 @@ +# 顶级游资监控系统需求梳理:今日游资操作优先 + +## 1. 核心目标 + +当前系统不再以“展示所有游资参与过的股票”为首页重点。 + +用户的真实诉求是:每天盘后快速知道目标游资今天新增做了什么,而不是被大量历史参与股票淹没。 + +首页必须优先回答三个问题: + +1. 今天目标游资新买了哪些票? +2. 今天目标游资卖出了哪些票? +3. 每一笔买入、卖出的金额是多少,涉及哪个游资、哪个席位、哪只股票? + +## 2. 首页信息架构 + +首页改为“今日游资操作台”,默认显示最新交易日。 + +首页首屏不再优先展示全部参与股票列表,也不再优先展示游资长期档案。 + +首页统计口径必须改为“只统计我的关注范围”,而不是统计系统里全部股票。 + +也就是说: + +1. 用户没有关注的股票,不进入首页核心统计。 +2. 用户关注之后,该股票才进入首页统计范围。 +3. 用户取消关注之后,该股票要立即从首页统计范围中移除。 +4. 首页指标永远服务于“我已经决定继续跟踪的票”,而不是“系统抓到的全部票”。 + +首页首屏推荐结构: + +1. 顶部状态区:最新交易日、我的关注股数量、今日关注股新增操作数、今日关注股买入金额、今日关注股卖出金额。 +2. 日期筛选区:默认最新交易日,可切换历史交易日。 +3. 今日操作流水:按游资、股票、买入/卖出方向展示。 +4. 可关注候选区:从今日买入、净买入、重点席位参与的股票中筛选。 +5. 我的关注列表入口:展示已关注股票数量和最近变化。 + +## 2.1 首页指标统计规则 + +首页指标不是全市场指标,也不是全样本指标,而是“关注池指标”。 + +首页指标的统计范围定义如下: + +1. 统计对象仅限我的关注列表中的股票。 +2. 如果某只股票今天有游资新增买入或卖出,且该股票在关注列表中,则计入首页指标。 +3. 如果某只股票今天有游资新增动作,但不在关注列表中,则只出现在候选区或操作流水中,不进入首页顶部核心指标。 +4. 如果一只股票被取消关注,则从下一次刷新开始不再参与首页任何关注统计。 + +首页建议统计项: + +1. 我的关注股数量。 +2. 今日有新增动作的关注股数量。 +3. 今日关注股中买入金额合计。 +4. 今日关注股中卖出金额合计。 +5. 今日关注股中净买入为正的股票数。 +6. 今日关注股中触发卖出预警的股票数。 + +## 3. 今日操作流水字段 + +今日操作流水必须展示以下字段: + +1. 交易日期。 +2. 游资名称。 +3. 股票名称。 +4. 股票代码。 +5. 操作方向:买入、卖出、净买入、净卖出。 +6. 买入金额。 +7. 卖出金额。 +8. 净额。 +9. 席位名称。 +10. 当前价格。 +11. 涨跌幅。 +12. 跳转入口:点击股票进入股票详情。 + +金额展示规则: + +1. 优先展示原始龙虎榜金额。 +2. 同时支持格式化为万元、亿元。 +3. 买入金额用红色或暖色强调。 +4. 卖出金额用蓝色或冷色强调。 +5. 净卖出或卖出预警必须明显突出。 + +## 4. 时间筛选 + +用户可以按日期筛选游资操作。 + +默认行为: + +1. 页面打开时默认展示最新交易日。 +2. 最新交易日一般等同于“今天盘后已导入的数据”。 +3. 如果当天没有数据,则显示数据库中最新交易日,并明确展示日期。 + +筛选能力: + +1. 支持选择某一天。 +2. 支持查看该日所有目标游资的买入/卖出流水。 +3. 支持按游资名称过滤。 +4. 支持按操作方向过滤:全部、买入、卖出、净买入、净卖出。 + +## 5. 关注列表 + +用户看到某只股票觉得有价值时,可以加入关注列表。 + +关注入口: + +1. 今日操作流水中的股票可以关注。 +2. 日期筛选后的历史操作股票也可以关注。 +3. 股票详情页也可以关注。 + +关注行为: + +1. 点击“关注”后股票进入“我的关注列表”。 +2. 关注列表中展示股票名称、代码、加入日期、来源交易日、来源游资、加入原因。 +3. 关注列表中的所有股票都可以点击跳转到股票详情。 +4. 可以取消关注。 +5. 关注列表是首页核心指标的唯一统计范围。 +6. 取消关注后,该股票立即不再参与首页统计。 + +关注列表第一阶段可以先存浏览器本地 localStorage。 + +后续如果需要多设备同步,再增加后端关注表。 + +## 6. 股票详情页 + +股票详情页保留。 + +股票详情页用于回答: + +1. 这只股票近期龙虎榜走势如何? +2. 哪些目标游资参与过? +3. 哪天买入,哪天卖出? +4. 当前是否出现风险信号? +5. 是否值得继续关注? + +股票详情页必须从所有股票入口跳转进入,包括: + +1. 今日操作流水。 +2. 历史日期筛选结果。 +3. 我的关注列表。 +4. 预警中心。 +5. 游资详情页股票列表。 + +## 7. 游资详情页 + +游资详情页保留,但不再作为首页首要信息。 + +游资详情页用于查看某个游资的长期参与情况、席位信息、风格标签和近期参与股票。 + +已移除“动作时间线”模块。 + +原因: + +1. 动作时间线会把用户带回“看历史故事”的路径。 +2. 当前核心需求是“今天新发生了什么”。 +3. 操作流水应统一放到首页或“操作流水页”里按日期筛选,而不是散在游资详情页。 + +## 8. 预警中心 + +预警中心保留。 + +预警中心主要展示: + +1. 卖出预警。 +2. 慢流出观察。 +3. 高风险股票。 +4. 系统建议动作。 + +预警中心不是首页主入口,但高风险卖出信息需要在首页今日操作中明显提示。 + +## 9. 后端接口要求 + +需要提供一个面向首页的游资操作流水接口。 + +当前新增接口: + +`GET /api/actions` + +参数: + +1. `trade_date`:可选,不传时默认最新交易日。 +2. `trader_name`:可选,用于筛选某个游资。 +3. `limit`:可选,默认 200。 + +返回内容: + +1. 当前交易日期。 +2. 操作流水数组。 +3. 每条流水包含股票、游资、席位、买入金额、卖出金额、净额、当前价格和涨跌幅。 + +后续建议接口: + +1. `GET /api/trade-dates`:返回可选交易日列表。 +2. `GET /api/watchlist`:返回关注列表。 +3. `POST /api/watchlist`:添加关注。 +4. `DELETE /api/watchlist/{stock_code}`:取消关注。 + +## 10. 当前开发调整结论 + +下一阶段前端开发优先级: + +1. 首页改为“今日游资操作台”,并且首页指标只统计关注列表。 +2. 增加日期筛选。 +3. 增加操作方向筛选。 +4. 展示买入、卖出、净额。 +5. 增加关注按钮和本地关注列表。 +6. 首页顶部指标与关注列表联动,取消关注后自动剔除统计。 +7. 所有股票入口都能跳转到股票详情。 +8. 游资详情页去掉动作时间线。 diff --git a/docs/顶级游资监控系统需求文档.md b/docs/顶级游资监控系统需求文档.md new file mode 100644 index 0000000..d764d3f --- /dev/null +++ b/docs/顶级游资监控系统需求文档.md @@ -0,0 +1,735 @@ +# 顶级游资监控与超短线分析系统需求文档 + +## 1. 文档概述 + +### 1.1 文档目标 +本文档用于定义“顶级游资监控与超短线分析系统”的业务目标、功能范围、核心流程、数据结构、页面需求与验收标准,作为后续产品设计、数据建模、开发实现与 Agent 拆分的统一依据。 + +### 1.2 项目背景 +当前需求来源于对 A 股龙虎榜数据的持续跟踪,希望重点监控特定顶级游资的上榜行为,观察其建仓、锁仓、加仓、撤退的完整过程,并将这些动作与个股价格走势、成交量、换手率、技术支撑位结合起来,形成可回放、可分析、可提示的超短线辅助决策系统。 + +### 1.3 核心目标 +系统核心不是“自动下单”,而是围绕以下四件事提供支持: + +1. 全量记录目标游资在目标个股上的历史操作轨迹。 +2. 直观展示游资动作与股价走势之间的关系。 +3. 展示个股所属板块、市值、价格表现和财务概况。 +4. 在满足既定技术条件时,给出小仓位试错型的观察建议或提示。 + +### 1.4 目标用户 +目标用户为具备一定股票交易经验、重点关注龙虎榜与超短线交易机会、希望通过数据化监控而非纯主观判断进行辅助决策的个人交易者。 + +## 2. 产品定位 + +### 2.1 产品定义 +本系统是一套围绕龙虎榜数据、游资席位识别、个股走势还原、技术条件匹配、预警提示构建的分析型系统。 + +### 2.2 产品边界 +系统当前阶段聚焦“监控、预警、提示”,不包含: + +1. 实盘自动交易。 +2. 券商下单接口接入。 +3. 全市场所有游资全量覆盖。 + +当前仅优先覆盖三个核心目标游资家族: + +1. 章盟主 +2. 炒股养家 +3. 欢乐海岸 + +## 3. 业务目标与成功标准 + +### 3.1 业务目标 +1. 建立 2026 年重点游资龙虎榜操作数据库。 +2. 对单只股票实现“游资进出 + 股价走势 + 系统建议”的时间轴还原。 +3. 识别并标记“锁仓”“加仓”“撤退”“多游资共振”等关键状态。 +4. 在盘后自动筛选出符合回调试错逻辑的候选标的。 + +### 3.2 成功标准 +1. 能按日期、股票、游资、席位检索到完整历史记录。 +2. 能在单票页面看到买卖点与 K 线走势对齐显示。 +3. 能计算并展示累计净额、预估成本区、阶段性状态。 +4. 能生成“今日备选列表”与风险提示。 +5. 盘后处理完成时间不晚于当日 17:00。 + +## 4. 重点监控对象 + +### 4.1 目标游资 + +#### 4.1.1 章盟主 +补充说明: + +1. 章盟主对应章建平,属于老牌顶级游资,资金体量大,市场辨识度高。 +2. 其交易风格更偏大题材、中军票、趋势强化与大资金推动,不完全等同于纯情绪打板选手。 +3. 在监控逻辑上,应重点关注其对大市值核心票、趋势票、板块中军票的参与,而不仅仅是连板小票。 +4. 文档补充资料显示,其当前操作手法更偏机构化和趋势化,适合在系统中赋予更高权重。 +5. 典型标签可归纳为“板块中军、权重大票、上亿级别资金体量”。 + +重点席位关键字: + +1. 国泰君安上海江苏路 +2. 国泰君安上海海阳西路 +3. 中信证券杭州延安路 +4. 中信证券杭州富春路 +5. 国泰君安宁波广福街 +6. 中信证券杭州四季路 +7. 财通证券杭州解放东路 +8. 国泰君安宁波彩虹北路 +9. 中信证券杭州宜春路 + +典型风格: + +1. 大题材中军 +2. 波段锁仓 +3. 资金体量大 +4. 偏趋势与中军票 +5. 对权重大票和趋势票的推动作用较强 +6. 常与市场高辨识度核心票绑定 + +席位分层建议: + +1. 核心高权重席位:国泰君安上海江苏路。 +2. 核心常用席位:国泰君安宁波彩虹北路、中信证券杭州延安路。 +3. 近年新增活跃席位:国泰君安上海海阳西路、国泰君安宁波广福街、中信证券杭州富春路。 +4. 历史席位:国信证券杭州保俶路、新疆证券杭州庆春路、中信金通证券杭州延安路、义乌化工路。 + +席位说明补充: + +1. 国泰君安上海江苏路长期活跃于龙虎榜,以波段参与大市值股票为主,历史辨识度最高。 +2. 国泰君安宁波彩虹北路多参与新股、强势股及蓝筹股主升段操作,风格相对稳健。 +3. 中信证券杭州延安路属于早期重要席位,具有较强历史代表性。 +4. 国泰君安上海海阳西路为近年新增主力席位之一,可重点关注 AI 算力、机器人等大题材方向。 +5. 国泰君安宁波广福街、中信证券杭州富春路可作为近年活跃补充席位,在系统中建议纳入二级重点监控。 + +#### 4.1.2 炒股养家 +重点席位关键字: + +1. 华鑫证券上海宛平南路 +2. 华鑫证券上海茅台路 +3. 华鑫证券上海分公司 + +典型风格: + +1. 情绪周期驱动 +2. 题材确认 +3. 高辨识度龙头参与度高 + +#### 4.1.3 欢乐海岸 +补充说明: + +1. 欢乐海岸属于高辨识度游资品牌,市场号召力强,历史上在高位强势股、妖股、封板后锁仓风格中极具代表性。 +2. 一旦其明确出手,通常容易带来强烈溢价和市场追捧,因此系统中既要重点跟踪,也要同步强化风险预警。 +3. 从监控角度,欢乐海岸更适合重点观察“封板后锁仓”“高位强势股延续”“妖股行情强化”等场景。 +4. 由于其市场辨识度高,出现卖出或慢流出信号时,也应比普通游资给予更高预警等级。 + +重点席位关键字: + +1. 中信证券深圳总部 +2. 中信证券深圳后海 +3. 广发证券深圳光彩路 +4. 中信证券深圳分公司 +5. 华泰证券深圳分公司 +6. 华泰证券深圳科苑南路华润大厦证券营业部 +7. 中国中金财富证券云浮新兴东堤北路证券营业部 +8. 天府证券深圳福田金田路证券营业部 +9. 中泰证券深圳宝源南路证券营业部 + +典型风格: + +1. 妖股风格明显 +2. 锁仓风格强 +3. 大级别情绪推动能力强 +4. 高位强势股参与度高 +5. 封板后锁仓特征明显 +6. 市场品牌效应强,容易形成跟风溢价 + +### 4.2 席位管理要求 +1. 席位需支持别名、历史变更、归属游资维护。 +2. 席位识别规则需支持人工修正。 +3. 后续允许新增游资与席位,不影响现有结构。 + +## 5. 核心业务逻辑 + +### 5.1 监控逻辑 +系统在每日盘后抓取龙虎榜数据后,需要完成以下处理: + +1. 识别是否出现目标游资席位。 +2. 记录该席位对应的买入额、卖出额、净额、成交金额、上榜原因。 +3. 将该记录与股票当日 K 线、成交量、换手率等行情数据对齐。 +4. 计算该股票在该游资维度下的累计净额。 +5. 判断当前处于建仓、加仓、锁仓、撤退中的哪一类状态。 + +### 5.2 状态判定逻辑 + +#### 5.2.1 建仓 +首次出现目标游资席位,且净买入为正。 + +#### 5.2.2 加仓 +同一股票后续再次出现目标游资席位,且累计净额继续增加。 + +#### 5.2.3 锁仓 +在后续若干交易日内未出现明显卖出记录,且累计净额维持高位;如股价回调但成交量缩小,则强化“锁仓”判断。 + +#### 5.2.4 撤退 +目标游资主力席位出现明显净卖出,导致累计净额快速下降,或核心席位连续流出。 + +#### 5.2.5 共振 +同一只股票在相近时间内出现两个及以上目标游资参与,判定为“多游资共振”。 + +#### 5.2.6 慢流出观察 +目标游资买入后,在后续约 5 个交易日内未出现新的加仓或明确锁仓动作,且股价表现转弱、累计净额无明显提升时,系统标记为“慢流出观察”。 + +### 5.3 预警逻辑 +系统需要对以下情形触发主动预警: + +1. 卖出预警:只要目标游资主力席位出现卖出记录,即触发预警。 +2. 大幅流出预警:当日净额由正转负,或累计净额明显下降时,提升为高优先级预警。 +3. 慢流出预警:买入后约 5 个交易日未继续动作,且未出现强化信号时,提示“可能进入慢慢流出阶段”。 +4. 破位预警:股价跌破关键支撑位、5 日线或成本区时触发风险提示。 +5. 共振失效预警:原本存在多游资共振,但核心游资之一开始卖出时触发。 + +预警等级建议分为: + +1. 普通提醒 +2. 重点预警 +3. 高风险预警 + +### 5.4 博弈提示逻辑 +系统仅在下列条件同时满足时,标记为“可关注”: + +1. 目标游资累计净额为正。 +2. 近期无明显主力撤退。 +3. 股价回调至 5 日线附近或 Fibonacci 0.382/0.5 区域。 +4. 回调过程未明显放量。 +5. 换手率未进入异常失控状态。 + +系统给出的结论分为: + +1. 观察 +2. 可关注 +3. 谨慎 +4. 规避 + +## 6. 功能需求 + +### 6.1 模块一:龙虎榜数据采集 +功能要求: + +1. 支持从公开可访问数据源获取龙虎榜数据。 +2. 支持按交易日批量采集。 +3. 支持补拉历史数据。 +4. 支持失败重试与日志记录。 + +当前优先数据源: + +1. 同花顺公开页面或可访问接口 +2. 东方财富公开页面或可访问接口 +3. AkShare 等基于公开数据封装的开源接口 + +约束要求: + +1. 不依赖 Wind 这类当前无法稳定获取的数据源。 +2. 数据源适配层需要支持替换与扩展。 +3. 采集逻辑与业务逻辑解耦。 + +输入: + +1. 交易日期 +2. 数据源配置 + +输出: + +1. 原始龙虎榜记录 +2. 采集日志 + +### 6.2 模块二:席位识别与归属映射 +功能要求: + +1. 对龙虎榜买卖席位进行标准化。 +2. 将席位映射到指定游资家族。 +3. 支持一对多别名归一。 +4. 支持人工维护席位字典。 + +输出字段至少包括: + +1. seat_name_raw +2. seat_name_std +3. trader_name +4. confidence + +### 6.3 模块三:游资动态墙 +功能要求: + +1. 展示目标游资当前监控中的所有股票。 +2. 显示每只股票的最新状态、累计净额、最近动作日期。 +3. 对新增建仓、加仓、撤退、高亮共振进行突出显示。 +4. 展示股票基础画像信息。 + +关键展示字段: + +1. 股票代码 +2. 股票名称 +3. 游资名称 +4. 最新净额 +5. 累计净额 +6. 当前状态 +7. 最近上榜日期 +8. 是否共振 +9. 所属板块 +10. 总市值 +11. 最新涨跌幅 +12. 最新股价 + +### 6.4 模块四:个股操盘轨迹还原 +功能要求: + +1. 展示个股在选定时间范围内的 K 线走势。 +2. 将目标游资的买入点与卖出点标注到时间轴上。 +3. 显示每次上榜动作的净额、席位、日期。 +4. 绘制累计净额柱状图。 +5. 绘制预估成本线或成本区。 +6. 展示该股票的行业、概念板块、市值与财务概况。 + +主图要求: + +1. 买入点用红色标记。 +2. 卖出点用蓝色标记。 +3. 成本线用虚线表示。 + +副图要求: + +1. 展示累计净额随时间变化。 +2. 支持观察“股价回调但累计净额稳定”的锁仓特征。 + +个股信息卡要求: + +1. 所属行业 +2. 所属概念板块 +3. 总市值 +4. 流通市值 +5. 最新股价 +6. 当日涨跌幅 +7. 阶段涨幅 +8. 财务状况总结 + +### 6.5 模块五:历史动作流水账 +功能要求: + +1. 按时间顺序展示单票的关键动作记录。 +2. 每条记录需要包含市场表现与系统建议。 +3. 支持导出为表格或文本。 + +字段要求: + +1. 日期 +2. 股票代码 +3. 游资名称 +4. 动作类型 +5. 当日净额 +6. 累计净额 +7. 股价涨跌幅 +8. 成交量变化 +9. 技术位状态 +10. 系统建议 + +### 6.6 模块六:个股画像与财务总结 +功能要求: + +1. 为每只监控股票生成个股画像卡。 +2. 展示股票所属行业、概念板块、市场属性和价格状态。 +3. 汇总关键财务指标并输出自动化财务总结。 +4. 财务总结应覆盖盈利能力、成长性、估值水平、负债水平。 + +建议展示字段: + +1. 股票代码 +2. 股票名称 +3. 所属行业 +4. 所属概念板块 +5. 总市值 +6. 流通市值 +7. 最新股价 +8. 当日涨跌幅 +9. 近5日涨幅 +10. 近20日涨幅 +11. 市盈率 PE +12. 市净率 PB +13. 营收增速 +14. 净利润增速 +15. ROE +16. 资产负债率 +17. 财务状况总结 + +财务状况总结规则: + +1. 基于公开财务数据自动生成摘要。 +2. 若数据缺失,明确提示缺失项。 +3. 总结风格以简明、可读、可比较为主。 + +### 6.7 模块七:预警中心 +功能要求: + +1. 汇总所有卖出、流出、破位、慢流出相关预警。 +2. 支持按游资、股票、预警等级、日期筛选。 +3. 支持在首页和详情页同步展示预警状态。 +4. 支持标记预警已读、忽略或持续关注。 + +预警触发规则: + +1. 目标游资一旦出现卖出记录,立即生成预警。 +2. 若买入后约 5 个交易日无继续动作,生成“慢流出观察”预警。 +3. 若累计净额下降且股价同步走弱,升级为高风险预警。 +4. 若跌破 5 日线或成本区,追加技术面风险预警。 + +关键展示字段: + +1. 预警时间 +2. 股票代码 +3. 股票名称 +4. 游资名称 +5. 预警类型 +6. 预警等级 +7. 触发原因 +8. 当前股价 +9. 当前涨跌幅 +10. 建议动作 + +### 6.8 模块八:博弈机会筛选 +功能要求: + +1. 盘后自动筛选满足条件的候选股票。 +2. 输出 3 至 5 只优先候选。 +3. 给出入选原因与风险提示。 + +候选分值建议由以下因子组成: + +1. 游资权重分 +2. 共振分 +3. 技术面分 +4. 题材分 +5. 风险扣分 + +### 6.9 模块九:Agent 协同分析 +建议至少预留以下 Agent 接口: + +1. Agent A:题材与新闻评分 +2. Agent B:技术面评分 +3. Agent C:龙虎榜追踪 +4. Agent D:预警解释与综合摘要 + +系统要求: + +1. 可独立运行单个 Agent。 +2. 可合并 Agent 评分输出总分。 +3. 保留中间结果用于预警解释。 + +## 7. 页面与交互需求 + +### 7.1 首页 +首页应展示: + +1. 今日新增建仓 +2. 今日新增加仓 +3. 今日撤退预警 +4. 今日共振标的 +5. 今日博弈备选 +6. 今日卖出预警 +7. 今日慢流出观察 + +首页展示原则: + +1. 将“预警”放在高于“机会”的位置。 +2. 优先展示需要用户立即关注的卖出和流出信号。 +3. 列表支持按预警等级排序。 + +### 7.2 游资视角页 +用户按游资查看时,应看到: + +1. 该游资的概览卡片,包括近期参与股票数、当前持有观察数、卖出预警数、慢流出观察数。 +2. 该游资近期参与的股票列表。 +3. 每只股票的累计净额与状态。 +4. 板块分布。 +5. 最近动作记录。 + +列表应重点展示字段: + +1. 股票代码 +2. 股票名称 +3. 所属板块 +4. 最新股价 +5. 当日涨跌幅 +6. 总市值 +7. 首次参与日期 +8. 最近动作日期 +9. 当日净额 +10. 累计净额 +11. 当前状态 +12. 预警状态 +13. 是否共振 + +交互要求: + +1. 点击“章盟主”等游资名称后,先进入该游资详情页。 +2. 游资详情页默认展示“近期参与股票列表”。 +3. 列表支持按最近动作、累计净额、涨跌幅、预警等级排序。 +4. 点击列表中的股票后,进入股票详情页。 + +### 7.3 个股视角页 +用户按股票查看时,应看到: + +1. 股票基础信息区,展示股票名称、代码、所属板块、行业、市值、最新股价、涨跌幅。 +2. 主图区域,展示 K 线走势与龙虎榜买卖点叠加图。 +3. 副图区域,展示累计净额柱状图、成本区和关键支撑位。 +4. 龙虎榜整体参与图,展示该股票在时间轴上的所有目标游资参与情况。 +5. 财务状况总结。 +6. 所有参与目标游资及席位明细。 +7. 当前状态判断。 +8. 系统建议与风险说明。 +9. 当前预警信息。 + +详情页图表要求: + +1. 主图要同时展示股价走势、买卖点、成本线。 +2. 副图要突出累计净额变化与价格背离。 +3. 龙虎榜整体参与图要能看出“谁先进入、谁在加仓、谁开始卖出”。 +4. 支持查看单一游资轨迹,也支持查看三大游资叠加全景图。 + +### 7.4 预警与监控页 +应支持: + +1. 查看当日与近期预警 +2. 查看单票监控详情 +3. 查看动作流水账 +4. 查看当前状态与提示输出 + +## 8. 数据需求与数据模型 + +### 8.1 核心数据表建议 + +#### 8.1.1 股票基础表 stocks +建议字段: + +1. stock_code +2. stock_name +3. industry +4. concept_tags +5. market +6. total_market_value +7. circulating_market_value + +#### 8.1.2 龙虎榜原始表 lhb_records +建议字段: + +1. trade_date +2. stock_code +3. stock_name +4. reason +5. seat_name +6. direction +7. buy_amount +8. sell_amount +9. net_amount +10. rank_no +11. source + +#### 8.1.3 席位映射表 seat_mapping +建议字段: + +1. seat_name_raw +2. seat_name_std +3. trader_name +4. alias_group +5. is_active +6. remark + +#### 8.1.4 股票行情表 stock_daily +建议字段: + +1. trade_date +2. stock_code +3. open +4. high +5. low +6. close +7. volume +8. amount +9. turnover_rate +10. pct_chg +11. amplitude + +#### 8.1.5 股票财务摘要表 stock_fundamentals +建议字段: + +1. trade_date +2. stock_code +3. pe_ttm +4. pb +5. revenue_yoy +6. profit_yoy +7. roe +8. debt_ratio +9. gross_margin +10. net_margin +11. finance_summary + +#### 8.1.6 游资持仓轨迹表 trader_stock_timeline +建议字段: + +1. trade_date +2. stock_code +3. trader_name +4. day_net_amount +5. cumulative_net_amount +6. estimated_cost_price +7. status +8. resonance_flag +9. risk_flag +10. system_advice +11. warning_type +12. warning_level +13. last_action_date +14. days_since_last_action + +#### 8.1.7 预警事件表 warning_events +建议字段: + +1. warning_time +2. trade_date +3. stock_code +4. stock_name +5. trader_name +6. warning_type +7. warning_level +8. trigger_reason +9. current_price +10. pct_chg +11. status +12. suggestion + +## 9. 关键计算规则 + +### 9.1 累计净额 +累计净额 = 历史净买入额累计求和。 + +### 9.2 成本区估算 +优先基于龙虎榜成交均价估算;若缺失,则使用当日价格区间与成交额进行近似计算。 + +### 9.3 Fibonacci 支撑位 +默认支持至少计算: + +1. 0.382 +2. 0.5 +3. 0.618 + +参数需要支持配置,不应写死。 + +### 9.4 5 日线判断 +以收盘价与 5 日均线关系为主,允许盘中跌破但收盘收回作为特殊观察信号。 + +### 9.5 风险过滤 +默认风险提示项包括: + +1. 换手率过高 +2. 放量破位 +3. 主力席位净流出 +4. 无题材支撑 +5. 非目标游资干扰较重 + +### 9.6 慢流出规则 +默认慢流出观察规则建议如下: + +1. 自首次明确买入日起计算。 +2. 若连续约 5 个交易日未出现新增买入或加仓动作,则进入观察。 +3. 若期间股价走弱、跌破短期支撑或累计净额无提升,则升级为慢流出预警。 +4. 该规则参数需要支持配置,不应写死。 + +## 10. 非功能需求 + +### 10.1 性能 +1. 日终处理应在 30 分钟内完成。 +2. 单票历史页面打开时间应控制在可接受范围内。 + +### 10.2 可维护性 +1. 席位配置可维护。 +2. 指标参数可配置。 +3. Agent 可独立扩展。 +4. 数据源适配器可独立扩展。 + +### 10.3 可追溯性 +1. 所有系统建议需可追溯到原始数据与计算因子。 +2. 所有状态结论需可解释。 + +## 11. 风险与约束 + +### 11.1 数据风险 +1. 龙虎榜为盘后数据,天然存在时滞。 +2. 席位不完全等同于个人,可能存在混用与噪声。 +3. 部分席位归属需人工确认。 + +### 11.2 交易风险 +1. 游资票波动大,不适合重仓。 +2. 回调并不必然意味着低风险。 +3. 系统建议仅为辅助分析,不构成投资承诺。 + +### 11.3 使用约束 +1. 默认使用场景为小仓位试错。 +2. 默认推荐仓位不超过单笔总资金的 5% 至 10%。 +3. 当前阶段优先支持盘后监控与预警,不承诺盘中实时触发。 + +## 12. 验收标准 + +### 12.1 第一阶段验收 +1. 成功通过公开数据源采集并入库 2026 年龙虎榜历史数据。 +2. 成功识别三大目标游资核心席位。 +3. 可查询任意股票的目标游资历史记录。 + +### 12.2 第二阶段验收 +1. 可在个股页面看到买卖点、成本线、累计净额柱状图。 +2. 可展示板块、市值、涨幅、股价等基础信息。 +3. 可自动生成动作流水账。 +4. 可输出锁仓、加仓、撤退状态。 +5. 一旦出现卖出记录可立即触发预警。 +6. 可识别买入后约 5 日无动作的慢流出观察状态。 + +### 12.3 第三阶段验收 +1. 可自动生成盘后候选列表。 +2. 可生成监控摘要与风险提示。 +3. 可接入 Agent A/B 输出题材分与技术分。 +4. 可生成财务状况摘要。 +5. 可在游资详情页展示近期参与股票列表。 +6. 可在股票详情页展示走势与龙虎榜整体参与图。 + +## 13. 版本建议 + +### 13.1 V1 +1. 历史数据采集 +2. 席位映射 +3. 单票轨迹还原 +4. 累计净额计算 +5. 个股基础信息展示 +6. 卖出即预警 + +### 13.2 V2 +1. 候选筛选器 +2. 首页看板 +3. 监控摘要 +4. 风险提示 +5. 财务摘要生成 +6. 慢流出预警 +7. 游资详情页列表 + +### 13.3 V3 +1. 多 Agent 协同评分 +2. 题材分析接入 +3. 评分解释与信号说明 +4. 多公开数据源自动切换 +5. 龙虎榜全景参与图 + +## 14. 当前默认假设 +基于现有聊天内容,本文档采用以下默认假设: + +1. 数据范围以 2026 年历史数据为优先。 +2. 数据源优先使用公开可获取的数据源,如同花顺、东方财富或 AkShare。 +3. 当前重点是“章盟主、炒股养家、欢乐海岸”三类游资。 +4. 当前重点是盘后分析、预警监控和次日观察,不是实时量化交易。 +5. 当前重点是辅助判断,不是自动交易执行。 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..037ad9e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,20 @@ +# longhubang frontend + +Vue 3 + Vite + TypeScript frontend for the top trader monitoring dashboard. + +## Development + +```powershell +npm install +npm run dev -- --host 127.0.0.1 --port 5173 +``` + +The dev server proxies `/api` to `http://127.0.0.1:8000`. + +## Build + +```powershell +npm run build +``` + +The production output is written to `dist/`. The FastAPI backend serves `frontend/dist` after a build. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..096d706 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..7eaa555 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1246 @@ +{ + "name": "longhubang-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "longhubang-frontend", + "version": "0.0.0", + "dependencies": { + "vue": "^3.5.32" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.1", + "typescript": "~6.0.2", + "vite": "^8.0.4", + "vue-tsc": "^3.2.6" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.6", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.2.6.tgz", + "integrity": "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.9.1", + "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 5.8", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.2.6.tgz", + "integrity": "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.6" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..20a1da5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "longhubang-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.32" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.1", + "typescript": "~6.0.2", + "vite": "^8.0.4", + "vue-tsc": "^3.2.6" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..0cca4b5 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..feeb26d --- /dev/null +++ b/frontend/src/assets/main.css @@ -0,0 +1,82 @@ +@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;700;900&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap'); + +:root { + --color-bg: #090d14; + --color-panel: rgba(16, 23, 33, 0.95); + --color-line: rgba(214, 168, 95, 0.22); + --color-text: #f5efe4; + --color-muted: #93a2b5; + --color-gold: #d4a35c; + --color-gold-soft: #f0c071; + --color-blue: #5ab8ff; + --color-red: #ff5d5d; + --color-green: #2dbd7b; + --color-orange: #ffae42; + --shadow-strong: 0 24px 80px rgba(0, 0, 0, 0.45); + --font-display: 'Noto Serif SC', 'STZhongsong', serif; + --font-body: 'IBM Plex Sans', 'Microsoft YaHei', sans-serif; + --font-mono: 'Bahnschrift', 'IBM Plex Sans', sans-serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + color-scheme: dark; + overflow: hidden; +} + +body { + margin: 0; + min-width: 360px; + height: 100vh; + color: var(--color-text); + font-family: var(--font-body); + overflow: hidden; + background: + radial-gradient(circle at 10% 20%, rgba(212, 163, 92, 0.12), transparent 26%), + radial-gradient(circle at 90% 10%, rgba(90, 184, 255, 0.1), transparent 24%), + linear-gradient(180deg, #0b0e13 0%, #090c12 100%); +} + +body::before { + content: ''; + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); + background-size: 36px 36px; + opacity: 0.3; + pointer-events: none; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + border: 0; +} + +#app { + width: min(1600px, calc(100vw - 32px)); + height: 100vh; + margin: 0 auto; + padding: 10px 0 10px; + position: relative; + overflow: hidden; +} + +@media (max-width: 720px) { + #app { + width: min(100vw - 20px, 1600px); + padding-top: 8px; + } +} diff --git a/frontend/src/components/AppHero.vue b/frontend/src/components/AppHero.vue new file mode 100644 index 0000000..6f3872e --- /dev/null +++ b/frontend/src/components/AppHero.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/frontend/src/components/HomeControlScreen.vue b/frontend/src/components/HomeControlScreen.vue new file mode 100644 index 0000000..63212de --- /dev/null +++ b/frontend/src/components/HomeControlScreen.vue @@ -0,0 +1,697 @@ + + + + + diff --git a/frontend/src/components/StockDetailScreen.vue b/frontend/src/components/StockDetailScreen.vue new file mode 100644 index 0000000..449b8d9 --- /dev/null +++ b/frontend/src/components/StockDetailScreen.vue @@ -0,0 +1,957 @@ + + + + + diff --git a/frontend/src/components/TraderDetailScreen.vue b/frontend/src/components/TraderDetailScreen.vue new file mode 100644 index 0000000..842036d --- /dev/null +++ b/frontend/src/components/TraderDetailScreen.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/frontend/src/components/WarningCenterScreen.vue b/frontend/src/components/WarningCenterScreen.vue new file mode 100644 index 0000000..08f868b --- /dev/null +++ b/frontend/src/components/WarningCenterScreen.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/frontend/src/components/home/CandidateFocusPanel.vue b/frontend/src/components/home/CandidateFocusPanel.vue new file mode 100644 index 0000000..37d982b --- /dev/null +++ b/frontend/src/components/home/CandidateFocusPanel.vue @@ -0,0 +1,427 @@ + + + + + diff --git a/frontend/src/components/home/CandidateWatchCard.vue b/frontend/src/components/home/CandidateWatchCard.vue new file mode 100644 index 0000000..a87dcf6 --- /dev/null +++ b/frontend/src/components/home/CandidateWatchCard.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/frontend/src/composables/useDashboardData.ts b/frontend/src/composables/useDashboardData.ts new file mode 100644 index 0000000..e9fbc8b --- /dev/null +++ b/frontend/src/composables/useDashboardData.ts @@ -0,0 +1,272 @@ +import { computed, ref, shallowRef } from 'vue' + +import type { + ActionItem, + ActionsResponse, + PipelineStatus, + StockDetail, + Summary, + TraderDetail, + TraderListItem, + WarningItem, + WatchlistItem, +} from '../types' +import { numberFromText } from '../utils/format' + +async function api(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers) + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + + const response = await fetch(path, { + ...init, + headers, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + return response.json() as Promise +} + +export function useDashboardData() { + const status = ref(null) + const summary = ref(null) + const warnings = ref([]) + const traders = ref([]) + const traderDetail = ref(null) + const stockDetail = ref(null) + const actions = ref([]) + const watchlist = ref([]) + + const selectedWarningCode = shallowRef('') + const selectedTraderId = shallowRef(null) + const selectedStockCode = shallowRef('') + const selectedDateFrom = shallowRef('') + const selectedDateTo = shallowRef('') + const selectedTraderFilter = shallowRef('all') + const selectedActionFilter = shallowRef<'all' | 'buy' | 'sell' | 'net_buy' | 'net_sell'>('all') + const isBooting = shallowRef(true) + const errorMessage = shallowRef('') + + const activeWarning = computed(() => { + return warnings.value.find((item) => item.stock_code === selectedWarningCode.value) ?? null + }) + + const availableTradeDates = computed(() => { + return (status.value?.recent_trade_days ?? []) + .map((item) => item.trade_date) + .filter((item): item is string => Boolean(item)) + }) + + const filteredActions = computed(() => { + return actions.value.filter((item) => { + if (selectedTraderFilter.value !== 'all' && item.trader_name !== selectedTraderFilter.value) { + return false + } + if (selectedActionFilter.value !== 'all' && item.action_side !== selectedActionFilter.value) { + return false + } + return true + }) + }) + + const watchlistMap = computed(() => { + return new Map(watchlist.value.map((item) => [item.stock_code, item])) + }) + + const watchedActionRows = computed(() => { + return filteredActions.value.filter((item) => watchlistMap.value.has(item.stock_code)) + }) + + const candidateActionRows = computed(() => { + const unique = new Map() + for (const item of filteredActions.value) { + if (watchlistMap.value.has(item.stock_code)) continue + if (!unique.has(item.stock_code)) { + unique.set(item.stock_code, item) + } + } + return [...unique.values()] + }) + + const watchlistMetrics = computed(() => { + const rows = watchedActionRows.value + const uniqueCodes = new Set(rows.map((item) => item.stock_code)) + const buyTotal = rows.reduce((sum, item) => sum + (numberFromText(item.buy_amount_wan) ?? 0), 0) + const sellTotal = rows.reduce((sum, item) => sum + (numberFromText(item.sell_amount_wan) ?? 0), 0) + const netPositiveCount = new Set( + rows + .filter((item) => (numberFromText(item.net_amount_wan) ?? 0) > 0) + .map((item) => item.stock_code), + ).size + const warningCount = new Set( + warnings.value + .filter((item) => watchlistMap.value.has(item.stock_code)) + .map((item) => item.stock_code), + ).size + + return { + watchCount: watchlist.value.length, + activeWatchCount: uniqueCodes.size, + buyTotalWan: buyTotal, + sellTotalWan: sellTotal, + netPositiveCount, + warningCount, + } + }) + + const watchlistStocksForDisplay = computed(() => { + return watchlist.value.map((item) => { + const warning = warnings.value.find((warningItem) => warningItem.stock_code === item.stock_code) ?? null + return { + ...item, + hasWarning: Boolean(warning), + warning, + } + }) + }) + + function isWatched(stockCode: string): boolean { + return watchlistMap.value.has(stockCode) + } + + async function loadWatchlist() { + watchlist.value = await api('/api/watchlist') + } + + async function addToWatchlist(item: Pick) { + if (watchlistMap.value.has(item.stock_code)) return + await api('/api/watchlist', { + method: 'POST', + body: JSON.stringify(item), + }) + await loadWatchlist() + } + + async function removeFromWatchlist(stockCode: string) { + if (!watchlistMap.value.has(stockCode)) return + await api<{ ok: boolean; stock_code: string }>(`/api/watchlist/${encodeURIComponent(stockCode)}`, { + method: 'DELETE', + }) + await loadWatchlist() + } + + async function loadActions() { + const params = new URLSearchParams() + if (selectedDateFrom.value && selectedDateTo.value) { + params.set('date_from', selectedDateFrom.value) + params.set('date_to', selectedDateTo.value) + } + if (selectedTraderFilter.value !== 'all') params.set('trader_name', selectedTraderFilter.value) + params.set('limit', '300') + + const response = await api(`/api/actions?${params.toString()}`) + actions.value = response.actions + if (!selectedDateFrom.value && response.date_from) { + selectedDateFrom.value = response.date_from + } + if (!selectedDateTo.value && response.date_to) { + selectedDateTo.value = response.date_to + } + } + + async function selectTrader(traderId: number) { + selectedTraderId.value = traderId + traderDetail.value = await api(`/api/traders/${traderId}`) + } + + async function selectStock(stockCode: string) { + selectedStockCode.value = stockCode + stockDetail.value = await api(`/api/stocks/${encodeURIComponent(stockCode)}`) + } + + async function selectWarning(item: WarningItem) { + selectedWarningCode.value = item.stock_code + await selectStock(item.stock_code) + } + + async function selectTradeDateRange(dateFrom: string, dateTo: string) { + selectedDateFrom.value = dateFrom + selectedDateTo.value = dateTo + await loadActions() + } + + async function initialize() { + isBooting.value = true + errorMessage.value = '' + + try { + const [statusResult, summaryResult, warningResult, traderResult, watchlistResult] = await Promise.all([ + api('/api/pipeline/status'), + api('/api/summary'), + api('/api/warnings?limit=40'), + api('/api/traders'), + api('/api/watchlist'), + ]) + + status.value = statusResult + summary.value = summaryResult + warnings.value = warningResult + traders.value = traderResult + watchlist.value = watchlistResult + + const latestTradeDate = statusResult.recent_trade_days[0]?.trade_date ?? '' + selectedDateFrom.value = latestTradeDate + selectedDateTo.value = latestTradeDate + + await loadActions() + + if (traderResult[0]) { + await selectTrader(traderResult[0].id) + } + + const preferredStockCode = watchlist.value[0]?.stock_code ?? warningResult[0]?.stock_code + if (preferredStockCode) { + selectedWarningCode.value = preferredStockCode + await selectStock(preferredStockCode) + } + } catch (error) { + errorMessage.value = String(error instanceof Error ? error.message : error) + } finally { + isBooting.value = false + } + } + + return { + status, + summary, + warnings, + traders, + traderDetail, + stockDetail, + actions, + watchlist, + selectedWarningCode, + selectedTraderId, + selectedStockCode, + selectedDateFrom, + selectedDateTo, + selectedTraderFilter, + selectedActionFilter, + activeWarning, + availableTradeDates, + filteredActions, + watchedActionRows, + candidateActionRows, + watchlistMetrics, + watchlistStocksForDisplay, + isBooting, + errorMessage, + initialize, + loadActions, + loadWatchlist, + selectTrader, + selectStock, + selectWarning, + selectTradeDateRange, + isWatched, + addToWatchlist, + removeFromWatchlist, + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..14a813b --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue' + +import App from './App.vue' +import './assets/main.css' + +createApp(App).mount('#app') diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..d5e09fa --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,192 @@ +export interface Summary { + warning_total: number + warning_by_level: Record + trader_total: number + stock_total: number + imported_days: number +} + +export interface WarningItem { + trade_date: string + stock_code: string + stock_name: string + trader_name: string + warning_type: string + warning_level: string + trigger_reason: string + current_price: string | null + pct_chg: string | null + suggestion?: string | null + created_at?: string +} + +export interface ActionItem { + trade_date: string + stock_code: string + stock_name: string + trader_name: string + table_title: string + seat_name: string + buy_amount_wan: string | null + sell_amount_wan: string | null + net_amount_wan: string | null + current_price: string | null + pct_chg: string | null + industry?: string | null + market?: string | null + board_label?: string | null + total_market_value?: number | null + circulating_market_value?: number | null + action_side: 'buy' | 'sell' | 'net_buy' | 'net_sell' +} + +export interface ActionsResponse { + trade_date: string | null + date_from: string | null + date_to: string | null + actions: ActionItem[] +} + +export interface TraderListItem { + id: number + name: string + alias_name?: string | null + warning_weight: string + style_tags: string[] + stock_count: number + sell_alert_count: number + slow_exit_count: number +} + +export interface TraderSeat { + seat_name: string + seat_level: string +} + +export interface TraderStock { + stock_code: string + stock_name: string + latest_price: string | null + pct_chg: string | null + action_count: number + last_trade_date: string | null + buy_action_count: number + sell_action_count: number + has_sell_alert: number + has_slow_exit: number +} + +export interface TraderDetail { + trader: { + id: number + name: string + alias_name?: string | null + warning_weight: string + style_tags: string[] + } + seats: TraderSeat[] + stocks: TraderStock[] + warnings: WarningItem[] +} + +export interface StockOverview { + trade_date: string + price: string | null + pct_chg: string | null + amount: string | null + net_buy: string | null + flag: string | null +} + +export interface MarketDailyRow { + trade_date: string + open: string + close: string + high: string + low: string + volume: string + amount: string + amplitude: string + pct_chg: string + price_chg: string + turnover: string +} + +export interface TraderAction { + trade_date: string + matched_trader_name: string + table_title: string + seat_name: string + buy_amount_wan: string | null + sell_amount_wan: string | null + net_amount_wan: string | null +} + +export interface TraderSummaryItem { + matched_trader_name: string + action_count: number + buy_count: number + sell_count: number + last_trade_date: string | null + total_buy_amount_wan: number + total_sell_amount_wan: number + total_net_amount_wan: number +} + +export interface StockDetail { + stock: { + stock_code: string + stock_name: string + market: string | null + industry: string | null + total_market_value: number | null + circulating_market_value: number | null + } + market_snapshot: { + stock_code?: string + stock_name?: string + industry?: string | null + circulating_shares?: number | null + circulating_market_value?: number | null + total_market_value?: number | null + latest_price?: number | null + high_price?: number | null + low_price?: number | null + open_price?: number | null + volume?: number | null + amount?: number | null + previous_close?: number | null + turnover?: number | null + price_chg?: number | null + pct_chg?: number | null + amplitude?: number | null + } + overview: StockOverview[] + market_daily: MarketDailyRow[] + trader_actions: TraderAction[] + trader_summary: TraderSummaryItem[] + warnings: WarningItem[] +} + +export interface PipelineStatus { + overview_total: number + detail_total: number + warning_total: number + trader_total: number + latest_trade_date: string | null + recent_trade_days: Array<{ + trade_date: string | null + overview_count: number + }> +} + +export interface WatchlistItem { + stock_code: string + stock_name: string + source_trade_date: string | null + source_trader_name: string | null + added_at: string + status?: 'active' | 'archived' + archived_at?: string | null + updated_at?: string +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..5be145d --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,69 @@ +export function warningLabel(type: string): string { + if (type === 'sell_alert') return '卖出预警' + if (type === 'slow_exit_watch') return '慢流出观察' + return type || '提醒' +} + +export function warningTone(level: string): 'red' | 'orange' | 'gold' { + if (level === 'high') return 'red' + if (level === 'medium') return 'orange' + return 'gold' +} + +export function priceTone(value: string | null | undefined): 'rise' | 'fall' | 'flat' { + if (!value) return 'flat' + if (String(value).startsWith('-')) return 'fall' + if (String(value).startsWith('+') || Number(value) > 0) return 'rise' + return 'flat' +} + +export function numberFromText(value: string | number | null | undefined): number | null { + if (value === null || value === undefined || value === '') return null + if (typeof value === 'number') return value + + const trimmed = value.replace(/,/g, '').trim() + if (!trimmed) return null + + let multiplier = 1 + if (trimmed.includes('亿')) multiplier = 100000000 + else if (trimmed.includes('万')) multiplier = 10000 + + const normalized = trimmed.replace('%', '').replace('亿', '').replace('万', '') + const parsed = Number(normalized) + return Number.isNaN(parsed) ? null : parsed * multiplier +} + +export function compactMoney(value: string | number | null | undefined): string { + const parsed = numberFromText(value) + if (parsed === null) return '-' + if (Math.abs(parsed) >= 100000000) return `${(parsed / 100000000).toFixed(2)}亿` + if (Math.abs(parsed) >= 10000) return `${(parsed / 10000).toFixed(0)}万` + return `${parsed.toFixed(0)}` +} + +export function formatDate(value: string | null | undefined): string { + return value || '-' +} + +export function formatWanAmount(value: string | number | null | undefined): string { + const parsed = numberFromText(value) + if (parsed === null) return '-' + + if (Math.abs(parsed) >= 10000) { + return `${(parsed / 10000).toFixed(Math.abs(parsed) >= 100000 ? 1 : 0)}亿` + } + + if (Math.abs(parsed) >= 1) { + const fixed = Math.abs(parsed) >= 1000 ? 0 : 1 + return `${parsed.toFixed(fixed)}万` + } + + return `${parsed.toFixed(2)}万` +} + +export function formatSignedWanAmount(value: string | number | null | undefined): string { + const parsed = numberFromText(value) + if (parsed === null) return '-' + const prefix = parsed > 0 ? '+' : '' + return `${prefix}${formatWanAmount(parsed)}` +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..5c750c5 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b29b48e --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + host: '127.0.0.1', + port: 5173, + proxy: { + '/api': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/start-dev.ps1 b/start-dev.ps1 new file mode 100644 index 0000000..27f1fde --- /dev/null +++ b/start-dev.ps1 @@ -0,0 +1,32 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +$LogDir = Join-Path $Root "logs" +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null + +$BackendOut = Join-Path $LogDir "backend.out.log" +$BackendErr = Join-Path $LogDir "backend.err.log" +$FrontendOut = Join-Path $LogDir "frontend.out.log" +$FrontendErr = Join-Path $LogDir "frontend.err.log" + +$Backend = Start-Process ` + -FilePath "python" ` + -ArgumentList "scripts/run_api.py" ` + -WorkingDirectory (Join-Path $Root "backend") ` + -RedirectStandardOutput $BackendOut ` + -RedirectStandardError $BackendErr ` + -PassThru + +$Frontend = Start-Process ` + -FilePath "npm.cmd" ` + -ArgumentList "run", "dev", "--", "--host", "127.0.0.1", "--port", "5173" ` + -WorkingDirectory (Join-Path $Root "frontend") ` + -RedirectStandardOutput $FrontendOut ` + -RedirectStandardError $FrontendErr ` + -PassThru + +Write-Host "Backend PID: $($Backend.Id)" +Write-Host "Frontend PID: $($Frontend.Id)" +Write-Host "Frontend URL: http://127.0.0.1:5173/" +Write-Host "Backend URL: http://127.0.0.1:8000/" +Write-Host "Logs: $LogDir"