Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
203f81334a | ||
|
|
5c7f8e15c9 | ||
|
|
f75d9555e0 | ||
|
|
104e6aa396 | ||
|
|
deb84c4069 | ||
|
|
1c50cc5bcb | ||
|
|
406118bba6 | ||
|
|
faac60b1a6 | ||
|
|
dec3cd1236 | ||
|
|
38de3de0a3 | ||
|
|
b3df070481 |
+64
-29
@@ -1,40 +1,75 @@
|
||||
# Architecture
|
||||
# Candidate architecture
|
||||
|
||||
The normative governance contract is documented in
|
||||
`docs/governance/architecture-standard.md`. This file describes the currently deployed
|
||||
shape; the standard defines the target boundaries and the rules applied during migration.
|
||||
`app/` is the behavior-preserving modular source tree accepted by the user on 2026-08-01.
|
||||
The original `webapp/` runtime remains the deployment rollback baseline until an explicitly
|
||||
approved switch. `next/` is a rejected, frozen implementation and is not a source for this
|
||||
directory.
|
||||
|
||||
The application intentionally keeps a small deployment footprint: one Python process, one
|
||||
SQLite database, and a build-free browser client. The internal boundaries are nevertheless
|
||||
explicit so new features do not bypass account isolation or data-quality rules.
|
||||
The application deliberately remains a modular monolith: one Python process, one SQLite WAL
|
||||
database, and a build-free HTML/CSS/JavaScript client. The migration changed source ownership
|
||||
and imports, not the technology stack or observable product behavior.
|
||||
|
||||
## Backend boundaries
|
||||
## Runtime path
|
||||
|
||||
- `server.py`: application services and HTTP request/response wiring.
|
||||
- `api_access.py`: the single authorization policy for authenticated, member, and admin APIs.
|
||||
- `app_config.py`: runtime paths, local environment loading, and shared input validation.
|
||||
- `database.py`: SQLite schema, migrations, and persistence operations.
|
||||
- `tushare_client.py` and `realtime_aggregator.py`: external market-data adapters.
|
||||
- `sentiment_engine.py`, `screener.py`, and `heaven_engine.py`: deterministic domain logic.
|
||||
- `mentor_agent.py`, `heaven_agent.py`, and `llm_strategy.py`: bounded LLM adapters.
|
||||
```text
|
||||
browser
|
||||
-> frontend/shared/api.js
|
||||
-> backend HTTP transport and feature HTTP mixins
|
||||
-> feature services
|
||||
-> repositories / DataGateway / LLMGateway
|
||||
-> SQLite / market providers / model providers
|
||||
|
||||
## Data ownership
|
||||
background scheduler
|
||||
-> backend/jobs
|
||||
-> the same feature services and repositories
|
||||
```
|
||||
|
||||
Public market snapshots, stock factors, built-in strategies, limit-up reasons, seat aliases,
|
||||
and sector-element mappings are shared. Only administrators can modify shared knowledge.
|
||||
## Source ownership
|
||||
|
||||
Watchlists, review notes, custom strategies, screener runs, mentor conversations, birth data,
|
||||
alerts, trading journals, and assistant conversations are owned by a user ID and must be
|
||||
queried with that ID. LLM features additionally require active membership.
|
||||
- `server.py` is the stable command/import facade. Runtime composition lives in
|
||||
`backend/application.py` and `backend/bootstrap/`.
|
||||
- `backend/http/` owns common authentication, request IDs, responses, static delivery, and
|
||||
error normalization. Feature-specific transport handlers live beside their feature.
|
||||
Exact POST endpoints that only delegate to one of those handlers use the explicit maps in
|
||||
`backend/application.py`; endpoints with path parameters, body handling, or special error
|
||||
semantics remain visible control flow in `RequestHandler`.
|
||||
- `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent,
|
||||
or deterministic calculation code for that product area.
|
||||
- `backend/data/` owns provider construction, source policy, provenance, units, freshness,
|
||||
coverage, and display-versus-calculation eligibility.
|
||||
- `backend/database/` owns connection management, ordered migrations, and narrow repository
|
||||
adapters. Root `database.py` remains the legacy schema/composition anchor and combines the
|
||||
feature repository mixins; do not add feature queries to it.
|
||||
- `backend/jobs/` owns job definitions, locks, retries, idempotency, and persisted run state.
|
||||
- `backend/llm/` owns model selection, membership/quota checks, fallback, provider transport,
|
||||
streaming rules, and call audit. Feature agents only prepare messages and interpret
|
||||
feature-specific results.
|
||||
- `frontend/shared/` is the only browser API/state/Shell/component boundary.
|
||||
- `frontend/pages/` owns page-local behavior. The original runtime was split mechanically;
|
||||
source markers and preservation tests prove that the pieces reassemble to the audited
|
||||
original, apart from explicitly registered trial retirements.
|
||||
- `frontend/styles/`, `frontend/shared/tokens.css`, and the Wentian page stylesheet preserve
|
||||
the approved cascade and light/dark/mobile behavior.
|
||||
- `config/` is the versioned registry for pages, features, APIs, datasets, quality rules,
|
||||
jobs, and the generated candidate architecture inventory.
|
||||
|
||||
## Data integrity
|
||||
Root modules such as `screener.py`, `tushare_client.py`, and `mentor_agent.py` are compatibility
|
||||
aliases to canonical modules. They contain no second implementation and remain only because
|
||||
the original public import surface is part of the preservation contract.
|
||||
|
||||
Production reads never synthesize market prices. A failed live request may use the latest real
|
||||
snapshot at or before the requested date. When no real snapshot exists, the API reports that
|
||||
the data is unavailable. Demo builders remain test fixtures only.
|
||||
## Non-negotiable maintenance rules
|
||||
|
||||
## Change contract
|
||||
1. Preserve account ownership in every user-private query and test it with two accounts.
|
||||
2. Browser requests go through `frontend/shared/api.js`; provider calls go through the data
|
||||
boundary; model calls go through `backend/llm/`.
|
||||
3. Calculation datasets fail closed when required source, date, unit, freshness, or coverage
|
||||
evidence is missing. Display fallbacks do not silently enter calculations.
|
||||
4. Do not implement logic in both a root compatibility module and a canonical module.
|
||||
5. Do not remove compatibility or uncertain code without reference scanning, old/new
|
||||
differential evidence, browser checks, and manual acceptance.
|
||||
6. Run `python tools/verify_baseline.py` for every change and add `--e2e` when runtime or
|
||||
frontend behavior can be affected.
|
||||
|
||||
New endpoints must be added to `api_access.required_role` when they need member or admin
|
||||
access. New user-owned tables must include `user_id`, an ownership index, and cross-account
|
||||
tests. API payload compatibility is protected by the Python and Playwright suites.
|
||||
The authoritative migration constraints and handoff procedure are in
|
||||
`../docs/migration/原版保真迁移总纲.md` and
|
||||
`../docs/migration/人工维护与本地切换指南.md`.
|
||||
|
||||
+9
-5
@@ -2,23 +2,27 @@
|
||||
|
||||
一个面向 A 股盘后复盘的本地 Web 工作台。后端使用 Python 访问 Tushare Pro,前端不依赖构建工具。
|
||||
|
||||
本目录是从原版源码逐项移动、机械拆分并完成差分验证与用户人工验收的模块化正式源码,
|
||||
不是依据规格书重新开发的第二套产品。正式部署切换前,`webapp/`根目录继续作为当前部署与
|
||||
回档基线;冻结的`next/`不得用于部署或后续开发。目录职责见[ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
|
||||
当前包含集合竞价、涨停池、炸板池、跌停板、昨日涨停、涨停表现、市场天梯、板块轮动、题材库、人气热榜、龙虎榜和个人复盘工作区。交易日快照与同步记录保存在本地 SQLite 数据库 `data/review.db`。
|
||||
|
||||
集合竞价中心采用盘前生命周期:9:15 前显示预告,9:15–9:25 明确等待最终竞价,9:25–9:30 自动读取并重试最终竞价筛选,9:30 后停止更新并冻结为复盘归档。当前 Tushare 只提供 9:25 最终竞价快照,不将其表述为动态虚拟撮合行情。
|
||||
|
||||
第三阶段加入了机构席位、席位别名、个股复权日 K、资金流、自选股、涨停原因修订、个股笔记、每日复盘和历史数据回补。
|
||||
|
||||
股票代码在桌面端悬停后会显示分时与日 K 快速预览,默认优先展示日 K;移动端点击代码后从底部打开预览面板。股票详情以及板块、题材、指数详情均可在日 K 与最新分时之间切换。日 K 复用个股详情缓存;分时使用隔离的东方财富分钟图表源和短时内存缓存,只负责展示,不写入主行情、不参与情绪、选股或问天计算。图表源不可用时界面会明确显示“分时不可用”,不会使用日 K 数据模拟分时走势。
|
||||
股票代码在桌面端悬停后会显示分时与日 K 快速预览,默认优先展示日 K;移动端点击代码后从底部打开预览面板。股票详情以及板块、题材、指数详情均可在日 K 与最新分时之间切换。日 K 复用个股详情缓存;分时优先使用 iFinD,东方财富仅作隔离的展示兜底,并使用短时内存缓存。图表数据不写入主行情、不参与情绪、选股或问天计算;不可用时明确显示“分时不可用”,不会用日 K 模拟分时走势。
|
||||
|
||||
智能选股模块包含 45 日全市场因子库、六阶段市场识别、七套内置策略、受控公式 DSL、自然语言策略编译、候选排名和滚动回测。竞价涨幅、竞价成交额、竞价换手率与竞价量比随因子数据一并同步,可用于自定义公式和历史回测。首次使用需在页面点击“同步因子数据”。未配置 LLM 时使用本地策略模板;配置兼容 API 后自动切换为主模型编译,主模型失败时自动使用辅助模型,两者均支持独立连通性测试。
|
||||
智能选股包含六阶段盘后候选、29 套精选策略、自定义公式 DSL、自然语言公式编译、候选排名和滚动回测。阶段与精选策略在当日行情更新后由后台确定性计算;自定义选股由用户手动执行,LLM 只负责编译自然语言条件,不参与候选筛选。竞价、估值、财务、资金、人气和席位等字段按已登记的数据可用性进入因子库,缺失时明确显示覆盖问题。
|
||||
|
||||
每次选股结果会自动进入五交易日持续跟踪,展示 T+1 开盘/收盘、T+3、T+5、最大涨幅与最大回撤。提醒中心支持手工日期提醒,并在策略首日反馈和五日跟踪完成时生成账号私有的站内提醒。
|
||||
候选只有经用户手动加入后才进入五交易日持续跟踪,展示 T+1 开盘/收盘、T+3、T+5、最大涨幅与最大回撤。提醒中心支持手工日期提醒,并在策略首日反馈和五日跟踪完成时生成账号私有的站内提醒。
|
||||
|
||||
问师模块会读取当前复盘、近十日市场情绪、涨跌停、昨日反馈、板块轮动、市场阶段、龙虎榜和指定个股数据,再按选中的游资思维 Skill 进行单师对话。对话记录按账号、老师和交易日期保存在服务端;主模型不可用时自动切换辅助模型。
|
||||
|
||||
新增公开问师角色时,在 `游资skills` 下增加一个包含 `SKILL.md` 的独立目录,并在 `游资skills/mentor_catalog.json` 中登记素材等级与结构质检。管理员私有角色放在 `data/private-mentor-skills`,该目录不进入 Git 或 Docker 镜像,且只会出现在管理员的问师列表中。系统会从 Skill 的 frontmatter、一级标题、核心模型和引用语中自动生成角色信息,无需修改注册代码。
|
||||
|
||||
问天模块包含三个相互独立的部分:观势以市场数据生成三才六爻,用于观察“势”,行情缺失或自动取象明显偏差时可显式手动校准六爻,人工结果与自动来源严格区分;观气依据干支、精确节气、五运六气及客主加临关系观察“运”,行业五行仅作传统取象归类;观心通过30秒静心、六次三枚铜钱起卦、察念和解卦完成一次不输入问题的问心仪式。卦象、干支、节气与气机关系均由本地确定性程序计算,LLM只负责解释,不参与起卦或改动结果。
|
||||
问天模块包含三个相互独立的部分:观势以市场数据生成三才六爻,用于观察“势”,行情缺失或自动取象明显偏差时可显式手动校准六爻,人工结果与自动来源严格区分;观气依据干支、精确节气、五运六气及客主加临关系观察“运”,行业五行仅作传统取象归类;观心先准备1秒,再完成5轮“吸3秒、顿2秒、呼4秒”,随后以六次三枚铜钱起卦、察念和解卦完成一次不输入问题的问心仪式。卦象、干支、节气与气机关系均由本地确定性程序计算,LLM只负责解释,不参与起卦或改动结果。
|
||||
|
||||
问天模块使用项目本地的 `lunar-python` 计算历法,并使用 `data/iching_zh.json` 中的固定六十四卦、卦辞和爻辞。第三方授权见 `THIRD_PARTY_NOTICES.md`。
|
||||
|
||||
@@ -27,7 +31,7 @@
|
||||
## 启动
|
||||
|
||||
```powershell
|
||||
cd webapp
|
||||
cd webapp\app
|
||||
python -m pip install -r requirements.txt
|
||||
python server.py
|
||||
```
|
||||
|
||||
+4
-88
@@ -1,91 +1,7 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility alias for the canonical review-assistant implementation."""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
import sys
|
||||
|
||||
from llm_stream import OpenAIStreamAccumulator
|
||||
from backend.features.review import agent as _implementation
|
||||
|
||||
|
||||
class ReviewAssistantError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def stream_review_assistant(
|
||||
context: dict[str, Any],
|
||||
question: str,
|
||||
history: list[dict[str, str]],
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
timeout: int = 120,
|
||||
) -> Iterator[str]:
|
||||
if not api_key or not model:
|
||||
raise ReviewAssistantError("智能解读服务尚未配置。")
|
||||
messages = [{"role": "system", "content": _system_prompt(context)}]
|
||||
messages.extend(history[-12:])
|
||||
messages.append({"role": "user", "content": question})
|
||||
request = urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}/chat/completions",
|
||||
data=json.dumps(
|
||||
{"model": model, "messages": messages, "stream": True}, ensure_ascii=False
|
||||
).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": "XiaobaiReviewWeb/1.0",
|
||||
"Accept": "text/event-stream",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
yielded = False
|
||||
accumulator = OpenAIStreamAccumulator()
|
||||
for raw_line in response:
|
||||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||
if not line or line.startswith(":"):
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
line = line[5:].strip()
|
||||
if line == "[DONE]":
|
||||
break
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = payload.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
choice = choices[0] or {}
|
||||
content = accumulator.feed(choice)
|
||||
if content:
|
||||
yielded = True
|
||||
yield str(content)
|
||||
if not yielded:
|
||||
raise ReviewAssistantError("智能解读未返回有效内容。")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc
|
||||
|
||||
|
||||
def _system_prompt(context: dict[str, Any]) -> str:
|
||||
context_json = json.dumps(context, ensure_ascii=False, separators=(",", ":"))
|
||||
return f"""
|
||||
你是“小白复盘”的统一复盘助手。你负责把网页中已经存在的市场统计、策略跟踪、提醒、复盘笔记和手工交易日志连接起来,帮助用户复盘和形成下一步观察计划。
|
||||
|
||||
最高优先级规则:
|
||||
1. 只能使用下方“网页复盘数据”,数据缺失就明确说明,不得补造行情、交易或胜率。
|
||||
2. 不自动下单,不声称已执行任何操作,不修改策略、提醒、笔记或交易日志。
|
||||
3. 不承诺收益,不给无条件买卖指令。建议必须写成条件、失效条件和风险边界。
|
||||
4. 区分市场事实、用户记录和你的推断。引用数字时写明数据日期。
|
||||
5. 优先结合用户自己的策略跟踪与交易日志寻找可验证的重复模式;样本不足时明确标注。
|
||||
6. 使用中文,先直接回答,再给数据依据和下一步观察。避免空泛口号,不展示模型、接口或内部工程信息。
|
||||
7. 控制在 800 个中文字符以内,除非用户明确要求展开。
|
||||
|
||||
网页复盘数据:
|
||||
{context_json}
|
||||
""".strip()
|
||||
sys.modules[__name__] = _implementation
|
||||
|
||||
+53
-1684
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ from typing import Any
|
||||
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parents[2]
|
||||
STATIC_DIR = APP_DIR / "static"
|
||||
STATIC_DIR = APP_DIR / "frontend"
|
||||
DATA_DIR = APP_DIR / "data"
|
||||
ENV_FILE = APP_DIR / ".env"
|
||||
MENTOR_SKILLS_DIR = APP_DIR / "游资skills"
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
from .service import AlertService
|
||||
from .facade import AlertServiceMixin
|
||||
from .http import AlertHttpMixin
|
||||
from .repository import AlertRepositoryMixin
|
||||
|
||||
__all__ = ["AlertService"]
|
||||
__all__ = [
|
||||
"AlertHttpMixin",
|
||||
"AlertRepositoryMixin",
|
||||
"AlertService",
|
||||
"AlertServiceMixin",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "AlertService":
|
||||
from .service import AlertService
|
||||
|
||||
return AlertService
|
||||
raise AttributeError(name)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AlertServiceMixin:
|
||||
def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]:
|
||||
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12)
|
||||
self.alert_service.sync_strategy_tracking(self.current_user_id, tracking)
|
||||
return self.alert_service.list_alerts(
|
||||
self.current_user_id, status, as_of
|
||||
)
|
||||
|
||||
def create_alert(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
alert_id = self.alert_service.create_manual(self.current_user_id, payload)
|
||||
return {"id": alert_id, **self.alert_center()}
|
||||
|
||||
def mark_alert_read(self, alert_id: int) -> dict[str, Any]:
|
||||
self.alert_service.mark_read(self.current_user_id, alert_id)
|
||||
return self.alert_center()
|
||||
|
||||
def mark_all_alerts_read(self, as_of: str = "") -> dict[str, Any]:
|
||||
compact_date = self.alert_service.calendar_date(as_of or date.today().isoformat())
|
||||
self.alert_service.mark_all_read(self.current_user_id, compact_date)
|
||||
return self.alert_center(as_of=compact_date)
|
||||
|
||||
def delete_alert(self, alert_id: int) -> dict[str, Any]:
|
||||
deleted = self.alert_service.delete(self.current_user_id, alert_id)
|
||||
return {"deleted": deleted, **self.alert_center()}
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
|
||||
class AlertHttpMixin:
|
||||
def save_alert(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
self.send_json(
|
||||
{"ok": True, **self.application_service.create_alert(body)},
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AlertRepositoryMixin:
|
||||
def save_alert(
|
||||
self,
|
||||
user_id: int,
|
||||
kind: str,
|
||||
title: str,
|
||||
content: str,
|
||||
available_date: str,
|
||||
code: str,
|
||||
dedupe_key: str,
|
||||
) -> int:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO alerts
|
||||
(user_id, kind, title, content, available_date, code, dedupe_key,
|
||||
is_read, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
ON CONFLICT(user_id, dedupe_key) DO UPDATE SET
|
||||
title=excluded.title, content=excluded.content,
|
||||
available_date=excluded.available_date, updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
int(user_id), kind, title, content, available_date, code,
|
||||
dedupe_key, now, now,
|
||||
),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?",
|
||||
(int(user_id), dedupe_key),
|
||||
).fetchone()
|
||||
return int(row["id"])
|
||||
|
||||
def list_alerts(
|
||||
self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100
|
||||
) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
if unread_only:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id, kind, title, content, available_date, code, is_read,
|
||||
created_at, updated_at, read_at
|
||||
FROM alerts
|
||||
WHERE user_id = ? AND available_date <= ? AND is_read = 0
|
||||
ORDER BY available_date DESC, id DESC LIMIT ?
|
||||
""",
|
||||
(int(user_id), as_of, max(1, min(300, int(limit)))),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id, kind, title, content, available_date, code, is_read,
|
||||
created_at, updated_at, read_at
|
||||
FROM alerts WHERE user_id = ?
|
||||
ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END,
|
||||
is_read, available_date, id DESC LIMIT ?
|
||||
""",
|
||||
(int(user_id), as_of, max(1, min(300, int(limit)))),
|
||||
).fetchall()
|
||||
return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows]
|
||||
|
||||
def count_unread_alerts(self, user_id: int, as_of: str) -> int:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS total FROM alerts
|
||||
WHERE user_id = ? AND available_date <= ? AND is_read = 0
|
||||
""",
|
||||
(int(user_id), as_of),
|
||||
).fetchone()
|
||||
return int(row["total"] if row else 0)
|
||||
|
||||
def mark_alert_read(self, user_id: int, alert_id: int) -> bool:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(now, now, int(alert_id), int(user_id)),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def mark_all_alerts_read(self, user_id: int, as_of: str) -> int:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
|
||||
WHERE user_id = ? AND available_date <= ? AND is_read = 0
|
||||
""",
|
||||
(now, now, int(user_id), as_of),
|
||||
)
|
||||
return int(cursor.rowcount)
|
||||
|
||||
def delete_alert(self, user_id: int, alert_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM alerts WHERE id = ? AND user_id = ?",
|
||||
(int(alert_id), int(user_id)),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
@@ -0,0 +1,24 @@
|
||||
from .agent import HeavenAgentError, interpret_heaven
|
||||
from .engine import (
|
||||
build_five_phase_field,
|
||||
build_market_hexagram,
|
||||
build_manual_market_hexagram,
|
||||
build_personal_field,
|
||||
hexagram_from_lines,
|
||||
)
|
||||
from .http import HeavenHttpMixin
|
||||
from .repository import HeavenRepositoryMixin
|
||||
from .service import HeavenServiceMixin
|
||||
|
||||
__all__ = [
|
||||
"HeavenAgentError",
|
||||
"HeavenHttpMixin",
|
||||
"HeavenRepositoryMixin",
|
||||
"HeavenServiceMixin",
|
||||
"build_five_phase_field",
|
||||
"build_manual_market_hexagram",
|
||||
"build_market_hexagram",
|
||||
"build_personal_field",
|
||||
"hexagram_from_lines",
|
||||
"interpret_heaven",
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from backend.llm import transport as llm_transport
|
||||
|
||||
|
||||
class HeavenAgentError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def interpret_heaven(
|
||||
mode: str,
|
||||
context: dict[str, Any],
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
timeout: int = 90,
|
||||
) -> dict[str, Any]:
|
||||
if mode not in {"trend", "fortune", "heart"}:
|
||||
raise HeavenAgentError("不支持的问天解读模式。")
|
||||
if not api_key or not model:
|
||||
raise HeavenAgentError("LLM API Key 或模型尚未配置。")
|
||||
system_prompt = _system_prompt(mode)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(context, ensure_ascii=False, separators=(",", ":")),
|
||||
},
|
||||
]
|
||||
try:
|
||||
result = llm_transport.chat_completion(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
messages=messages,
|
||||
timeout=timeout,
|
||||
user_agent="XiaobaiReviewWeb/0.7",
|
||||
)
|
||||
answer = str(result.content).strip()
|
||||
if not answer:
|
||||
raise KeyError("empty response")
|
||||
except llm_transport.OpenAIHTTPError as exc:
|
||||
raise HeavenAgentError(exc.describe("问天模型调用失败")) from exc
|
||||
except (llm_transport.OpenAITransportError, KeyError) as exc:
|
||||
raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc
|
||||
return {
|
||||
"answer": answer,
|
||||
"model": model,
|
||||
"latency_ms": result.latency_ms,
|
||||
}
|
||||
|
||||
|
||||
def _system_prompt(mode: str) -> str:
|
||||
common = """
|
||||
你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情。
|
||||
问天属于传统文化与娱乐化观察,不是预测模型,不承诺应验,不输出无条件买卖指令,不用神秘话术制造确定性。
|
||||
使用中文,先给核心判断,再解释结构。引用市场数字时标明数据日期。输出纯文本,可使用简短标题。
|
||||
""".strip()
|
||||
if mode == "trend":
|
||||
return common + """
|
||||
|
||||
当前任务是“观势·解势”。六爻从初爻到上爻依次是个股内核、个股外显、板块内核、板块外显、指数内核、指数外显;初二为地、三四为人、五上为天。
|
||||
行情数据只负责生成六爻,本次解势必须以卦象本身为主,不得根据指数涨跌、板块强弱、涨停家数、成交量或个股表现直接推演方向。context中不会提供这些数字,也不会提供爻位对应的市场角色。
|
||||
先解释本卦卦名的核心义、上下卦组合及大象;再只解释实际动爻所代表的转折,并说明本卦如何走向之卦;最后可把这一组卦势翻译成克制的市场语言。
|
||||
重点是“本卦为当下之势,动爻为变化关节,之卦为所趋之势”。不要说明某一动爻对应指数、板块或个股,也不要输出“一看指数、二看涨停家数”一类行情观察条件。
|
||||
全文控制在300至450个中文字符,最多四小段。卦理约占九成,市场翻译最多一句,只能落到节制、等待、守信、辨伪等行为态度,不得据此预测市场下一阶段、涨跌方向或动能变化。不直接荐股,不使用Markdown表格。
|
||||
不要使用“必然、确定、必涨、必跌、后续将、进入某阶段”等断语;天机只点出势的性质与变化关系,不替用户宣布结果。
|
||||
""".strip()
|
||||
if mode == "fortune":
|
||||
return common + """
|
||||
|
||||
当前任务是“观气·解运”。严格区分五运、六气、节气、月令和日干,不把丙午简单解释为火年。
|
||||
严格服从five_phase_field.framework提供的确定性结构,不自行重新计算五行:年纲由中运与司天在泉构成;岁半以前司天为主、在泉为辅,岁半以后在泉为主、司天为辅;当前六气层以客气加临主气为核心;日辰只负责触发。节气只用于定位当前六气阶段,不得再次叠加为独立力量。
|
||||
重点解释framework.relations中的客主同气、客生主、主生客、客克主或主克客,以及客胜为从、主胜为逆、司天在泉同位、天符岁会等已经判定的关系。不得把司天、在泉、主气、客气视为彼此独立的证据重复计权,也不得自行增删传统格局。
|
||||
首要解释当日气场容易放大参与者的哪些情绪、判断偏差和操作冲动,例如急躁、恐惧、迟疑、追涨、过早止损或路径依赖;再给出一至两个调节动作。
|
||||
如有personal_profile,结合其日主、十神、五行平衡倾向说明当日对该用户主观状态的影响,但不得把简化平衡倾向说成唯一喜用神,也不得复述或猜测出生日期。
|
||||
不得引用市场上涨下跌家数、涨跌停数量、成交额、板块强度或个股表现来证明气场。industry_affinity只是五行行业取象示例,不是行情旁证;行业契合度最多在末尾用一句话说明,不得写“当日共振”或暗示相关行业必然涨跌。
|
||||
全文控制在420至600个中文字符,按“三层气机、人的状态、操作偏向、个人影响(如有)、制衡动作”组织,标题必须写“三层气机”。明确这些是传统历法框架下的观察语言,不宣称气候或五行直接导致股价。
|
||||
""".strip()
|
||||
return common + """
|
||||
|
||||
当前任务是“观心·解卦”。用户的问题始终只在心中,没有输入给你,因此你不能猜测问题内容,也不能替用户作具体决定。
|
||||
全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句。
|
||||
不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味。
|
||||
""".strip()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
|
||||
class HeavenHttpMixin:
|
||||
def heaven_hexagram(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
result = self.application_service.heaven_hexagram(body.get("lines"))
|
||||
self.send_json({"ok": True, "hexagram": result})
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def heaven_personal(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
result = self.application_service.heaven_personal(body)
|
||||
self.send_json({"ok": True, "personal": result})
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def heaven_interpret(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
result = self.application_service.heaven_interpret(body)
|
||||
self.send_json({"ok": True, **result})
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class HeavenRepositoryMixin:
|
||||
@staticmethod
|
||||
def _heaven_reading_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"mode": str(row["mode"]),
|
||||
"context_date": str(row["context_date"]),
|
||||
"subject": str(row["subject"]),
|
||||
"subject_detail": str(row["subject_detail"]),
|
||||
"answer": str(row["answer"]),
|
||||
"created_at": str(row["created_at"]),
|
||||
}
|
||||
|
||||
def save_heaven_reading(
|
||||
self,
|
||||
user_id: int,
|
||||
mode: str,
|
||||
context_date: str,
|
||||
subject: str,
|
||||
subject_detail: str,
|
||||
answer: str,
|
||||
context_snapshot: dict[str, Any],
|
||||
dedupe_key: str,
|
||||
) -> dict[str, Any]:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
snapshot_json = json.dumps(
|
||||
context_snapshot, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO heaven_readings
|
||||
(user_id, mode, context_date, subject, subject_detail, answer,
|
||||
context_snapshot, dedupe_key, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, dedupe_key) DO NOTHING
|
||||
""",
|
||||
(
|
||||
int(user_id), mode, context_date, subject, subject_detail,
|
||||
answer, snapshot_json, dedupe_key, now,
|
||||
),
|
||||
)
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT id, mode, context_date, subject, subject_detail, answer, created_at
|
||||
FROM heaven_readings WHERE user_id = ? AND dedupe_key = ?
|
||||
""",
|
||||
(int(user_id), dedupe_key),
|
||||
).fetchone()
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM heaven_readings
|
||||
WHERE user_id = ? AND mode = ? AND id NOT IN (
|
||||
SELECT id FROM heaven_readings
|
||||
WHERE user_id = ? AND mode = ? ORDER BY id DESC LIMIT 100
|
||||
)
|
||||
""",
|
||||
(int(user_id), mode, int(user_id), mode),
|
||||
)
|
||||
result = self._heaven_reading_dict(row)
|
||||
if not result:
|
||||
raise ValueError("解读记录保存失败。")
|
||||
return result
|
||||
|
||||
def list_heaven_readings(
|
||||
self,
|
||||
user_id: int,
|
||||
mode: str,
|
||||
context_date: str = "",
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = ["user_id = ?", "mode = ?"]
|
||||
parameters: list[Any] = [int(user_id), mode]
|
||||
if context_date:
|
||||
clauses.append("context_date = ?")
|
||||
parameters.append(context_date)
|
||||
parameters.append(max(1, min(100, int(limit))))
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT id, mode, context_date, subject, subject_detail, answer, created_at
|
||||
FROM heaven_readings WHERE {' AND '.join(clauses)}
|
||||
ORDER BY context_date DESC, id DESC LIMIT ?
|
||||
""",
|
||||
parameters,
|
||||
).fetchall()
|
||||
return [self._heaven_reading_dict(row) for row in rows if row]
|
||||
|
||||
def latest_heaven_reading(
|
||||
self, user_id: int, mode: str, context_date: str = ""
|
||||
) -> dict[str, Any] | None:
|
||||
items = self.list_heaven_readings(user_id, mode, context_date, 1)
|
||||
return items[0] if items else None
|
||||
|
||||
def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM heaven_readings WHERE id = ? AND user_id = ?",
|
||||
(int(reading_id), int(user_id)),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ from datetime import datetime, time as dt_time, timedelta
|
||||
from threading import Lock
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from backend.bootstrap.config import tushare_code as _stock_market_code
|
||||
from backend.data.providers.ifind_client import IfindError, IfindHttpClient
|
||||
|
||||
|
||||
@@ -475,16 +476,6 @@ def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
|
||||
def _stock_market_code(code: str) -> str:
|
||||
if code.startswith(("4", "8", "9")):
|
||||
suffix = "BJ"
|
||||
elif code.startswith("6"):
|
||||
suffix = "SH"
|
||||
else:
|
||||
suffix = "SZ"
|
||||
return f"{code}.{suffix}"
|
||||
|
||||
|
||||
def _number(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
|
||||
@@ -3,14 +3,12 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from llm_stream import OpenAIStreamAccumulator
|
||||
from backend.llm import transport as llm_transport
|
||||
|
||||
|
||||
class MentorAgentError(RuntimeError):
|
||||
@@ -195,50 +193,20 @@ def stream_with_mentor(
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
messages.extend(history[-10:])
|
||||
messages.append({"role": "user", "content": question})
|
||||
payload = json.dumps(
|
||||
{"model": model, "messages": messages, "stream": True},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}/chat/completions",
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": "XiaobaiReviewWeb/0.6",
|
||||
"Accept": "text/event-stream",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
yielded = False
|
||||
accumulator = OpenAIStreamAccumulator()
|
||||
for raw_line in response:
|
||||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||
if not line or line.startswith(":"):
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
line = line[5:].strip()
|
||||
if line == "[DONE]":
|
||||
break
|
||||
try:
|
||||
result = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = result.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
choice = choices[0] or {}
|
||||
content = accumulator.feed(choice)
|
||||
if content:
|
||||
yielded = True
|
||||
yield str(content)
|
||||
if not yielded:
|
||||
raise MentorAgentError("问师模型未返回有效内容。")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise MentorAgentError(_http_error_message(exc)) from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
yield from llm_transport.stream_chat_completion(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
messages=messages,
|
||||
timeout=timeout,
|
||||
user_agent="XiaobaiReviewWeb/0.6",
|
||||
)
|
||||
except llm_transport.OpenAIEmptyResponseError as exc:
|
||||
raise MentorAgentError("问师模型未返回有效内容。") from exc
|
||||
except llm_transport.OpenAIHTTPError as exc:
|
||||
raise MentorAgentError(exc.describe("问师模型调用失败")) from exc
|
||||
except llm_transport.OpenAITransportError as exc:
|
||||
raise MentorAgentError(f"问师模型调用失败:{exc}") from exc
|
||||
|
||||
|
||||
@@ -298,20 +266,3 @@ def _parse_frontmatter(content: str) -> dict[str, str]:
|
||||
def _first_sentence(text: str) -> str:
|
||||
compact = " ".join(line.strip() for line in text.splitlines() if line.strip())
|
||||
return re.split(r"[。;]", compact, maxsplit=1)[0].strip()
|
||||
|
||||
|
||||
def _http_error_message(exc: urllib.error.HTTPError) -> str:
|
||||
detail = ""
|
||||
try:
|
||||
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
detail = str(error.get("message") or error.get("code") or "")
|
||||
elif error:
|
||||
detail = str(error)
|
||||
elif payload.get("message"):
|
||||
detail = str(payload["message"])
|
||||
except (json.JSONDecodeError, OSError):
|
||||
detail = ""
|
||||
suffix = f":{detail[:300]}" if detail else ""
|
||||
return f"问师模型调用失败(HTTP {exc.code}){suffix}"
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
from .trade_journal import EMOTIONS, TRADE_ACTIONS, TradeJournalService
|
||||
from .agent import ReviewAssistantError, stream_review_assistant
|
||||
from .http import ReviewHttpMixin
|
||||
from .repository import ReviewRepositoryMixin
|
||||
from .service import ReviewServiceMixin
|
||||
|
||||
__all__ = ["EMOTIONS", "TRADE_ACTIONS", "TradeJournalService"]
|
||||
__all__ = [
|
||||
"EMOTIONS",
|
||||
"ReviewAssistantError",
|
||||
"ReviewHttpMixin",
|
||||
"ReviewRepositoryMixin",
|
||||
"ReviewServiceMixin",
|
||||
"TRADE_ACTIONS",
|
||||
"TradeJournalService",
|
||||
"stream_review_assistant",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in {"EMOTIONS", "TRADE_ACTIONS", "TradeJournalService"}:
|
||||
from . import trade_journal
|
||||
|
||||
return getattr(trade_journal, name)
|
||||
raise AttributeError(name)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from backend.llm import transport as llm_transport
|
||||
|
||||
|
||||
class ReviewAssistantError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def stream_review_assistant(
|
||||
context: dict[str, Any],
|
||||
question: str,
|
||||
history: list[dict[str, str]],
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
timeout: int = 120,
|
||||
) -> Iterator[str]:
|
||||
if not api_key or not model:
|
||||
raise ReviewAssistantError("智能解读服务尚未配置。")
|
||||
messages = [{"role": "system", "content": _system_prompt(context)}]
|
||||
messages.extend(history[-12:])
|
||||
messages.append({"role": "user", "content": question})
|
||||
try:
|
||||
yield from llm_transport.stream_chat_completion(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
messages=messages,
|
||||
timeout=timeout,
|
||||
user_agent="XiaobaiReviewWeb/1.0",
|
||||
)
|
||||
except llm_transport.OpenAIEmptyResponseError as exc:
|
||||
raise ReviewAssistantError("智能解读未返回有效内容。") from exc
|
||||
except llm_transport.OpenAIHTTPError as exc:
|
||||
raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc
|
||||
except llm_transport.OpenAITransportError as exc:
|
||||
raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc
|
||||
|
||||
|
||||
def _system_prompt(context: dict[str, Any]) -> str:
|
||||
context_json = json.dumps(context, ensure_ascii=False, separators=(",", ":"))
|
||||
return f"""
|
||||
你是“小白复盘”的统一复盘助手。你负责把网页中已经存在的市场统计、策略跟踪、提醒、复盘笔记和手工交易日志连接起来,帮助用户复盘和形成下一步观察计划。
|
||||
|
||||
最高优先级规则:
|
||||
1. 只能使用下方“网页复盘数据”,数据缺失就明确说明,不得补造行情、交易或胜率。
|
||||
2. 不自动下单,不声称已执行任何操作,不修改策略、提醒、笔记或交易日志。
|
||||
3. 不承诺收益,不给无条件买卖指令。建议必须写成条件、失效条件和风险边界。
|
||||
4. 区分市场事实、用户记录和你的推断。引用数字时写明数据日期。
|
||||
5. 优先结合用户自己的策略跟踪与交易日志寻找可验证的重复模式;样本不足时明确标注。
|
||||
6. 使用中文,先直接回答,再给数据依据和下一步观察。避免空泛口号,不展示模型、接口或内部工程信息。
|
||||
7. 控制在 800 个中文字符以内,除非用户明确要求展开。
|
||||
|
||||
网页复盘数据:
|
||||
{context_json}
|
||||
""".strip()
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from http import HTTPStatus
|
||||
|
||||
from backend.bootstrap.config import normalize_date, validate_stock_code, validate_text
|
||||
from backend.features.review.agent import ReviewAssistantError
|
||||
|
||||
|
||||
class ReviewHttpMixin:
|
||||
def save_trade_entry(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
self.send_json(
|
||||
{"ok": True, **self.application_service.save_trade_entry(body)},
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def stream_assistant_chat(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
stream = self.application_service.assistant_stream(body)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-cache, no-transform")
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
try:
|
||||
for chunk in stream:
|
||||
self._write_stream_event({"type": "delta", "content": chunk})
|
||||
self._write_stream_event({"type": "done"})
|
||||
except (ValueError, ReviewAssistantError) as exc:
|
||||
self._write_stream_event({"type": "error", "error": str(exc)})
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
self.close_connection = True
|
||||
|
||||
def save_watchlist(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
code = validate_stock_code(str(body.get("code", "")))
|
||||
name = validate_text(body.get("name"), "股票名称", 30, required=True)
|
||||
sector = validate_text(body.get("sector"), "所属板块", 50)
|
||||
color = str(body.get("color") or "red")
|
||||
if color not in {"red", "blue", "green", "amber"}:
|
||||
raise ValueError("标记颜色不支持。")
|
||||
remark = validate_text(body.get("remark"), "跟踪备注", 240)
|
||||
service = self.application_service
|
||||
service.database.save_watchlist(
|
||||
service.current_user_id, code, name, sector, color, remark
|
||||
)
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"items": service.database.list_watchlist(service.current_user_id),
|
||||
}
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def save_note(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
code = str(body.get("code") or "").strip()
|
||||
if code:
|
||||
code = validate_stock_code(code)
|
||||
stock_name = validate_text(body.get("stock_name"), "股票名称", 30)
|
||||
trade_date = normalize_date(str(body.get("trade_date") or date.today().isoformat()))
|
||||
summary = validate_text(body.get("summary"), "盘面摘要", 500)
|
||||
content = validate_text(body.get("content"), "复盘内容", 5000)
|
||||
plan = validate_text(body.get("plan"), "明日计划", 2000)
|
||||
if not summary and not content and not plan:
|
||||
raise ValueError("每日复盘内容不能全部为空。")
|
||||
raw_id = body.get("id")
|
||||
note_id = int(raw_id) if raw_id else None
|
||||
service = self.application_service
|
||||
saved_id = service.database.save_note(
|
||||
service.current_user_id,
|
||||
code,
|
||||
stock_name,
|
||||
trade_date,
|
||||
content,
|
||||
plan,
|
||||
note_id,
|
||||
summary=summary,
|
||||
)
|
||||
self.send_json({"ok": True, "id": saved_id})
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ReviewRepositoryMixin:
|
||||
def list_watchlist(self, user_id: int) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT code, name, sector, color, remark, created_at, updated_at
|
||||
FROM watchlist WHERE user_id = ? ORDER BY updated_at DESC, code
|
||||
""",
|
||||
(int(user_id),),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def save_watchlist(
|
||||
self, user_id: int, code: str, name: str, sector: str, color: str,
|
||||
remark: str | None = None,
|
||||
) -> None:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT remark FROM watchlist WHERE user_id = ? AND code = ?",
|
||||
(int(user_id), code),
|
||||
).fetchone()
|
||||
saved_remark = (
|
||||
str(existing["remark"] or "") if remark is None and existing else str(remark or "")
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO watchlist
|
||||
(user_id, code, name, sector, color, remark, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, code) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
sector = excluded.sector,
|
||||
color = excluded.color,
|
||||
remark = excluded.remark,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(int(user_id), code, name, sector, color, saved_remark, now, now),
|
||||
)
|
||||
|
||||
def watchlist_price_history(
|
||||
self, codes: list[str], end_date: str, limit_per_code: int = 6
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
result: dict[str, list[dict[str, Any]]] = {}
|
||||
if not codes:
|
||||
return result
|
||||
with self.connect() as connection:
|
||||
for code in codes:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT trade_date, ts_code, close, pct_chg
|
||||
FROM daily_bars
|
||||
WHERE substr(ts_code, 1, 6) = ? AND trade_date <= ?
|
||||
ORDER BY trade_date DESC LIMIT ?
|
||||
""",
|
||||
(str(code), end_date, int(limit_per_code)),
|
||||
).fetchall()
|
||||
result[str(code)] = [dict(row) for row in reversed(rows)]
|
||||
return result
|
||||
|
||||
def delete_watchlist(self, user_id: int, code: str) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM watchlist WHERE user_id = ? AND code = ?",
|
||||
(int(user_id), code),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def list_notes(
|
||||
self,
|
||||
user_id: int,
|
||||
code: str = "",
|
||||
trade_date: str = "",
|
||||
scope: str = "all",
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = ["user_id = ?"]
|
||||
parameters: list[Any] = [int(user_id)]
|
||||
if scope == "daily":
|
||||
clauses.append("code = ''")
|
||||
elif scope == "stock":
|
||||
clauses.append("code <> ''")
|
||||
if code:
|
||||
clauses.append("code = ?")
|
||||
parameters.append(code)
|
||||
if trade_date:
|
||||
clauses.append("trade_date = ?")
|
||||
parameters.append(trade_date)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at
|
||||
FROM review_notes {where}
|
||||
ORDER BY trade_date DESC, updated_at DESC, id DESC LIMIT 200
|
||||
""",
|
||||
parameters,
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def save_note(
|
||||
self,
|
||||
user_id: int,
|
||||
code: str,
|
||||
stock_name: str,
|
||||
trade_date: str,
|
||||
content: str,
|
||||
plan: str,
|
||||
note_id: int | None = None,
|
||||
summary: str = "",
|
||||
) -> int:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
if note_id:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE review_notes
|
||||
SET code = ?, stock_name = ?, trade_date = ?, summary = ?, content = ?, plan = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(code, stock_name, trade_date, summary, content, plan, now, note_id, int(user_id)),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise ValueError("复盘笔记不存在。")
|
||||
return note_id
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO review_notes
|
||||
(user_id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(int(user_id), code, stock_name, trade_date, summary, content, plan, now, now),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def delete_note(self, user_id: int, note_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM review_notes WHERE id = ? AND user_id = ?",
|
||||
(note_id, int(user_id)),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def save_trade_entry(
|
||||
self,
|
||||
user_id: int,
|
||||
trade_date: str,
|
||||
code: str,
|
||||
name: str,
|
||||
action: str,
|
||||
price: float,
|
||||
quantity: int,
|
||||
position_pct: float,
|
||||
pnl_amount: float | None,
|
||||
pnl_pct: float | None,
|
||||
thesis: str,
|
||||
execution: str,
|
||||
emotion: str,
|
||||
tags: list[str],
|
||||
trade_id: int | None = None,
|
||||
) -> int:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
tags_json = json.dumps(tags, ensure_ascii=False, separators=(",", ":"))
|
||||
with self.connect() as connection:
|
||||
if trade_id:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE trade_entries SET
|
||||
trade_date=?, code=?, name=?, action=?, price=?, quantity=?,
|
||||
position_pct=?, pnl_amount=?, pnl_pct=?, thesis=?, execution=?,
|
||||
emotion=?, tags=?, updated_at=?
|
||||
WHERE id=? AND user_id=?
|
||||
""",
|
||||
(
|
||||
trade_date, code, name, action, price, quantity, position_pct,
|
||||
pnl_amount, pnl_pct, thesis, execution, emotion, tags_json, now,
|
||||
int(trade_id), int(user_id),
|
||||
),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise ValueError("交易记录不存在或无权修改。")
|
||||
return int(trade_id)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO trade_entries
|
||||
(user_id, trade_date, code, name, action, price, quantity,
|
||||
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
|
||||
tags, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(user_id), trade_date, code, name, action, price, quantity,
|
||||
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
|
||||
tags_json, now, now,
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def list_trade_entries(
|
||||
self, user_id: int, start_date: str = "", end_date: str = "", code: str = "",
|
||||
limit: int = 300,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = ["user_id = ?"]
|
||||
parameters: list[Any] = [int(user_id)]
|
||||
if start_date:
|
||||
clauses.append("trade_date >= ?")
|
||||
parameters.append(start_date)
|
||||
if end_date:
|
||||
clauses.append("trade_date <= ?")
|
||||
parameters.append(end_date)
|
||||
if code:
|
||||
clauses.append("code = ?")
|
||||
parameters.append(code)
|
||||
parameters.append(max(1, min(1000, int(limit))))
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT * FROM trade_entries WHERE {' AND '.join(clauses)}
|
||||
ORDER BY trade_date DESC, id DESC LIMIT ?
|
||||
""",
|
||||
parameters,
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def delete_trade_entry(self, user_id: int, trade_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM trade_entries WHERE id = ? AND user_id = ?",
|
||||
(int(trade_id), int(user_id)),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def save_assistant_exchange(
|
||||
self, user_id: int, question: str, answer: str, context_date: str
|
||||
) -> None:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO assistant_messages
|
||||
(user_id, role, content, context_date, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(int(user_id), "user", question, context_date, now),
|
||||
(int(user_id), "assistant", answer, context_date, now),
|
||||
],
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM assistant_messages WHERE user_id = ? AND id NOT IN (
|
||||
SELECT id FROM assistant_messages
|
||||
WHERE user_id = ? ORDER BY id DESC LIMIT 200
|
||||
)
|
||||
""",
|
||||
(int(user_id), int(user_id)),
|
||||
)
|
||||
|
||||
def list_assistant_messages(self, user_id: int, limit: int = 100) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT role, content, context_date, created_at FROM assistant_messages
|
||||
WHERE user_id = ? ORDER BY id DESC LIMIT ?
|
||||
""",
|
||||
(int(user_id), max(1, min(200, int(limit)))),
|
||||
).fetchall()
|
||||
return [dict(row) for row in reversed(rows)]
|
||||
|
||||
def delete_assistant_messages(self, user_id: int) -> int:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),)
|
||||
)
|
||||
return int(cursor.rowcount)
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from backend.bootstrap.config import normalize_date, tushare_code, validate_text
|
||||
from backend.data.providers.tushare_client import TushareError
|
||||
from backend.features.review.agent import ReviewAssistantError, stream_review_assistant
|
||||
|
||||
|
||||
class ReviewServiceMixin:
|
||||
def trade_entries(
|
||||
self, start_date: str = "", end_date: str = "", code: str = ""
|
||||
) -> dict[str, Any]:
|
||||
return self.trade_journal.list_entries(
|
||||
self.current_user_id, start_date, end_date, code
|
||||
)
|
||||
|
||||
def review_watchlist(self, trade_date: str) -> dict[str, Any]:
|
||||
normalized_date = normalize_date(trade_date)
|
||||
items = self.database.list_watchlist(self.current_user_id)
|
||||
if not items:
|
||||
return {"items": [], "trade_date": normalized_date}
|
||||
|
||||
resolved_date = normalized_date
|
||||
if self.configured:
|
||||
try:
|
||||
client = self._tushare_client()
|
||||
resolved_date, _ = client.resolve_trade_context(normalized_date)
|
||||
history = self.database.watchlist_price_history(
|
||||
[str(item["code"]) for item in items], resolved_date
|
||||
)
|
||||
missing_codes = [
|
||||
str(item["code"]) for item in items
|
||||
if len(history.get(str(item["code"])) or []) < 6
|
||||
]
|
||||
start_date = (
|
||||
datetime.strptime(resolved_date, "%Y%m%d") - timedelta(days=24)
|
||||
).strftime("%Y%m%d")
|
||||
for code in missing_codes:
|
||||
rows = client.query(
|
||||
"daily",
|
||||
{
|
||||
"ts_code": tushare_code(code),
|
||||
"start_date": start_date,
|
||||
"end_date": resolved_date,
|
||||
},
|
||||
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
||||
)
|
||||
if rows:
|
||||
self.database.upsert_daily_bars(rows)
|
||||
if missing_codes:
|
||||
history = self.database.watchlist_price_history(
|
||||
[str(item["code"]) for item in items], resolved_date
|
||||
)
|
||||
except (TushareError, ValueError):
|
||||
history = self.database.watchlist_price_history(
|
||||
[str(item["code"]) for item in items], resolved_date
|
||||
)
|
||||
else:
|
||||
history = self.database.watchlist_price_history(
|
||||
[str(item["code"]) for item in items], resolved_date
|
||||
)
|
||||
|
||||
auction_scores: dict[str, Any] = {}
|
||||
try:
|
||||
auction = self.auction_center(normalized_date, False)
|
||||
auction_scores = {
|
||||
str(row.get("code") or ""): row.get("attention_score")
|
||||
for row in (auction.get("watchlist_rows") or [])
|
||||
if row.get("available", True)
|
||||
}
|
||||
except (TushareError, ValueError):
|
||||
pass
|
||||
|
||||
enriched = []
|
||||
for item in items:
|
||||
code = str(item.get("code") or "")
|
||||
bars = history.get(code) or []
|
||||
latest = bars[-1] if bars else {}
|
||||
close = float(latest.get("close") or 0)
|
||||
base_close = float(bars[-6].get("close") or 0) if len(bars) >= 6 else 0
|
||||
enriched.append(
|
||||
{
|
||||
**item,
|
||||
"change": (
|
||||
round(float(latest.get("pct_chg") or 0), 2) if latest else None
|
||||
),
|
||||
"return_5d": (
|
||||
round((close / base_close - 1) * 100, 2)
|
||||
if close > 0 and base_close > 0 else None
|
||||
),
|
||||
"attention_score": auction_scores.get(code),
|
||||
"market_date": str(latest.get("trade_date") or ""),
|
||||
}
|
||||
)
|
||||
return {"items": enriched, "trade_date": resolved_date}
|
||||
|
||||
def save_trade_entry(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
trade_id = self.trade_journal.save(self.current_user_id, payload)
|
||||
return {"id": trade_id, **self.trade_entries()}
|
||||
|
||||
def delete_trade_entry(self, trade_id: int) -> dict[str, Any]:
|
||||
deleted = self.trade_journal.delete(self.current_user_id, trade_id)
|
||||
return {"deleted": deleted, **self.trade_entries()}
|
||||
|
||||
def assistant_messages(self) -> list[dict[str, Any]]:
|
||||
return self.database.list_assistant_messages(self.current_user_id)
|
||||
|
||||
def clear_assistant_messages(self) -> int:
|
||||
return self.database.delete_assistant_messages(self.current_user_id)
|
||||
|
||||
def assistant_stream(self, payload: dict[str, Any]):
|
||||
question = validate_text(payload.get("question"), "问题", 2000, required=True)
|
||||
trade_date = normalize_date(
|
||||
str(payload.get("trade_date") or date.today().isoformat())
|
||||
)
|
||||
context = self._assistant_context(trade_date)
|
||||
history = [
|
||||
{"role": item["role"], "content": str(item["content"])[:4000]}
|
||||
for item in self.assistant_messages()[-12:]
|
||||
if item.get("role") in {"user", "assistant"}
|
||||
]
|
||||
def generate():
|
||||
answer_parts: list[str] = []
|
||||
events = self.llm_gateway.stream(
|
||||
"assistant",
|
||||
"review-assistant-v1",
|
||||
lambda profile: stream_review_assistant(
|
||||
context,
|
||||
question,
|
||||
history,
|
||||
profile.api_key,
|
||||
profile.base_url,
|
||||
profile.model,
|
||||
),
|
||||
(ReviewAssistantError,),
|
||||
)
|
||||
for event in events:
|
||||
if event.kind == "delta":
|
||||
chunk = str(event.value or "")
|
||||
answer_parts.append(chunk)
|
||||
yield chunk
|
||||
elif event.kind == "complete":
|
||||
self.database.save_assistant_exchange(
|
||||
self.current_user_id,
|
||||
question,
|
||||
"".join(answer_parts).strip(),
|
||||
trade_date,
|
||||
)
|
||||
|
||||
return generate()
|
||||
|
||||
def _assistant_context(self, trade_date: str) -> dict[str, Any]:
|
||||
dashboard = self.get_dashboard(trade_date)
|
||||
actual_date = normalize_date(
|
||||
str((dashboard.get("meta") or {}).get("trade_date") or trade_date)
|
||||
)
|
||||
sentiment = self.sentiment_history(actual_date, 10)
|
||||
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 5)
|
||||
alerts = self.alert_service.list_alerts(
|
||||
self.current_user_id, "all", date.today().isoformat()
|
||||
)
|
||||
trades = self.trade_journal.list_entries(
|
||||
self.current_user_id, end_date=actual_date
|
||||
)
|
||||
return {
|
||||
"data_date": actual_date,
|
||||
"market": {
|
||||
"overview": dashboard.get("overview") or {},
|
||||
"top_sectors": (dashboard.get("sectors") or [])[:8],
|
||||
"limit_performance": dashboard.get("limit_performance") or {},
|
||||
"sentiment_history": (sentiment.get("rows") or [])[-10:],
|
||||
},
|
||||
"personal": {
|
||||
"watchlist": self.database.list_watchlist(self.current_user_id)[:30],
|
||||
"review_notes": self.database.list_notes(
|
||||
self.current_user_id, scope="daily"
|
||||
)[:10],
|
||||
"strategy_tracking": {
|
||||
"summary": tracking.get("summary") or {},
|
||||
"batches": (tracking.get("batches") or [])[:5],
|
||||
},
|
||||
"alerts": (alerts.get("items") or [])[:20],
|
||||
"trade_summary": trades.get("summary") or {},
|
||||
"trade_entries": (trades.get("items") or [])[:30],
|
||||
},
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from backend.llm import transport as llm_transport
|
||||
from screener import FACTOR_FIELDS, REGIMES
|
||||
|
||||
|
||||
@@ -21,39 +19,25 @@ def test_llm_connection(
|
||||
) -> dict[str, Any]:
|
||||
if not api_key or not model:
|
||||
raise LLMCompilerError("API Key 或模型未配置。")
|
||||
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "只回复 OK"}],
|
||||
"stream": False,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
endpoint,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": "XiaobaiReviewWeb/0.5",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
reply = str(result["choices"][0]["message"]["content"]).strip()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise LLMCompilerError(_http_error_message(exc)) from exc
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
||||
result = llm_transport.chat_completion(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "只回复 OK"}],
|
||||
timeout=timeout,
|
||||
user_agent="XiaobaiReviewWeb/0.5",
|
||||
)
|
||||
reply = str(result.content).strip()
|
||||
except llm_transport.OpenAIHTTPError as exc:
|
||||
raise LLMCompilerError(exc.describe("模型连接测试失败")) from exc
|
||||
except llm_transport.OpenAITransportError as exc:
|
||||
raise LLMCompilerError(f"模型连接测试失败:{exc}") from exc
|
||||
return {
|
||||
"ok": True,
|
||||
"model": model,
|
||||
"reply": reply[:100],
|
||||
"latency_ms": round((time.perf_counter() - started) * 1000),
|
||||
"latency_ms": result.latency_ms,
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +51,6 @@ def compile_strategy_with_llm(
|
||||
) -> dict[str, Any]:
|
||||
if not api_key or not model:
|
||||
raise LLMCompilerError("尚未配置 LLM API Key 或模型。")
|
||||
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
||||
schema = {
|
||||
"name": "策略名称",
|
||||
"description": "策略说明",
|
||||
@@ -90,57 +73,28 @@ def compile_strategy_with_llm(
|
||||
"退潮和冰点策略必须提高门槛并允许结果为空。"
|
||||
f"严格遵循以下结构:{json.dumps(schema, ensure_ascii=False)}"
|
||||
)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"messages": [
|
||||
try:
|
||||
result = llm_transport.chat_completion(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt[:3000]},
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
endpoint,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": "XiaobaiReviewWeb/0.4",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
content = result["choices"][0]["message"]["content"].strip()
|
||||
timeout=timeout,
|
||||
user_agent="XiaobaiReviewWeb/0.4",
|
||||
)
|
||||
content = result.content.strip()
|
||||
if content.startswith("```"):
|
||||
content = content.strip("`")
|
||||
if content.startswith("json"):
|
||||
content = content[4:].strip()
|
||||
compiled = json.loads(content)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise LLMCompilerError(_http_error_message(exc).replace("模型连接测试", "LLM 策略编译")) from exc
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
||||
except llm_transport.OpenAIHTTPError as exc:
|
||||
raise LLMCompilerError(exc.describe("LLM 策略编译失败")) from exc
|
||||
except (llm_transport.OpenAITransportError, json.JSONDecodeError) as exc:
|
||||
raise LLMCompilerError(f"LLM 策略编译失败:{exc}") from exc
|
||||
compiled["compiler"] = "llm"
|
||||
compiled["model"] = model
|
||||
return compiled
|
||||
|
||||
|
||||
def _http_error_message(exc: urllib.error.HTTPError) -> str:
|
||||
detail = ""
|
||||
try:
|
||||
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
detail = str(error.get("message") or error.get("code") or "")
|
||||
elif error:
|
||||
detail = str(error)
|
||||
elif payload.get("message"):
|
||||
detail = str(payload["message"])
|
||||
except (json.JSONDecodeError, OSError):
|
||||
detail = ""
|
||||
suffix = f":{detail[:300]}" if detail else ""
|
||||
return f"模型连接测试失败(HTTP {exc.code}){suffix}"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .stream import OpenAIStreamAccumulator
|
||||
|
||||
|
||||
class OpenAITransportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class OpenAIHTTPError(OpenAITransportError):
|
||||
def __init__(self, code: int, detail: str = "") -> None:
|
||||
super().__init__(f"HTTP {code}")
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
|
||||
def describe(self, label: str) -> str:
|
||||
suffix = f":{self.detail[:300]}" if self.detail else ""
|
||||
return f"{label}(HTTP {self.code}){suffix}"
|
||||
|
||||
|
||||
class OpenAIEmptyResponseError(OpenAITransportError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenAIChatCompletion:
|
||||
content: Any
|
||||
latency_ms: int
|
||||
|
||||
|
||||
def chat_completion(
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
timeout: int,
|
||||
user_agent: str,
|
||||
) -> OpenAIChatCompletion:
|
||||
request = _request(api_key, base_url, model, messages, user_agent, stream=False)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise OpenAIHTTPError(exc.code, _http_error_detail(exc)) from exc
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
TimeoutError,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
IndexError,
|
||||
) as exc:
|
||||
raise OpenAITransportError(str(exc)) from exc
|
||||
return OpenAIChatCompletion(
|
||||
content=content,
|
||||
latency_ms=round((time.perf_counter() - started) * 1000),
|
||||
)
|
||||
|
||||
|
||||
def stream_chat_completion(
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
timeout: int,
|
||||
user_agent: str,
|
||||
) -> Iterator[str]:
|
||||
request = _request(api_key, base_url, model, messages, user_agent, stream=True)
|
||||
yielded = False
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
accumulator = OpenAIStreamAccumulator()
|
||||
for raw_line in response:
|
||||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||
if not line or line.startswith(":"):
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
line = line[5:].strip()
|
||||
if line == "[DONE]":
|
||||
break
|
||||
try:
|
||||
result = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = result.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
content = accumulator.feed(choices[0] or {})
|
||||
if content:
|
||||
yielded = True
|
||||
yield str(content)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise OpenAIHTTPError(exc.code, _http_error_detail(exc)) from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
raise OpenAITransportError(str(exc)) from exc
|
||||
if not yielded:
|
||||
raise OpenAIEmptyResponseError("empty response")
|
||||
|
||||
|
||||
def _request(
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
user_agent: str,
|
||||
*,
|
||||
stream: bool,
|
||||
) -> urllib.request.Request:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": user_agent,
|
||||
}
|
||||
if stream:
|
||||
headers["Accept"] = "text/event-stream"
|
||||
return urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}/chat/completions",
|
||||
data=json.dumps(
|
||||
{"model": model, "messages": messages, "stream": stream},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
|
||||
def _http_error_detail(exc: urllib.error.HTTPError) -> str:
|
||||
try:
|
||||
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
return str(error.get("message") or error.get("code") or "")
|
||||
if error:
|
||||
return str(error)
|
||||
return str(payload.get("message") or "")
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return ""
|
||||
+11
-6
@@ -1,12 +1,15 @@
|
||||
# Governance Registries
|
||||
|
||||
These registries describe the approved product surface during architecture migration.
|
||||
These registries describe the approved product surface of the modular preservation candidate.
|
||||
|
||||
- `pages.config.json`: primary page identity, navigation group, access expectation, scrolling,
|
||||
and mobile composition policy.
|
||||
- `features.config.json`: feature ownership, backend access class, data scope, and availability.
|
||||
- `api.config.json`: transitional inventory of current routes, generated from `server.py` and
|
||||
assigned to a feature owner.
|
||||
- `api.config.json`: current routes generated from the preserved `server.py` API surface, with
|
||||
one feature owner and backend access class per route. Its dispatcher implementation lives in
|
||||
`backend/application.py`.
|
||||
- `architecture-inventory.json`: generated inventory of candidate pages, routes, tables,
|
||||
providers, model entry points, CSS layers, and remaining code hotspots.
|
||||
- `data-fields.config.json`: canonical data products, provider eligibility, intended use, and
|
||||
known blocked datasets.
|
||||
- `data-quality.config.json`: freshness, coverage, units, adjustment, point-in-time, and
|
||||
@@ -14,13 +17,15 @@ These registries describe the approved product surface during architecture migra
|
||||
- `jobs.config.json`: background schedules, dependencies, lock keys, retry policy, timeouts,
|
||||
and output versions.
|
||||
|
||||
During Stage 04 these files are contract inputs, not runtime replacements. Backend access in
|
||||
`api_access.py` remains authoritative until the HTTP governance phase switches it atomically.
|
||||
Frontend visibility remains a presentation concern and never grants backend access.
|
||||
The registries are governance contracts, not substitutes for runtime authorization. Backend
|
||||
access in `backend/http/routes.py` is authoritative; frontend visibility is only a presentation
|
||||
concern and never grants access.
|
||||
|
||||
Regenerate the transitional API inventory after a route change:
|
||||
|
||||
```shell
|
||||
python tools/build_api_registry.py
|
||||
python tools/build_api_registry.py --check
|
||||
python tools/build_architecture_inventory.py
|
||||
python tools/build_architecture_inventory.py --check
|
||||
```
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"captured_from": "app accepted modular runtime",
|
||||
"runtime": {
|
||||
"http_server": "http.server.ThreadingHTTPServer",
|
||||
"application_processes": 1,
|
||||
"database": "SQLite WAL",
|
||||
"frontend": "build-free HTML/CSS/JavaScript",
|
||||
"container_port": 8765
|
||||
},
|
||||
"counts": {
|
||||
"primary_pages": 16,
|
||||
"api_exact_paths": 53,
|
||||
"api_prefixes": 0,
|
||||
"api_patterns": 11,
|
||||
"database_tables": 36
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"id": "sentimentCycleView",
|
||||
"title": "情绪周期"
|
||||
},
|
||||
{
|
||||
"id": "limitPool",
|
||||
"title": "涨停池"
|
||||
},
|
||||
{
|
||||
"id": "brokenView",
|
||||
"title": "炸板池"
|
||||
},
|
||||
{
|
||||
"id": "downView",
|
||||
"title": "跌停板"
|
||||
},
|
||||
{
|
||||
"id": "yesterdayView",
|
||||
"title": "昨日涨停"
|
||||
},
|
||||
{
|
||||
"id": "performanceView",
|
||||
"title": "涨停表现"
|
||||
},
|
||||
{
|
||||
"id": "ladderView",
|
||||
"title": "市场天梯"
|
||||
},
|
||||
{
|
||||
"id": "rotationView",
|
||||
"title": "板块轮动"
|
||||
},
|
||||
{
|
||||
"id": "auctionView",
|
||||
"title": "集合竞价"
|
||||
},
|
||||
{
|
||||
"id": "themeLibraryView",
|
||||
"title": "题材库"
|
||||
},
|
||||
{
|
||||
"id": "popularityView",
|
||||
"title": "人气热榜"
|
||||
},
|
||||
{
|
||||
"id": "dragonView",
|
||||
"title": "龙虎榜"
|
||||
},
|
||||
{
|
||||
"id": "screenerView",
|
||||
"title": "智能选股"
|
||||
},
|
||||
{
|
||||
"id": "mentorView",
|
||||
"title": "问师"
|
||||
},
|
||||
{
|
||||
"id": "heavenView",
|
||||
"title": "问天"
|
||||
},
|
||||
{
|
||||
"id": "reviewWorkspaceView",
|
||||
"title": "我的复盘"
|
||||
}
|
||||
],
|
||||
"api": {
|
||||
"exact": [
|
||||
"/api/account/birth-profile",
|
||||
"/api/account/password",
|
||||
"/api/account/status",
|
||||
"/api/admin/membership",
|
||||
"/api/admin/refresh",
|
||||
"/api/admin/settings",
|
||||
"/api/admin/settings/test",
|
||||
"/api/alerts",
|
||||
"/api/alerts/read-all",
|
||||
"/api/assistant/chat",
|
||||
"/api/assistant/messages",
|
||||
"/api/auction",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/me",
|
||||
"/api/auth/register",
|
||||
"/api/backfill",
|
||||
"/api/chart/intraday",
|
||||
"/api/dashboard",
|
||||
"/api/dragon-tiger",
|
||||
"/api/dragon-tiger/profiles",
|
||||
"/api/health",
|
||||
"/api/heaven/hexagram",
|
||||
"/api/heaven/interpret",
|
||||
"/api/heaven/personal",
|
||||
"/api/heaven/readings",
|
||||
"/api/heaven/sector-phases",
|
||||
"/api/heaven/setup",
|
||||
"/api/mentors/chat",
|
||||
"/api/mentors/messages",
|
||||
"/api/mentors/preferences",
|
||||
"/api/mentors/setup",
|
||||
"/api/notes",
|
||||
"/api/popularity",
|
||||
"/api/realtime-aggregate/health",
|
||||
"/api/reasons",
|
||||
"/api/rotation/history",
|
||||
"/api/rotation/members",
|
||||
"/api/screener/compile",
|
||||
"/api/screener/run",
|
||||
"/api/screener/setup",
|
||||
"/api/screener/strategies",
|
||||
"/api/screener/sync",
|
||||
"/api/screener/tracking",
|
||||
"/api/screener/tracking/refresh",
|
||||
"/api/search",
|
||||
"/api/search/detail",
|
||||
"/api/seat-aliases",
|
||||
"/api/sentiment/history",
|
||||
"/api/themes",
|
||||
"/api/themes/detail",
|
||||
"/api/trades",
|
||||
"/api/watchlist"
|
||||
],
|
||||
"prefixes": [],
|
||||
"patterns": [
|
||||
"/api/alerts/(\\d+)",
|
||||
"/api/alerts/(\\d+)/read",
|
||||
"/api/heaven/readings/(\\d+)",
|
||||
"/api/heaven/sector-phases/(.+)",
|
||||
"/api/notes/(\\d+)",
|
||||
"/api/screener/strategies/(\\d+)",
|
||||
"/api/screener/tracking/(\\d+)",
|
||||
"/api/stock/(\\d{6})",
|
||||
"/api/stock/(\\d{6})/preview",
|
||||
"/api/trades/(\\d+)",
|
||||
"/api/watchlist/(\\d{6})"
|
||||
]
|
||||
},
|
||||
"database_tables": [
|
||||
"users",
|
||||
"user_sessions",
|
||||
"user_credentials",
|
||||
"user_birth_profiles",
|
||||
"system_settings",
|
||||
"llm_usage",
|
||||
"dashboard_snapshots",
|
||||
"sync_runs",
|
||||
"data_snapshots",
|
||||
"watchlist",
|
||||
"review_notes",
|
||||
"reason_overrides",
|
||||
"seat_aliases",
|
||||
"sector_phase_overrides",
|
||||
"stock_master",
|
||||
"daily_bars",
|
||||
"benchmark_bars",
|
||||
"daily_indicators",
|
||||
"fundamental_indicators",
|
||||
"moneyflow_daily",
|
||||
"auction_factors",
|
||||
"earnings_events",
|
||||
"popularity_factors",
|
||||
"lhb_institution_daily",
|
||||
"screener_strategies",
|
||||
"screener_runs",
|
||||
"mentor_messages",
|
||||
"mentor_preferences",
|
||||
"wencai_saved_queries",
|
||||
"strategy_tracks",
|
||||
"alerts",
|
||||
"trade_entries",
|
||||
"assistant_messages",
|
||||
"heaven_readings",
|
||||
"job_runs",
|
||||
"schema_migrations"
|
||||
],
|
||||
"background_job_methods": [
|
||||
"_background_refresh_tick"
|
||||
],
|
||||
"external_data_adapters": [
|
||||
{
|
||||
"provider": "tushare",
|
||||
"path": "backend/data/providers/tushare_client.py",
|
||||
"runtime_role": "primary deterministic market data"
|
||||
},
|
||||
{
|
||||
"provider": "ifind",
|
||||
"path": "backend/data/providers/ifind_client.py",
|
||||
"runtime_role": "realtime, charts, snapshots, enrichment"
|
||||
},
|
||||
{
|
||||
"provider": "eastmoney",
|
||||
"path": "backend/features/market/charts.py",
|
||||
"runtime_role": "display chart fallback"
|
||||
},
|
||||
{
|
||||
"provider": "eastmoney",
|
||||
"path": "backend/data/realtime.py",
|
||||
"runtime_role": "isolated realtime observation"
|
||||
},
|
||||
{
|
||||
"provider": "tencent",
|
||||
"path": "backend/data/realtime.py",
|
||||
"runtime_role": "index observation fallback"
|
||||
}
|
||||
],
|
||||
"llm_entrypoints": [
|
||||
{
|
||||
"function": "stream_with_mentor",
|
||||
"path": "backend/features/mentor/agent.py"
|
||||
},
|
||||
{
|
||||
"function": "interpret_heaven",
|
||||
"path": "backend/features/heaven/agent.py"
|
||||
},
|
||||
{
|
||||
"function": "stream_review_assistant",
|
||||
"path": "backend/features/review/agent.py"
|
||||
},
|
||||
{
|
||||
"function": "compile_strategy_with_llm",
|
||||
"path": "backend/features/screener/compiler.py"
|
||||
},
|
||||
{
|
||||
"function": "test_llm_connection",
|
||||
"path": "backend/features/screener/compiler.py"
|
||||
}
|
||||
],
|
||||
"llm_transport": [
|
||||
{
|
||||
"function": "chat_completion",
|
||||
"path": "backend/llm/transport.py"
|
||||
},
|
||||
{
|
||||
"function": "stream_chat_completion",
|
||||
"path": "backend/llm/transport.py"
|
||||
}
|
||||
],
|
||||
"css_layers": [
|
||||
"/shared/tokens.css?v=20260729-1",
|
||||
"/styles/styles.css",
|
||||
"/styles/renovation.css?v=20260725-5",
|
||||
"/styles/redesign-v2.css?v=20260728-1",
|
||||
"/styles/design-system.css?v=20260728-4",
|
||||
"/styles/theme.css?v=20260728-2",
|
||||
"/pages/heaven/page.css?v=20260728-7"
|
||||
],
|
||||
"code_hotspots": [
|
||||
{
|
||||
"path": "frontend/styles/styles.css",
|
||||
"bytes": 361776,
|
||||
"lines": 15465
|
||||
},
|
||||
{
|
||||
"path": "frontend/styles/redesign-v2.css",
|
||||
"bytes": 263539,
|
||||
"lines": 8570
|
||||
},
|
||||
{
|
||||
"path": "frontend/index.html",
|
||||
"bytes": 135019,
|
||||
"lines": 1892
|
||||
},
|
||||
{
|
||||
"path": "backend/features/screener/engine.py",
|
||||
"bytes": 108552,
|
||||
"lines": 2213
|
||||
},
|
||||
{
|
||||
"path": "backend/data/providers/tushare_client.py",
|
||||
"bytes": 94329,
|
||||
"lines": 2175
|
||||
},
|
||||
{
|
||||
"path": "frontend/app.js",
|
||||
"bytes": 89213,
|
||||
"lines": 1939
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages/heaven/page.js",
|
||||
"bytes": 86493,
|
||||
"lines": 1830
|
||||
},
|
||||
{
|
||||
"path": "frontend/styles/renovation.css",
|
||||
"bytes": 83949,
|
||||
"lines": 1553
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages/heaven/page.css",
|
||||
"bytes": 73222,
|
||||
"lines": 1084
|
||||
},
|
||||
{
|
||||
"path": "backend/features/heaven/service.py",
|
||||
"bytes": 63123,
|
||||
"lines": 1303
|
||||
},
|
||||
{
|
||||
"path": "backend/features/market/insights.py",
|
||||
"bytes": 58150,
|
||||
"lines": 1314
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages/market/runtime.js",
|
||||
"bytes": 55720,
|
||||
"lines": 1333
|
||||
},
|
||||
{
|
||||
"path": "backend/features/heaven/engine.py",
|
||||
"bytes": 51670,
|
||||
"lines": 1181
|
||||
},
|
||||
{
|
||||
"path": "backend/application.py",
|
||||
"bytes": 48749,
|
||||
"lines": 1129
|
||||
},
|
||||
{
|
||||
"path": "frontend/styles/theme.css",
|
||||
"bytes": 36427,
|
||||
"lines": 1253
|
||||
},
|
||||
{
|
||||
"path": "database.py",
|
||||
"bytes": 33284,
|
||||
"lines": 746
|
||||
}
|
||||
]
|
||||
}
|
||||
+6
-486
@@ -8,12 +8,15 @@ from typing import Any
|
||||
|
||||
from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory
|
||||
from backend.features.accounts.repository import AccountRepositoryMixin
|
||||
from backend.features.alerts.repository import AlertRepositoryMixin
|
||||
from backend.features.auction.repository import AuctionRepositoryMixin
|
||||
from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin
|
||||
from backend.features.heaven.repository import HeavenRepositoryMixin
|
||||
from backend.features.market.repository import MarketRepositoryMixin
|
||||
from backend.features.mentor.repository import MentorRepositoryMixin
|
||||
from backend.features.pools.repository import PoolRepositoryMixin
|
||||
from backend.features.popularity.repository import PopularityRepositoryMixin
|
||||
from backend.features.review.repository import ReviewRepositoryMixin
|
||||
from backend.features.screener.repository import ScreenerRepositoryMixin
|
||||
from backend.features.system.repository import SystemSettingsRepositoryMixin
|
||||
from backend.llm.repository import LLMAuditRepositoryMixin
|
||||
@@ -21,12 +24,15 @@ from backend.llm.repository import LLMAuditRepositoryMixin
|
||||
|
||||
class ReviewDatabase(
|
||||
AccountRepositoryMixin,
|
||||
AlertRepositoryMixin,
|
||||
AuctionRepositoryMixin,
|
||||
DragonTigerRepositoryMixin,
|
||||
HeavenRepositoryMixin,
|
||||
MarketRepositoryMixin,
|
||||
MentorRepositoryMixin,
|
||||
PoolRepositoryMixin,
|
||||
PopularityRepositoryMixin,
|
||||
ReviewRepositoryMixin,
|
||||
ScreenerRepositoryMixin,
|
||||
SystemSettingsRepositoryMixin,
|
||||
LLMAuditRepositoryMixin,
|
||||
@@ -660,147 +666,6 @@ class ReviewDatabase(
|
||||
MigrationRunner().apply(connection, MIGRATIONS)
|
||||
|
||||
|
||||
def list_watchlist(self, user_id: int) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT code, name, sector, color, remark, created_at, updated_at
|
||||
FROM watchlist WHERE user_id = ? ORDER BY updated_at DESC, code
|
||||
""",
|
||||
(int(user_id),),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def save_watchlist(
|
||||
self, user_id: int, code: str, name: str, sector: str, color: str,
|
||||
remark: str | None = None,
|
||||
) -> None:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT remark FROM watchlist WHERE user_id = ? AND code = ?",
|
||||
(int(user_id), code),
|
||||
).fetchone()
|
||||
saved_remark = (
|
||||
str(existing["remark"] or "") if remark is None and existing else str(remark or "")
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO watchlist
|
||||
(user_id, code, name, sector, color, remark, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, code) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
sector = excluded.sector,
|
||||
color = excluded.color,
|
||||
remark = excluded.remark,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(int(user_id), code, name, sector, color, saved_remark, now, now),
|
||||
)
|
||||
|
||||
def watchlist_price_history(
|
||||
self, codes: list[str], end_date: str, limit_per_code: int = 6
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
result: dict[str, list[dict[str, Any]]] = {}
|
||||
if not codes:
|
||||
return result
|
||||
with self.connect() as connection:
|
||||
for code in codes:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT trade_date, ts_code, close, pct_chg
|
||||
FROM daily_bars
|
||||
WHERE substr(ts_code, 1, 6) = ? AND trade_date <= ?
|
||||
ORDER BY trade_date DESC LIMIT ?
|
||||
""",
|
||||
(str(code), end_date, int(limit_per_code)),
|
||||
).fetchall()
|
||||
result[str(code)] = [dict(row) for row in reversed(rows)]
|
||||
return result
|
||||
|
||||
def delete_watchlist(self, user_id: int, code: str) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM watchlist WHERE user_id = ? AND code = ?",
|
||||
(int(user_id), code),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
def list_notes(
|
||||
self,
|
||||
user_id: int,
|
||||
code: str = "",
|
||||
trade_date: str = "",
|
||||
scope: str = "all",
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = ["user_id = ?"]
|
||||
parameters: list[Any] = [int(user_id)]
|
||||
if scope == "daily":
|
||||
clauses.append("code = ''")
|
||||
elif scope == "stock":
|
||||
clauses.append("code <> ''")
|
||||
if code:
|
||||
clauses.append("code = ?")
|
||||
parameters.append(code)
|
||||
if trade_date:
|
||||
clauses.append("trade_date = ?")
|
||||
parameters.append(trade_date)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at
|
||||
FROM review_notes {where}
|
||||
ORDER BY trade_date DESC, updated_at DESC, id DESC LIMIT 200
|
||||
""",
|
||||
parameters,
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def save_note(
|
||||
self,
|
||||
user_id: int,
|
||||
code: str,
|
||||
stock_name: str,
|
||||
trade_date: str,
|
||||
content: str,
|
||||
plan: str,
|
||||
note_id: int | None = None,
|
||||
summary: str = "",
|
||||
) -> int:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
if note_id:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE review_notes
|
||||
SET code = ?, stock_name = ?, trade_date = ?, summary = ?, content = ?, plan = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(code, stock_name, trade_date, summary, content, plan, now, note_id, int(user_id)),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise ValueError("复盘笔记不存在。")
|
||||
return note_id
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO review_notes
|
||||
(user_id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(int(user_id), code, stock_name, trade_date, summary, content, plan, now, now),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def delete_note(self, user_id: int, note_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM review_notes WHERE id = ? AND user_id = ?",
|
||||
(note_id, int(user_id)),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
|
||||
def list_sector_phase_overrides(self) -> dict[str, str]:
|
||||
with self.connect() as connection:
|
||||
@@ -830,10 +695,6 @@ class ReviewDatabase(
|
||||
(name,),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
|
||||
|
||||
def list_wencai_saved_queries(
|
||||
self, user_id: int, limit: int = 30
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -883,344 +744,3 @@ class ReviewDatabase(
|
||||
(int(query_id), int(user_id)),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def save_alert(
|
||||
self,
|
||||
user_id: int,
|
||||
kind: str,
|
||||
title: str,
|
||||
content: str,
|
||||
available_date: str,
|
||||
code: str,
|
||||
dedupe_key: str,
|
||||
) -> int:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO alerts
|
||||
(user_id, kind, title, content, available_date, code, dedupe_key,
|
||||
is_read, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
ON CONFLICT(user_id, dedupe_key) DO UPDATE SET
|
||||
title=excluded.title, content=excluded.content,
|
||||
available_date=excluded.available_date, updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
int(user_id), kind, title, content, available_date, code,
|
||||
dedupe_key, now, now,
|
||||
),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?",
|
||||
(int(user_id), dedupe_key),
|
||||
).fetchone()
|
||||
return int(row["id"])
|
||||
|
||||
def list_alerts(
|
||||
self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100
|
||||
) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
if unread_only:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id, kind, title, content, available_date, code, is_read,
|
||||
created_at, updated_at, read_at
|
||||
FROM alerts
|
||||
WHERE user_id = ? AND available_date <= ? AND is_read = 0
|
||||
ORDER BY available_date DESC, id DESC LIMIT ?
|
||||
""",
|
||||
(int(user_id), as_of, max(1, min(300, int(limit)))),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id, kind, title, content, available_date, code, is_read,
|
||||
created_at, updated_at, read_at
|
||||
FROM alerts WHERE user_id = ?
|
||||
ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END,
|
||||
is_read, available_date, id DESC LIMIT ?
|
||||
""",
|
||||
(int(user_id), as_of, max(1, min(300, int(limit)))),
|
||||
).fetchall()
|
||||
return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows]
|
||||
|
||||
def count_unread_alerts(self, user_id: int, as_of: str) -> int:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS total FROM alerts
|
||||
WHERE user_id = ? AND available_date <= ? AND is_read = 0
|
||||
""",
|
||||
(int(user_id), as_of),
|
||||
).fetchone()
|
||||
return int(row["total"] if row else 0)
|
||||
|
||||
def mark_alert_read(self, user_id: int, alert_id: int) -> bool:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(now, now, int(alert_id), int(user_id)),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def mark_all_alerts_read(self, user_id: int, as_of: str) -> int:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
|
||||
WHERE user_id = ? AND available_date <= ? AND is_read = 0
|
||||
""",
|
||||
(now, now, int(user_id), as_of),
|
||||
)
|
||||
return int(cursor.rowcount)
|
||||
|
||||
def delete_alert(self, user_id: int, alert_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM alerts WHERE id = ? AND user_id = ?",
|
||||
(int(alert_id), int(user_id)),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def save_trade_entry(
|
||||
self,
|
||||
user_id: int,
|
||||
trade_date: str,
|
||||
code: str,
|
||||
name: str,
|
||||
action: str,
|
||||
price: float,
|
||||
quantity: int,
|
||||
position_pct: float,
|
||||
pnl_amount: float | None,
|
||||
pnl_pct: float | None,
|
||||
thesis: str,
|
||||
execution: str,
|
||||
emotion: str,
|
||||
tags: list[str],
|
||||
trade_id: int | None = None,
|
||||
) -> int:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
tags_json = json.dumps(tags, ensure_ascii=False, separators=(",", ":"))
|
||||
with self.connect() as connection:
|
||||
if trade_id:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE trade_entries SET
|
||||
trade_date=?, code=?, name=?, action=?, price=?, quantity=?,
|
||||
position_pct=?, pnl_amount=?, pnl_pct=?, thesis=?, execution=?,
|
||||
emotion=?, tags=?, updated_at=?
|
||||
WHERE id=? AND user_id=?
|
||||
""",
|
||||
(
|
||||
trade_date, code, name, action, price, quantity, position_pct,
|
||||
pnl_amount, pnl_pct, thesis, execution, emotion, tags_json, now,
|
||||
int(trade_id), int(user_id),
|
||||
),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise ValueError("交易记录不存在或无权修改。")
|
||||
return int(trade_id)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO trade_entries
|
||||
(user_id, trade_date, code, name, action, price, quantity,
|
||||
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
|
||||
tags, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(user_id), trade_date, code, name, action, price, quantity,
|
||||
position_pct, pnl_amount, pnl_pct, thesis, execution, emotion,
|
||||
tags_json, now, now,
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def list_trade_entries(
|
||||
self, user_id: int, start_date: str = "", end_date: str = "", code: str = "",
|
||||
limit: int = 300,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = ["user_id = ?"]
|
||||
parameters: list[Any] = [int(user_id)]
|
||||
if start_date:
|
||||
clauses.append("trade_date >= ?")
|
||||
parameters.append(start_date)
|
||||
if end_date:
|
||||
clauses.append("trade_date <= ?")
|
||||
parameters.append(end_date)
|
||||
if code:
|
||||
clauses.append("code = ?")
|
||||
parameters.append(code)
|
||||
parameters.append(max(1, min(1000, int(limit))))
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT * FROM trade_entries WHERE {' AND '.join(clauses)}
|
||||
ORDER BY trade_date DESC, id DESC LIMIT ?
|
||||
""",
|
||||
parameters,
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def delete_trade_entry(self, user_id: int, trade_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM trade_entries WHERE id = ? AND user_id = ?",
|
||||
(int(trade_id), int(user_id)),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def save_assistant_exchange(
|
||||
self, user_id: int, question: str, answer: str, context_date: str
|
||||
) -> None:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO assistant_messages
|
||||
(user_id, role, content, context_date, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(int(user_id), "user", question, context_date, now),
|
||||
(int(user_id), "assistant", answer, context_date, now),
|
||||
],
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM assistant_messages WHERE user_id = ? AND id NOT IN (
|
||||
SELECT id FROM assistant_messages
|
||||
WHERE user_id = ? ORDER BY id DESC LIMIT 200
|
||||
)
|
||||
""",
|
||||
(int(user_id), int(user_id)),
|
||||
)
|
||||
|
||||
def list_assistant_messages(self, user_id: int, limit: int = 100) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT role, content, context_date, created_at FROM assistant_messages
|
||||
WHERE user_id = ? ORDER BY id DESC LIMIT ?
|
||||
""",
|
||||
(int(user_id), max(1, min(200, int(limit)))),
|
||||
).fetchall()
|
||||
return [dict(row) for row in reversed(rows)]
|
||||
|
||||
def delete_assistant_messages(self, user_id: int) -> int:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),)
|
||||
)
|
||||
return int(cursor.rowcount)
|
||||
|
||||
@staticmethod
|
||||
def _heaven_reading_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"mode": str(row["mode"]),
|
||||
"context_date": str(row["context_date"]),
|
||||
"subject": str(row["subject"]),
|
||||
"subject_detail": str(row["subject_detail"]),
|
||||
"answer": str(row["answer"]),
|
||||
"created_at": str(row["created_at"]),
|
||||
}
|
||||
|
||||
def save_heaven_reading(
|
||||
self,
|
||||
user_id: int,
|
||||
mode: str,
|
||||
context_date: str,
|
||||
subject: str,
|
||||
subject_detail: str,
|
||||
answer: str,
|
||||
context_snapshot: dict[str, Any],
|
||||
dedupe_key: str,
|
||||
) -> dict[str, Any]:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
snapshot_json = json.dumps(
|
||||
context_snapshot, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO heaven_readings
|
||||
(user_id, mode, context_date, subject, subject_detail, answer,
|
||||
context_snapshot, dedupe_key, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, dedupe_key) DO NOTHING
|
||||
""",
|
||||
(
|
||||
int(user_id), mode, context_date, subject, subject_detail,
|
||||
answer, snapshot_json, dedupe_key, now,
|
||||
),
|
||||
)
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT id, mode, context_date, subject, subject_detail, answer, created_at
|
||||
FROM heaven_readings WHERE user_id = ? AND dedupe_key = ?
|
||||
""",
|
||||
(int(user_id), dedupe_key),
|
||||
).fetchone()
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM heaven_readings
|
||||
WHERE user_id = ? AND mode = ? AND id NOT IN (
|
||||
SELECT id FROM heaven_readings
|
||||
WHERE user_id = ? AND mode = ? ORDER BY id DESC LIMIT 100
|
||||
)
|
||||
""",
|
||||
(int(user_id), mode, int(user_id), mode),
|
||||
)
|
||||
result = self._heaven_reading_dict(row)
|
||||
if not result:
|
||||
raise ValueError("解读记录保存失败。")
|
||||
return result
|
||||
|
||||
def list_heaven_readings(
|
||||
self,
|
||||
user_id: int,
|
||||
mode: str,
|
||||
context_date: str = "",
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = ["user_id = ?", "mode = ?"]
|
||||
parameters: list[Any] = [int(user_id), mode]
|
||||
if context_date:
|
||||
clauses.append("context_date = ?")
|
||||
parameters.append(context_date)
|
||||
parameters.append(max(1, min(100, int(limit))))
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT id, mode, context_date, subject, subject_detail, answer, created_at
|
||||
FROM heaven_readings WHERE {' AND '.join(clauses)}
|
||||
ORDER BY context_date DESC, id DESC LIMIT ?
|
||||
""",
|
||||
parameters,
|
||||
).fetchall()
|
||||
return [self._heaven_reading_dict(row) for row in rows if row]
|
||||
|
||||
def latest_heaven_reading(
|
||||
self, user_id: int, mode: str, context_date: str = ""
|
||||
) -> dict[str, Any] | None:
|
||||
items = self.list_heaven_readings(user_id, mode, context_date, 1)
|
||||
return items[0] if items else None
|
||||
|
||||
def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM heaven_readings WHERE id = ? AND user_id = ?",
|
||||
(int(reading_id), int(user_id)),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from backend.features.sentiment.engine import apply_sentiment_to_dashboard
|
||||
|
||||
|
||||
DEMO_LIMITS = [
|
||||
("600664", "哈药股份", 4.94, 10.02, "医药", "创新药+医药流通", "09:25:00", "09:25:00", 0, 5, 11.78, 14.65, 26458),
|
||||
("603580", "艾艾精工", 40.84, 9.99, "机器人", "实控人变更+机器人", "09:25:01", "09:25:01", 0, 3, 0.11, 0.53, 27190),
|
||||
("600785", "新华百货", 9.32, 10.04, "零售", "新零售+股权转让", "10:32:33", "10:32:33", 2, 2, 9.57, 29.44, 4285),
|
||||
("002739", "万达电影", 10.32, 10.02, "文化传媒", "影视院线+AI视频", "09:30:33", "09:30:33", 0, 2, 3.95, 217.94, 25014),
|
||||
("000504", "南华生物", 9.36, 9.99, "医药", "细胞医疗+中报预增", "09:39:18", "09:39:18", 1, 2, 8.73, 30.89, 1962),
|
||||
("000676", "智度股份", 6.22, 10.09, "端侧AI", "AI营销+端侧AI", "09:46:45", "09:46:45", 0, 2, 6.61, 78.36, 10368),
|
||||
("600162", "香江控股", 2.78, 9.88, "房地产", "房地产+地产链", "09:30:57", "09:30:57", 1, 2, 10.56, 90.86, 4540),
|
||||
("002365", "永安药业", 13.18, 10.02, "医药", "医药+宠物经济", "09:33:24", "09:33:24", 0, 2, 10.52, 38.84, 8277),
|
||||
("000566", "海南海药", 5.67, 10.10, "脑机接口", "创新药+脑机接口", "11:01:12", "11:03:48", 2, 2, 22.75, 73.56, 8769),
|
||||
("002632", "道明光学", 9.63, 10.06, "端侧AI", "AI手机+反光材料", "09:25:00", "09:25:00", 0, 1, 2.63, 60.15, 13417),
|
||||
("000892", "欢瑞世纪", 3.87, 9.94, "文化传媒", "短剧+AI应用", "09:34:57", "09:34:57", 0, 1, 10.80, 37.96, 5635),
|
||||
("603496", "恒为科技", 25.08, 10.00, "云计算", "算力+华为", "09:58:12", "10:46:30", 1, 1, 7.65, 80.31, 16611),
|
||||
("603327", "福蓉科技", 8.57, 10.01, "端侧AI", "AI手机+消费电子", "09:30:02", "09:30:02", 0, 1, 7.02, 77.84, 7784),
|
||||
("300968", "格林精密", 10.24, 20.00, "端侧AI", "折叠屏+AI眼镜", "09:36:33", "09:36:33", 0, 1, 20.06, 48.23, 7850),
|
||||
("002045", "国光电器", 8.34, 10.03, "消费电子", "音响电声+AI眼镜", "09:37:45", "09:37:45", 0, 1, 7.11, 66.04, 4517),
|
||||
("600203", "福日电子", 11.92, 9.96, "消费电子", "华为产业链+机器人", "09:45:03", "09:45:03", 0, 1, 12.04, 105.50, 10554),
|
||||
("002881", "美格智能", 39.05, 10.00, "端侧AI", "物理AI+算力模组", "10:07:42", "10:07:42", 0, 1, 14.32, 128.20, 4299),
|
||||
]
|
||||
|
||||
|
||||
DEMO_BROKEN = [
|
||||
("002141", "贤丰控股", 5.91, 5.35, "PCB板", "PCB板+资产重组", "09:37:03", "14:56:24", 3, 18.95, 61.05),
|
||||
("002432", "九安医疗", 72.00, 7.48, "医药", "业绩增长+AI应用", "10:53:00", "14:09:45", 5, 14.13, 335.00),
|
||||
("002980", "华盛昌", 107.37, 5.12, "光通信", "光通信+仪器仪表", "09:59:18", "14:38:36", 1, 17.94, 108.75),
|
||||
("603725", "天安新材", 14.08, 7.40, "机器人", "机器人+新材料", "09:36:34", "14:37:19", 5, 13.58, 42.92),
|
||||
("603127", "昭衍新药", 53.25, 5.20, "医药", "创新药+CRO", "10:35:49", "10:46:55", 2, 19.56, 335.66),
|
||||
("002261", "拓维信息", 29.95, 6.47, "云计算", "算力+华为", "10:48:15", "10:53:54", 3, 12.04, 343.26),
|
||||
("603893", "瑞芯微", 222.24, 5.58, "国产芯片", "国产芯片+端侧AI", "09:55:26", "13:31:14", 1, 7.60, 939.80),
|
||||
("603103", "横店影视", 14.94, 5.21, "文化传媒", "影视院线+暑期档", "13:01:06", "13:01:51", 1, 2.79, 94.75),
|
||||
]
|
||||
|
||||
|
||||
DEMO_DOWN = [
|
||||
("603683", "晶华新材", 25.56, -10.00, "新材料", "高位股风险释放", 4.41, 173.67, 1),
|
||||
("603928", "兴业股份", 12.34, -9.99, "化工", "连续上涨后补跌", 11.96, 42.04, 4),
|
||||
("000988", "华工科技", 130.69, -10.00, "光通信", "高位成交放大", 6.28, 1313.42, 1),
|
||||
("603137", "恒尚节能", 32.05, -10.00, "建筑", "昨日涨停断板", 1.48, 58.63, 1),
|
||||
("603115", "海星股份", 81.06, -10.00, "有色金属", "板块退潮", 3.02, 196.08, 1),
|
||||
("605376", "博迁新材", 166.02, -10.00, "新材料", "资金兑现", 5.35, 434.31, 1),
|
||||
("003020", "立方制药", 19.72, -10.00, "医药", "医药分化", 22.88, 45.00, 1),
|
||||
("605255", "天普股份", 78.47, -10.00, "汽车零部件", "连板失败", 2.12, 105.21, 1),
|
||||
("002123", "梦网科技", 7.68, -9.96, "通信", "板块调整", 1.39, 61.86, 2),
|
||||
("603713", "密尔克卫", 64.80, -10.00, "物流", "业绩预期调整", 3.99, 103.43, 1),
|
||||
]
|
||||
|
||||
|
||||
def _stock_rows() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"code": code,
|
||||
"ts_code": code,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change": change,
|
||||
"sector": sector,
|
||||
"reason": reason,
|
||||
"first_time": first_time,
|
||||
"last_time": last_time,
|
||||
"open_times": open_times,
|
||||
"streak": streak,
|
||||
"turnover_rate": turnover,
|
||||
"amount_billion": amount,
|
||||
"seal_amount_million": seal,
|
||||
"float_mv_billion": round(amount * 3.2, 1),
|
||||
"status": "涨停",
|
||||
}
|
||||
for code, name, price, change, sector, reason, first_time, last_time,
|
||||
open_times, streak, turnover, amount, seal in DEMO_LIMITS
|
||||
]
|
||||
|
||||
|
||||
def _broken_rows() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"code": code,
|
||||
"ts_code": code,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change": change,
|
||||
"sector": sector,
|
||||
"reason": reason,
|
||||
"first_time": first_time,
|
||||
"last_time": last_time,
|
||||
"open_times": open_times,
|
||||
"streak": 1,
|
||||
"turnover_rate": turnover,
|
||||
"amount_billion": amount,
|
||||
"seal_amount_million": 0,
|
||||
"float_mv_billion": round(amount * 3.5, 1),
|
||||
"status": "炸板",
|
||||
}
|
||||
for code, name, price, change, sector, reason, first_time, last_time,
|
||||
open_times, turnover, amount in DEMO_BROKEN
|
||||
]
|
||||
|
||||
|
||||
def _down_rows() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"code": code,
|
||||
"ts_code": code,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change": change,
|
||||
"sector": sector,
|
||||
"reason": reason,
|
||||
"first_time": "--",
|
||||
"last_time": "--",
|
||||
"open_times": 0,
|
||||
"streak": streak,
|
||||
"turnover_rate": turnover,
|
||||
"amount_billion": amount,
|
||||
"seal_amount_million": 0,
|
||||
"float_mv_billion": round(amount * 4.1, 1),
|
||||
"status": "跌停",
|
||||
}
|
||||
for code, name, price, change, sector, reason, turnover, amount, streak in DEMO_DOWN
|
||||
]
|
||||
|
||||
|
||||
def _ladders(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for level in sorted({row["streak"] for row in rows}, reverse=True):
|
||||
stocks = [row for row in rows if row["streak"] == level]
|
||||
result.append(
|
||||
{
|
||||
"level": level,
|
||||
"label": "首板" if level == 1 else f"{level}板",
|
||||
"count": len(stocks),
|
||||
"stocks": stocks,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _sectors(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
counts = Counter(row["sector"] for row in rows)
|
||||
result = []
|
||||
for name, count in counts.most_common():
|
||||
stocks = [row for row in rows if row["sector"] == name]
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"count": count,
|
||||
"strength": min(99, 48 + count * 9 + max(row["streak"] for row in stocks) * 4),
|
||||
"amount_billion": round(sum(row["amount_billion"] for row in stocks), 1),
|
||||
"leader": max(stocks, key=lambda row: (row["streak"], row["amount_billion"]))["name"],
|
||||
"change": round(sum(row["change"] for row in stocks) / count, 2),
|
||||
"max_streak": max(row["streak"] for row in stocks),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _yesterday_rows(current: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
current_map = {row["code"]: row for row in current}
|
||||
definitions = [
|
||||
("600664", "哈药股份", 4, 10.02, "晋级"),
|
||||
("603580", "艾艾精工", 2, 9.99, "晋级"),
|
||||
("600785", "新华百货", 1, 10.04, "晋级"),
|
||||
("002739", "万达电影", 1, 10.02, "晋级"),
|
||||
("000504", "南华生物", 1, 9.99, "晋级"),
|
||||
("000676", "智度股份", 1, 10.09, "晋级"),
|
||||
("603127", "昭衍新药", 1, 5.20, "炸板"),
|
||||
("002432", "九安医疗", 2, 7.48, "炸板"),
|
||||
("001388", "信通电子", 3, -5.33, "断板"),
|
||||
("605255", "天普股份", 2, -10.00, "跌停"),
|
||||
("600403", "大有能源", 1, -6.75, "断板"),
|
||||
("002185", "华天科技", 1, -10.00, "跌停"),
|
||||
("600829", "人民同泰", 1, 2.30, "断板"),
|
||||
("600844", "金煤科技", 1, 1.18, "断板"),
|
||||
]
|
||||
rows = []
|
||||
for code, name, prior_streak, current_change, outcome in definitions:
|
||||
current_row = current_map.get(code, {})
|
||||
rows.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": name,
|
||||
"prior_streak": prior_streak,
|
||||
"current_streak": current_row.get("streak", 0),
|
||||
"current_change": current_change,
|
||||
"current_price": current_row.get("price", 0),
|
||||
"sector": current_row.get("sector", "其他"),
|
||||
"reason": current_row.get("reason", "昨日涨停股表现跟踪"),
|
||||
"outcome": outcome,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for level in sorted({row["prior_streak"] for row in rows}, reverse=True):
|
||||
group = [row for row in rows if row["prior_streak"] == level]
|
||||
advanced = sum(row["outcome"] == "晋级" for row in group)
|
||||
positive = sum(row["current_change"] > 0 for row in group)
|
||||
result.append(
|
||||
{
|
||||
"level": level,
|
||||
"label": "昨日首板" if level == 1 else f"昨日{level}板",
|
||||
"count": len(group),
|
||||
"advanced": advanced,
|
||||
"advance_rate": round(advanced / len(group) * 100, 1),
|
||||
"positive_rate": round(positive / len(group) * 100, 1),
|
||||
"average_change": round(sum(row["current_change"] for row in group) / len(group), 2),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _rotation(sectors: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
previous_counts = {
|
||||
"端侧AI": 7,
|
||||
"医药": 5,
|
||||
"文化传媒": 1,
|
||||
"消费电子": 1,
|
||||
"机器人": 3,
|
||||
"房地产": 2,
|
||||
"零售": 0,
|
||||
"云计算": 2,
|
||||
"脑机接口": 1,
|
||||
}
|
||||
result = []
|
||||
for index, sector in enumerate(sectors, start=1):
|
||||
previous = previous_counts.get(sector["name"], 0)
|
||||
delta = sector["count"] - previous
|
||||
result.append(
|
||||
{
|
||||
**sector,
|
||||
"rank": index,
|
||||
"previous_count": previous,
|
||||
"delta": delta,
|
||||
"trend": "升温" if delta > 0 else "降温" if delta < 0 else "持平",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def build_demo_dashboard(trade_date: str, notice: str = "") -> dict[str, Any]:
|
||||
limits = _stock_rows()
|
||||
broken = _broken_rows()
|
||||
down_limits = _down_rows()
|
||||
ladders = _ladders(limits)
|
||||
sectors = _sectors(limits)
|
||||
yesterday = _yesterday_rows(limits)
|
||||
dashboard = {
|
||||
"meta": {
|
||||
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
||||
"previous_trade_date": "2026-07-16",
|
||||
"source": "demo",
|
||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"notice": notice or "当前展示演示数据,配置 Tushare Token 后可读取真实行情。",
|
||||
},
|
||||
"overview": {
|
||||
"up_count": 2344,
|
||||
"down_count": 2695,
|
||||
"flat_count": 33,
|
||||
"limit_up_count": 41,
|
||||
"limit_down_count": 3,
|
||||
"broken_count": 25,
|
||||
"amount_billion": 24035.6,
|
||||
"seal_rate": 62.1,
|
||||
},
|
||||
"limits": limits,
|
||||
"broken": broken,
|
||||
"down_limits": down_limits,
|
||||
"yesterday_limits": yesterday,
|
||||
"limit_performance": _performance(yesterday),
|
||||
"ladders": ladders,
|
||||
"sectors": sectors,
|
||||
"sector_rotation": _rotation(sectors),
|
||||
}
|
||||
return apply_sentiment_to_dashboard(dashboard)
|
||||
|
||||
|
||||
def build_demo_dragon_tiger(trade_date: str, notice: str = "") -> dict[str, Any]:
|
||||
stocks = _stock_rows()[:10]
|
||||
seat_names = [
|
||||
"机构专用",
|
||||
"沪股通专用",
|
||||
"深股通专用",
|
||||
"中信证券股份有限公司上海分公司",
|
||||
"国泰海通证券股份有限公司南京太平南路证券营业部",
|
||||
]
|
||||
rows = []
|
||||
for index, stock in enumerate(stocks):
|
||||
buy = round(86.5 - index * 6.3, 2)
|
||||
sell = round(22.8 + index * 3.1, 2)
|
||||
net = round(buy - sell, 2)
|
||||
institutions = [
|
||||
{
|
||||
"seat_name": seat_names[index % len(seat_names)],
|
||||
"buy_million": buy,
|
||||
"sell_million": sell,
|
||||
"net_buy_million": net,
|
||||
},
|
||||
{
|
||||
"seat_name": seat_names[(index + 2) % len(seat_names)],
|
||||
"buy_million": round(buy * 0.42, 2),
|
||||
"sell_million": round(sell * 0.65, 2),
|
||||
"net_buy_million": round(buy * 0.42 - sell * 0.65, 2),
|
||||
},
|
||||
]
|
||||
rows.append(
|
||||
{
|
||||
"code": stock["code"],
|
||||
"ts_code": stock["code"] + (".SH" if stock["code"].startswith("6") else ".SZ"),
|
||||
"name": stock["name"],
|
||||
"price": stock["price"],
|
||||
"change": stock["change"],
|
||||
"turnover_rate": stock["turnover_rate"],
|
||||
"amount_billion": stock["amount_billion"],
|
||||
"buy_million": buy,
|
||||
"sell_million": sell,
|
||||
"net_buy_million": net,
|
||||
"net_rate": round(net / max(buy + sell, 1) * 100, 2),
|
||||
"reason": "日涨幅偏离值达到7%" if index % 2 == 0 else "连续三个交易日涨幅偏离值累计达到20%",
|
||||
"institutions": institutions,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"meta": {
|
||||
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
||||
"source": "demo",
|
||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"notice": notice or "龙虎榜当前展示演示数据。",
|
||||
},
|
||||
"summary": {
|
||||
"stock_count": len(rows),
|
||||
"institution_count": sum(len(row["institutions"]) for row in rows),
|
||||
"net_buy_million": round(sum(row["net_buy_million"] for row in rows), 2),
|
||||
"positive_count": sum(row["net_buy_million"] > 0 for row in rows),
|
||||
},
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def build_demo_stock_detail(
|
||||
code: str,
|
||||
trade_date: str,
|
||||
name: str = "示例股票",
|
||||
industry: str = "其他",
|
||||
notice: str = "",
|
||||
) -> dict[str, Any]:
|
||||
end = datetime.strptime(trade_date, "%Y%m%d")
|
||||
seed = sum(ord(character) for character in code)
|
||||
base = 8 + seed % 45
|
||||
prices = []
|
||||
close = float(base)
|
||||
for index in range(90):
|
||||
day = end - timedelta(days=(89 - index))
|
||||
drift = math.sin((index + seed) / 6) * 0.018 + 0.002
|
||||
open_price = close * (1 + math.sin(index * 1.7) * 0.006)
|
||||
close = max(1, close * (1 + drift))
|
||||
high = max(open_price, close) * (1.012 + (index % 3) * 0.002)
|
||||
low = min(open_price, close) * (0.988 - (index % 2) * 0.002)
|
||||
prices.append(
|
||||
{
|
||||
"trade_date": day.strftime("%Y-%m-%d"),
|
||||
"open": round(open_price, 2),
|
||||
"high": round(high, 2),
|
||||
"low": round(low, 2),
|
||||
"close": round(close, 2),
|
||||
"change": round((close / open_price - 1) * 100, 2),
|
||||
"volume": 180000 + (index % 11) * 26000 + seed * 10,
|
||||
"amount_billion": round(1.8 + (index % 9) * 0.36, 2),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"meta": {
|
||||
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
||||
"source": "demo",
|
||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"notice": notice or "个股详情当前展示演示数据。",
|
||||
},
|
||||
"stock": {
|
||||
"code": code,
|
||||
"ts_code": code + (".SH" if code.startswith("6") else ".SZ"),
|
||||
"name": name,
|
||||
"industry": industry,
|
||||
"area": "--",
|
||||
"market": "主板",
|
||||
"list_date": "--",
|
||||
"price": prices[-1]["close"],
|
||||
"change": prices[-1]["change"],
|
||||
},
|
||||
"prices": prices,
|
||||
"moneyflow": {
|
||||
"net_million": 18.62,
|
||||
"large_million": 31.48,
|
||||
"medium_million": -4.12,
|
||||
"small_million": -8.74,
|
||||
},
|
||||
}
|
||||
+1939
File diff suppressed because it is too large
Load Diff
@@ -18,12 +18,12 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/shared/tokens.css?v=20260729-1">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<link rel="stylesheet" href="/renovation.css?v=20260725-5">
|
||||
<link rel="stylesheet" href="/redesign-v2.css?v=20260728-1">
|
||||
<link rel="stylesheet" href="/design-system.css?v=20260728-4">
|
||||
<link rel="stylesheet" href="/theme.css?v=20260728-2">
|
||||
<link rel="stylesheet" href="/wentian-v2.css?v=20260728-7">
|
||||
<link rel="stylesheet" href="/styles/styles.css">
|
||||
<link rel="stylesheet" href="/styles/renovation.css?v=20260725-5">
|
||||
<link rel="stylesheet" href="/styles/redesign-v2.css?v=20260728-1">
|
||||
<link rel="stylesheet" href="/styles/design-system.css?v=20260728-4">
|
||||
<link rel="stylesheet" href="/styles/theme.css?v=20260728-2">
|
||||
<link rel="stylesheet" href="/pages/heaven/page.css?v=20260728-7">
|
||||
</head>
|
||||
<body>
|
||||
<section id="authGate" class="auth-gate" aria-label="账号登录">
|
||||
@@ -1865,12 +1865,13 @@
|
||||
<div id="toast" class="toast" role="status" hidden></div>
|
||||
|
||||
<script src="/vendor/lucide.min.js" defer></script>
|
||||
<script src="/ui-core.js" defer></script>
|
||||
<script src="/shared/ui-core.js" defer></script>
|
||||
<script src="/shared/components.js?v=20260729-1" defer></script>
|
||||
<script src="/pages.config.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/runtime.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/sentiment/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/pools/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/market/runtime.js?v=20260731-1" defer></script>
|
||||
<script src="/pages/ladder/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/rotation/page.js?v=20260729-1" defer></script>
|
||||
<script src="/pages/auction/page.js?v=20260729-1" defer></script>
|
||||
@@ -1884,7 +1885,8 @@
|
||||
<script src="/shared/state.js?v=20260729-1" defer></script>
|
||||
<script src="/shared/api.js?v=20260729-1" defer></script>
|
||||
<script src="/shared/shell.js?v=20260729-1" defer></script>
|
||||
<script src="/heaven-loading-v2.js?v=20260728-2" defer></script>
|
||||
<script src="/shared/export.js?v=20260731-1" defer></script>
|
||||
<script src="/pages/heaven/loading-v2.js?v=20260728-2" defer></script>
|
||||
<script src="/app.js?v=20260729-6" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,272 @@
|
||||
window.XiaobaiPageModules.register("auction", ["auctionView"], {
|
||||
enter: ["loadAuction"],
|
||||
leave: ["clearAuction"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2217-2481 */
|
||||
async function loadAuctionCenter(force = false) {
|
||||
if (state.auctionLoading) return;
|
||||
state.auctionLoading = true;
|
||||
const button = document.querySelector("#auctionRefreshButton");
|
||||
button.disabled = true;
|
||||
setText("auctionDateLabel", "正在读取竞价数据");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
if (force) query.set("force", "1");
|
||||
state.auctionData = await apiRequest(`/api/auction?${query}`);
|
||||
renderAuctionCenter();
|
||||
scheduleAuctionTransition(state.auctionData.meta || {});
|
||||
} catch (error) {
|
||||
document.querySelector("#auctionSummary").innerHTML = "";
|
||||
document.querySelector("#auctionThemeCarry").innerHTML = "";
|
||||
document.querySelector("#auctionNewThemes").innerHTML = "";
|
||||
document.querySelector("#auctionAmountTrend").innerHTML = "";
|
||||
document.querySelector("#auctionAmountCompare").innerHTML = "";
|
||||
document.querySelector("#auctionTableBody").innerHTML = "";
|
||||
document.querySelector("#auctionEmpty").hidden = false;
|
||||
setText("auctionDateLabel", error.message || "竞价数据暂不可用");
|
||||
showToast(error.message || "竞价数据加载失败");
|
||||
} finally {
|
||||
state.auctionLoading = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAuctionCenter() {
|
||||
const payload = state.auctionData;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
renderAuctionPhase(payload.meta || {});
|
||||
setText(
|
||||
"auctionDateLabel",
|
||||
`${payload.meta?.carried_forward ? "最近有效竞价" : "竞价日期"} ${payload.meta?.trade_date || "--"}`,
|
||||
);
|
||||
document.querySelector("#auctionSummary").innerHTML = [
|
||||
["竞价覆盖", `${formatNumber(summary.stock_count, 0)} 只`, ""],
|
||||
["重点异动", `${formatNumber(summary.focus_count, 0)} 只`, "up"],
|
||||
["竞价一字", `${formatNumber(summary.one_price_count, 0)} 只`, ""],
|
||||
["竞价成交额", `${formatNumber(summary.amount_billion, 2)} 亿`, ""],
|
||||
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
|
||||
setText("auctionFocusCount", number(summary.focus_count));
|
||||
setText("auctionAllCount", number(summary.candidate_count));
|
||||
setText("auctionOnePriceCount", number(summary.one_price_count));
|
||||
setText("auctionWatchlistCount", number(payload.watchlist_rows?.length));
|
||||
renderAuctionInsights(payload);
|
||||
renderAuctionTable();
|
||||
}
|
||||
|
||||
function renderAuctionInsights(payload) {
|
||||
const themes = payload.themes || {};
|
||||
const carry = themes.carry || [];
|
||||
const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" };
|
||||
setText("auctionThemeBaseline", `基于 ${payload.candidate_meta?.baseline_date || "--"}`);
|
||||
document.querySelector("#auctionThemeCarry").innerHTML = carry.length
|
||||
? carry.map((item) => `
|
||||
<div class="auction-theme-row">
|
||||
<strong class="auction-theme-name">${escapeHtml(item.name)}</strong>
|
||||
<span class="auction-theme-info">${escapeHtml(item.leader || "--")} · 昨日 ${number(item.prior_limit_count)} 只涨停</span>
|
||||
<span class="auction-theme-status ${tone[item.status] || "mixed"}">${escapeHtml(item.status)}</span>
|
||||
<span class="auction-theme-median ${item.median_change == null ? "" : changeClass(item.median_change)}">${item.median_change == null ? "暂无有效候选" : `${signed(item.median_change)}%`}<small>中位</small></span>
|
||||
</div>`).join("")
|
||||
: '<div class="auction-inline-empty">暂无昨日强势题材基线</div>';
|
||||
|
||||
const newThemes = themes.new_themes || [];
|
||||
document.querySelector("#auctionNewThemes").innerHTML = newThemes.length
|
||||
? newThemes.map((item) => `<span title="${escapeHtml((item.leaders || []).join("、"))}">${escapeHtml(item.name)} <strong>${number(item.stock_count)}</strong></span>`).join("")
|
||||
: '<small>尚未形成多股共振的新线索</small>';
|
||||
|
||||
const history = payload.amount_history || [];
|
||||
const maximum = Math.max(...history.map((item) => number(item.amount_billion)), 1);
|
||||
const priorFive = history.slice(Math.max(0, history.length - 6), Math.max(0, history.length - 1));
|
||||
const fiveDayAverage = priorFive.length
|
||||
? priorFive.reduce((sum, item) => sum + number(item.amount_billion), 0) / priorFive.length
|
||||
: null;
|
||||
document.querySelector("#auctionAmountTrend").innerHTML = history.length
|
||||
? history.map((item, index) => {
|
||||
const height = Math.max(8, number(item.amount_billion) / maximum * 100);
|
||||
const current = index === history.length - 1 ? " current" : "";
|
||||
return `<div class="auction-amount-day${current}" title="${escapeHtml(item.trade_date)} · ${formatNumber(item.amount_billion, 2)} 亿 · ${number(item.stock_count)} 只">
|
||||
<span style="height:${height.toFixed(1)}%"></span><small>${escapeHtml(String(item.trade_date || "").slice(5))}</small>
|
||||
</div>`;
|
||||
}).join("") + (fiveDayAverage === null ? "" : `<div class="auction-amount-average" style="bottom:${(20 + Math.min(fiveDayAverage / maximum, 1) * 82).toFixed(1)}px"><small>5日均 ${formatNumber(fiveDayAverage, 1)}</small></div>`)
|
||||
: '<div class="auction-inline-empty">历史竞价量能尚未形成</div>';
|
||||
setText("auctionAmountValue", `${formatNumber(payload.summary?.amount_billion, 2)} 亿`);
|
||||
const comparison = [
|
||||
["较昨日", payload.summary?.amount_change_previous],
|
||||
["较5日均值", payload.summary?.amount_change_5d],
|
||||
];
|
||||
document.querySelector("#auctionAmountCompare").innerHTML = comparison.map(([label, value]) => `
|
||||
<span>${label}<strong class="${value == null ? "" : changeClass(value)}">${value == null ? "--" : `${signed(value)}%`}</strong></span>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderAuctionPhase(meta) {
|
||||
const phase = meta.phase || "archive";
|
||||
const available = Boolean(meta.available);
|
||||
const copy = {
|
||||
pending: ["竞价尚未开始", "9:15 进入观察期,9:25 读取最终竞价结果。", "下一阶段 09:15"],
|
||||
observing: ["竞价观察期", "此阶段先观察盘前变化,系统将在 9:25 自动读取最终结果。", "09:25 定格"],
|
||||
selection: available
|
||||
? ["竞价筛选窗口", "最终竞价结果已经定格,请在 9:30 前完成筛选。", "有效至 09:30"]
|
||||
: ["等待最终竞价", "9:25 数据尚未到达,系统正在自动重试。", "即将更新"],
|
||||
finalized: ["今日竞价已定格", "9:30 后停止更新,仅保留用于复盘、回测与智能选股。", "已冻结"],
|
||||
archive: ["历史竞价归档", "当前展示所选交易日的最终竞价结果。", "归档数据"],
|
||||
}[phase] || ["竞价状态", "当前竞价状态待确认。", "--"];
|
||||
const notice = document.querySelector("#auctionPhaseNotice");
|
||||
notice.dataset.phase = phase;
|
||||
setText("auctionPhaseTitle", copy[0]);
|
||||
setText("auctionPhaseDetail", copy[1]);
|
||||
setText("auctionPhaseTime", copy[2]);
|
||||
const refresh = document.querySelector("#auctionRefreshButton");
|
||||
refresh.hidden = phase !== "selection";
|
||||
refresh.disabled = state.auctionLoading;
|
||||
}
|
||||
|
||||
function clearAuctionTimer() {
|
||||
if (state.auctionTimer) clearTimeout(state.auctionTimer);
|
||||
state.auctionTimer = null;
|
||||
}
|
||||
|
||||
function scheduleAuctionTransition(meta) {
|
||||
clearAuctionTimer();
|
||||
if (state.activeView !== "auctionView") return;
|
||||
let delay = 0;
|
||||
if (["selection", "finalized"].includes(meta.phase) && !meta.available) {
|
||||
delay = 10_000;
|
||||
} else if (meta.next_transition_at) {
|
||||
const transitionAt = new Date(meta.next_transition_at).getTime();
|
||||
if (Number.isFinite(transitionAt)) delay = Math.max(800, transitionAt - Date.now() + 500);
|
||||
}
|
||||
if (!delay) return;
|
||||
state.auctionTimer = setTimeout(() => {
|
||||
state.auctionTimer = null;
|
||||
if (state.activeView === "auctionView") loadAuctionCenter(true);
|
||||
}, Math.min(delay, 2_147_000_000));
|
||||
}
|
||||
|
||||
function renderAuctionTable() {
|
||||
const rows = currentAuctionRows();
|
||||
const columns = auctionColumns();
|
||||
const head = document.querySelector("#auctionTableHead");
|
||||
head.innerHTML = columns.map((column) => {
|
||||
const sorted = column.sortKey === state.auctionSortKey;
|
||||
const arrow = !column.sortKey ? "" : `<span class="arr">${sorted ? (state.auctionSortDirection === "desc" ? "▼" : "▲") : "↕"}</span>`;
|
||||
return `<th class="${column.numeric ? "number num " : ""}${column.sortKey ? "sortable " : ""}${sorted ? "sorted" : ""}"${column.sortKey ? ` data-auction-sort="${column.sortKey}"` : ""}>${column.label}${arrow}</th>`;
|
||||
}).join("");
|
||||
const body = document.querySelector("#auctionTableBody");
|
||||
body.innerHTML = rows.map((row) => `<tr data-code="${escapeHtml(row.code)}">${columns.map((column) => renderAuctionCell(row, column.key)).join("")}</tr>`).join("");
|
||||
bindStockRows(body);
|
||||
const datasetCopy = {
|
||||
focus: ["重点异动", "优先查看市场核心与显著预期差"],
|
||||
onePrice: ["竞价一字", "竞价封于当日真实涨停价,不参与普通异动评分"],
|
||||
watchlist: ["我的自选", "仅展示当前账号关注标的的竞价反馈"],
|
||||
all: ["全部候选", "昨日涨停、炸板与热榜前20候选"],
|
||||
}[state.auctionDataset] || ["竞价异动", ""];
|
||||
setText("auctionWorkspaceTitle", datasetCopy[0]);
|
||||
setText("auctionWorkspaceSubtitle", datasetCopy[1]);
|
||||
document.querySelector("#auctionExpectationControls").hidden = state.auctionDataset === "onePrice";
|
||||
const empty = document.querySelector("#auctionEmpty");
|
||||
const phase = state.auctionData?.meta?.phase || "archive";
|
||||
empty.textContent = phase === "selection" && !state.auctionData?.meta?.available
|
||||
? "正在等待 9:25 最终竞价数据"
|
||||
: state.auctionDataset === "watchlist"
|
||||
? "当前账号还没有可观察的自选股"
|
||||
: state.auctionDataset === "onePrice"
|
||||
? "当前没有竞价封于涨停价的股票"
|
||||
: "没有符合条件的竞价候选";
|
||||
empty.hidden = rows.length > 0;
|
||||
}
|
||||
|
||||
function currentAuctionRows() {
|
||||
const datasets = {
|
||||
focus: state.auctionData?.focus_rows || [],
|
||||
onePrice: state.auctionData?.one_price_rows || [],
|
||||
watchlist: state.auctionData?.watchlist_rows || [],
|
||||
all: state.auctionData?.rows || [],
|
||||
};
|
||||
let rows = [...(datasets[state.auctionDataset] || [])];
|
||||
const filter = state.auctionFilter;
|
||||
const labels = { above: "超预期", matched: "符合预期", below: "低于预期" };
|
||||
if (labels[filter]) rows = rows.filter((item) => item.expectation === labels[filter]);
|
||||
if (state.auctionQuery) {
|
||||
rows = rows.filter((item) => `${item.code} ${item.name} ${item.sector}`.toLocaleLowerCase("zh-CN").includes(state.auctionQuery));
|
||||
}
|
||||
const key = state.auctionSortKey;
|
||||
const direction = state.auctionSortDirection === "asc" ? 1 : -1;
|
||||
if (key) {
|
||||
rows.sort((left, right) => {
|
||||
const leftValue = left[key];
|
||||
const rightValue = right[key];
|
||||
if (leftValue == null && rightValue == null) return 0;
|
||||
if (leftValue == null) return 1;
|
||||
if (rightValue == null) return -1;
|
||||
const result = typeof leftValue === "number" || typeof rightValue === "number"
|
||||
? number(leftValue) - number(rightValue)
|
||||
: String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true });
|
||||
return result * direction;
|
||||
});
|
||||
}
|
||||
return rows.slice(0, 300);
|
||||
}
|
||||
|
||||
function auctionColumns() {
|
||||
const base = [
|
||||
{ key: "stock", label: "股票" },
|
||||
{ key: "context", label: "方向与来源" },
|
||||
{ key: "identity", label: "市场身份" },
|
||||
];
|
||||
const metrics = [
|
||||
{ key: "score", label: "关注分", numeric: true, sortKey: "attention_score" },
|
||||
{ key: "expectation", label: "预期判断" },
|
||||
{ key: "change", label: "竞价涨幅(%)", numeric: true, sortKey: "change" },
|
||||
{ key: "amount", label: "竞价额(百万)", numeric: true, sortKey: "amount_million" },
|
||||
{ key: "volume", label: "量比", numeric: true, sortKey: "volume_ratio" },
|
||||
];
|
||||
return state.auctionDataset === "onePrice" ? [...base, ...metrics.slice(2)] : [...base, ...metrics];
|
||||
}
|
||||
|
||||
function renderAuctionCell(row, key) {
|
||||
const unavailable = row.available === false;
|
||||
const onePrice = Boolean(row.is_one_price);
|
||||
const expectationTone = { "超预期": "above", "符合预期": "matched", "低于预期": "below" };
|
||||
if (key === "stock") return `<td><span class="auction-stock-cell-v2"><strong class="sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>`;
|
||||
if (key === "context") return `<td><span class="auction-context-cell-v2"><strong>${escapeHtml(row.sector || "其他")}</strong>${renderAuctionSources(row.source_label || (state.auctionDataset === "watchlist" ? "我的自选" : "全市场"))}</span></td>`;
|
||||
if (key === "identity") return `<td>${renderAuctionCoreTags(row.core_tags)}</td>`;
|
||||
if (unavailable) return key === "expectation"
|
||||
? '<td><span class="table-muted">暂无竞价</span></td>'
|
||||
: `<td class="${["score", "change", "amount", "volume"].includes(key) ? "number num" : ""}"></td>`;
|
||||
if (key === "score") return `<td class="number num auction-score">${onePrice ? "" : formatNumber(row.attention_score, 1)}</td>`;
|
||||
if (key === "expectation") {
|
||||
const tag = onePrice
|
||||
? '<span class="auction-one-price-tag">竞价一字</span>'
|
||||
: `<span class="auction-expectation ${expectationTone[row.expectation] || "matched"}">${escapeHtml(row.expectation || "符合预期")}</span>`;
|
||||
return `<td>${tag}</td>`;
|
||||
}
|
||||
if (key === "change") return `<td class="number num ${changeClass(row.change)}">${signed(row.change)}</td>`;
|
||||
if (key === "amount") return `<td class="number num">${formatNumber(row.amount_million, 2)}</td>`;
|
||||
if (key === "volume") return `<td class="number num auction-volume-ratio">${formatNumber(row.volume_ratio, 2)}</td>`;
|
||||
return "<td></td>";
|
||||
}
|
||||
|
||||
function renderAuctionSources(value) {
|
||||
const sources = String(value || "").split(/[·、/]/).map((item) => item.trim()).filter(Boolean).slice(0, 3);
|
||||
return `<small class="auction-source-tags-v2">${sources.map((source) => `<b>${escapeHtml(source)}</b>`).join("")}</small>`;
|
||||
}
|
||||
|
||||
function renderAuctionCoreTags(tags) {
|
||||
const values = Array.isArray(tags) ? tags : [];
|
||||
return values.length
|
||||
? `<span class="auction-core-tags">${values.slice(0, 2).map((tag) => `<b>${escapeHtml(tag)}</b>`).join("")}</span>`
|
||||
: '<span class="auction-identity-empty" aria-label="无市场身份"></span>';
|
||||
}
|
||||
|
||||
function exportAuctionRows() {
|
||||
const rows = currentAuctionRows();
|
||||
exportRows("集合竞价", rows, [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["行业", "sector"], ["来源", "source_label"],
|
||||
["市场身份", "core_tags"], ["关注分", "attention_score"], ["预期判断", "expectation"],
|
||||
["竞价涨幅%", "change"], ["竞价额百万", "amount_million"], ["量比", "volume_ratio"],
|
||||
]);
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2217-2481 */
|
||||
@@ -0,0 +1,375 @@
|
||||
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
|
||||
enter: ["loadDragonTiger"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2660-3028 */
|
||||
function selectDragonViewMode(mode) {
|
||||
state.dragonViewMode = mode === "profiles" ? "profiles" : "daily";
|
||||
document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => {
|
||||
const active = button.dataset.dragonViewMode === state.dragonViewMode;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
if (state.dragonViewMode === "profiles") {
|
||||
document.querySelector("#dragonDailyContent").hidden = true;
|
||||
document.querySelector("#dragonEmptyState").hidden = true;
|
||||
document.querySelector("#dragonProfilesContent").hidden = false;
|
||||
if (state.hotMoneyProfiles) renderHotMoneyProfiles();
|
||||
else loadHotMoneyProfiles();
|
||||
} else {
|
||||
document.querySelector("#dragonProfilesContent").hidden = true;
|
||||
if (state.dragonTiger) renderDragonTiger();
|
||||
else loadDragonTiger();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHotMoneyProfiles(force = false) {
|
||||
if (!force && state.hotMoneyProfiles) {
|
||||
renderHotMoneyProfiles();
|
||||
return;
|
||||
}
|
||||
setStatus("正在加载游资档案");
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (force) query.set("force", "1");
|
||||
const suffix = query.size ? `?${query}` : "";
|
||||
state.hotMoneyProfiles = await apiRequest(`/api/dragon-tiger/profiles${suffix}`);
|
||||
renderHotMoneyProfiles();
|
||||
const count = number(state.hotMoneyProfiles.summary?.profile_count);
|
||||
setStatus(`游资档案已加载 · 共 ${count} 位`);
|
||||
} catch (error) {
|
||||
showToast(error.message || "游资档案加载失败");
|
||||
setStatus("游资档案加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderHotMoneyProfiles() {
|
||||
const payload = state.hotMoneyProfiles;
|
||||
if (!payload) return;
|
||||
const profiles = payload.profiles || [];
|
||||
const summary = payload.summary || {};
|
||||
const query = state.hotMoneyProfileQuery;
|
||||
const visible = profiles.filter((profile) => {
|
||||
if (!query) return true;
|
||||
return [profile.name, profile.description, ...(profile.organizations || [])]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("zh-CN")
|
||||
.includes(query);
|
||||
});
|
||||
if (!visible.some((profile) => profile.id === state.selectedHotMoneyProfileId)) {
|
||||
state.selectedHotMoneyProfileId = visible[0]?.id || "";
|
||||
}
|
||||
const selected = visible.find((profile) => profile.id === state.selectedHotMoneyProfileId) || null;
|
||||
|
||||
setText("dragonDateLabel", `收录 ${number(summary.profile_count)} 位`);
|
||||
setText("hotMoneyProfileResultCount", query ? `${visible.length} / ${profiles.length} 位` : `${profiles.length} 位`);
|
||||
document.querySelector("#hotMoneyProfileSummary").innerHTML = [
|
||||
["收录游资", number(summary.profile_count)],
|
||||
["已有简介", number(summary.described_count)],
|
||||
["关联席位", number(summary.organization_count)],
|
||||
].map(([label, value]) => `<span><small>${label}</small><strong>${value}</strong></span>`).join("");
|
||||
|
||||
const list = document.querySelector("#hotMoneyProfileList");
|
||||
list.innerHTML = visible.length ? visible.map((profile, index) => `
|
||||
<button class="hot-money-profile-row-v2 ${profile.id === state.selectedHotMoneyProfileId ? "selected" : ""}"
|
||||
type="button" role="option" aria-selected="${profile.id === state.selectedHotMoneyProfileId}"
|
||||
data-hot-money-profile="${escapeHtml(profile.id)}">
|
||||
<span class="hot-money-profile-index-v2">${String(index + 1).padStart(2, "0")}</span>
|
||||
<span class="hot-money-profile-monogram-v2">${escapeHtml(profile.name.slice(0, 2))}</span>
|
||||
<span class="hot-money-profile-row-copy-v2">
|
||||
<strong>${escapeHtml(profile.name)}</strong>
|
||||
<small>${escapeHtml(profile.description || "暂未收录简介")}</small>
|
||||
</span>
|
||||
<span class="hot-money-profile-seat-count-v2">${number(profile.organization_count)} 席</span>
|
||||
</button>`).join("") : `
|
||||
<div class="hot-money-profile-list-empty-v2">
|
||||
<i data-lucide="search-x" aria-hidden="true"></i>
|
||||
<span>${profiles.length ? "没有符合条件的游资档案" : "游资名录暂不可用"}</span>
|
||||
</div>`;
|
||||
|
||||
const detail = document.querySelector("#hotMoneyProfileDetail");
|
||||
if (!selected) {
|
||||
detail.innerHTML = `
|
||||
<div class="hot-money-profile-empty-v2">
|
||||
<i data-lucide="contact" aria-hidden="true"></i>
|
||||
<strong>${profiles.length ? "选择一位游资查看档案" : "暂无可展示的游资档案"}</strong>
|
||||
</div>`;
|
||||
} else {
|
||||
const organizations = selected.organizations || [];
|
||||
detail.innerHTML = `
|
||||
<header class="hot-money-profile-detail-head-v2">
|
||||
<span class="hot-money-profile-avatar-v2">${escapeHtml(selected.name.slice(0, 2))}</span>
|
||||
<div>
|
||||
<small>游资档案</small>
|
||||
<h3>${escapeHtml(selected.name)}</h3>
|
||||
<span>${organizations.length ? `关联 ${organizations.length} 个公开席位` : "暂无关联席位"}</span>
|
||||
</div>
|
||||
</header>
|
||||
<section class="hot-money-profile-section-v2">
|
||||
<h4>人物简介</h4>
|
||||
<p class="${selected.description ? "" : "is-empty"}">${escapeHtml(selected.description || "名录暂未收录该游资的公开简介。")}</p>
|
||||
</section>
|
||||
<section class="hot-money-profile-section-v2 hot-money-profile-org-section-v2">
|
||||
<div class="hot-money-profile-section-title-v2">
|
||||
<h4>关联营业部</h4>
|
||||
<span>${organizations.length} 个</span>
|
||||
</div>
|
||||
<div class="hot-money-profile-organizations-v2">
|
||||
${organizations.length ? organizations.map((organization) => `
|
||||
<span><i data-lucide="building-2" aria-hidden="true"></i>${escapeHtml(organization)}</span>
|
||||
`).join("") : '<p class="is-empty">名录暂未收录关联营业部。</p>'}
|
||||
</div>
|
||||
</section>
|
||||
${payload.meta?.notice ? `<p class="hot-money-profile-notice-v2">${escapeHtml(payload.meta.notice)}</p>` : ""}`;
|
||||
}
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
async function loadDragonTiger(force = false) {
|
||||
const requestedDate = elements.tradeDate.value;
|
||||
if (
|
||||
!force
|
||||
&& ["success", "empty", "partial", "unavailable"].includes(state.dragonTiger?.meta?.status)
|
||||
&& (state.dragonTiger?.meta?.requested_date || state.dragonTiger?.meta?.trade_date) === requestedDate
|
||||
) {
|
||||
renderDragonTiger();
|
||||
return;
|
||||
}
|
||||
setStatus("正在加载龙虎榜");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: requestedDate });
|
||||
if (force) query.set("force", "1");
|
||||
const payload = await apiRequest(`/api/dragon-tiger?${query}`);
|
||||
state.dragonTiger = payload;
|
||||
renderDragonTiger();
|
||||
const statusLabel = payload.meta.status === "error"
|
||||
? "龙虎榜数据暂不可用"
|
||||
: payload.meta.status === "empty"
|
||||
? "当日暂无公开游资明细"
|
||||
: payload.meta.status === "partial"
|
||||
? "当日有龙虎榜,暂无命名游资明细"
|
||||
: payload.meta.status === "unavailable" ? "龙虎榜数据暂不可用" : "龙虎榜明细";
|
||||
setStatus(`${statusLabel} · 龙虎榜已加载`);
|
||||
} catch (error) {
|
||||
showToast(error.message || "龙虎榜加载失败");
|
||||
setStatus("龙虎榜加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderDragonTiger() {
|
||||
const payload = state.dragonTiger;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
if (state.dragonViewMode === "daily") setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`);
|
||||
const status = payload.meta?.status || "empty";
|
||||
const hasRecognizedTraders = (payload.traders || []).some((item) => item.identity_type === "trader" && item.recognized !== false);
|
||||
const showEmptyState = !hasRecognizedTraders
|
||||
&& !(payload.unclassified_seats || []).length
|
||||
&& ["empty", "error", "unavailable"].includes(status);
|
||||
const dailyVisible = state.dragonViewMode === "daily";
|
||||
document.querySelector("#dragonProfilesContent").hidden = dailyVisible;
|
||||
document.querySelector("#dragonEmptyState").hidden = !dailyVisible || !showEmptyState;
|
||||
document.querySelector("#dragonDailyContent").hidden = !dailyVisible || showEmptyState;
|
||||
if (showEmptyState) {
|
||||
const unavailable = ["error", "unavailable"].includes(status);
|
||||
setText("dragonEmptyTitle", unavailable ? "龙虎榜数据暂不可用" : `${payload.meta?.trade_date || "该交易日"} 暂无龙虎榜明细`);
|
||||
setText("dragonEmptyDescription", unavailable
|
||||
? "当前数据暂未完成更新,可稍后重新检查或查看前一交易日。"
|
||||
: "龙虎榜明细通常在交易日盘后陆续披露,可稍后刷新或查看前一交易日。");
|
||||
}
|
||||
document.querySelector("#dragonSummary").innerHTML = [
|
||||
["上榜游资", `${number(summary.trader_count)} 位`, ""],
|
||||
["操作明细", `${number(summary.operation_count)} 条`, ""],
|
||||
["席位净买入", formatMoneyMillion(summary.seat_net_buy_million), changeClass(summary.seat_net_buy_million)],
|
||||
["活跃股票", `${number(summary.active_stock_count)} 只`, ""],
|
||||
].map(([label, value, className]) => `<div class="dragon-metric"><span>${label}</span><strong class="${className}">${value}</strong></div>`).join("");
|
||||
|
||||
renderDragonTraderList();
|
||||
renderUnclassifiedSeats();
|
||||
}
|
||||
|
||||
function renderDragonTraderList() {
|
||||
const payload = state.dragonTiger;
|
||||
if (!payload) return;
|
||||
let traders = [...(payload.traders || [])].filter((item) => item.identity_type === "trader" && item.recognized !== false);
|
||||
if (state.dragonFilter === "buy") traders = traders.filter((item) => number(item.net_buy_million) > 0);
|
||||
if (state.dragonFilter === "sell") traders = traders.filter((item) => number(item.net_buy_million) < 0);
|
||||
if (state.dragonFilter === "unclassified") traders = [];
|
||||
if (state.dragonQuery) {
|
||||
traders = traders.filter((item) => {
|
||||
const searchable = [
|
||||
item.name,
|
||||
...(item.operations || []).flatMap((operation) => [operation.code, operation.name, operation.seat_name]),
|
||||
].join(" ").toLowerCase();
|
||||
return searchable.includes(state.dragonQuery);
|
||||
});
|
||||
}
|
||||
|
||||
const container = document.querySelector("#dragonTraderList");
|
||||
let emptyMessage = "没有符合当前条件的游资操作";
|
||||
if (!Array.isArray(payload.traders)) emptyMessage = "龙虎榜数据格式暂不可用,请稍后重试";
|
||||
else if (["error", "unavailable"].includes(payload.meta?.status)) emptyMessage = "龙虎榜数据暂不可用,请稍后重试";
|
||||
else if (payload.meta?.status === "empty") emptyMessage = "该交易日暂无游资每日明细";
|
||||
else if (payload.meta?.status === "partial") emptyMessage = `当日有 ${number(payload.summary?.official_stock_count)} 只股票上榜,但暂无可识别的游资明细`;
|
||||
if (!traders.some((item) => item.id === state.selectedDragonTraderId)) {
|
||||
state.selectedDragonTraderId = traders[0]?.id || "";
|
||||
}
|
||||
const cardMarkup = traders.map((trader, index) => {
|
||||
const description = trader.description || `${number(trader.stock_count)} 只股票,${number(trader.operation_count)} 笔操作`;
|
||||
return `
|
||||
<article class="dragon-trader-card dealing ${trader.id === state.selectedDragonTraderId ? "selected" : ""}" data-dragon-card="${escapeHtml(trader.id)}" aria-hidden="true" style="--deal-delay:${Math.min(index * 38, 650)}ms">
|
||||
<span class="dragon-card-rank">${String(index + 1).padStart(2, "0")}</span>
|
||||
<span class="dragon-card-monogram">${escapeHtml(trader.name.slice(0, 2))}</span>
|
||||
<span class="dragon-card-copy"><strong>${escapeHtml(trader.name)}</strong><q title="${escapeHtml(description)}">${escapeHtml(description)}</q></span>
|
||||
<span class="dragon-card-stats"><small>${number(trader.stock_count)} 股 · ${number(trader.operation_count)} 笔</small><b class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</b></span>
|
||||
</article>`;
|
||||
}).join("");
|
||||
const hitZoneMarkup = traders.map((trader) => `
|
||||
<button type="button" class="dragon-card-hit-zone" data-dragon-trader="${escapeHtml(trader.id)}" aria-label="查看 ${escapeHtml(trader.name)} 当日操作" aria-pressed="${trader.id === state.selectedDragonTraderId}"></button>
|
||||
`).join("");
|
||||
container.innerHTML = traders.length
|
||||
? `${cardMarkup}<div class="dragon-card-hit-layer">${hitZoneMarkup}</div>`
|
||||
: emptyStateHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage, { className: "dragon-empty" });
|
||||
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
|
||||
card.addEventListener("animationend", () => card.classList.remove("dealing"), { once: true });
|
||||
});
|
||||
container.querySelectorAll("[data-dragon-trader]").forEach((hitZone) => {
|
||||
const setHovered = (hovered) => {
|
||||
container.querySelector(`[data-dragon-card="${CSS.escape(hitZone.dataset.dragonTrader)}"]`)?.classList.toggle("hovered", hovered);
|
||||
};
|
||||
hitZone.addEventListener("pointerenter", () => setHovered(true));
|
||||
hitZone.addEventListener("pointerleave", () => setHovered(false));
|
||||
hitZone.addEventListener("focus", () => setHovered(true));
|
||||
hitZone.addEventListener("blur", () => setHovered(false));
|
||||
hitZone.addEventListener("click", () => {
|
||||
state.selectedDragonTraderId = hitZone.dataset.dragonTrader;
|
||||
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
|
||||
card.classList.toggle("selected", card.dataset.dragonCard === state.selectedDragonTraderId);
|
||||
});
|
||||
container.querySelectorAll("[data-dragon-trader]").forEach((item) => {
|
||||
item.setAttribute("aria-pressed", String(item.dataset.dragonTrader === state.selectedDragonTraderId));
|
||||
});
|
||||
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
|
||||
});
|
||||
});
|
||||
requestAnimationFrame(() => layoutDragonCards(container));
|
||||
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
|
||||
}
|
||||
|
||||
function layoutDragonCards(container = document.querySelector("#dragonTraderList")) {
|
||||
if (!container) return;
|
||||
const cards = [...container.querySelectorAll(".dragon-trader-card")];
|
||||
const hitZones = [...container.querySelectorAll(".dragon-card-hit-zone")];
|
||||
if (!cards.length) return;
|
||||
const compact = window.innerWidth <= 720;
|
||||
const cardWidth = compact ? 148 : 176;
|
||||
const available = Math.max(cardWidth, container.clientWidth - (compact ? 30 : 72));
|
||||
const spread = Math.min(available - cardWidth, compact ? 310 : 1050);
|
||||
const step = cards.length > 1 ? Math.min(cardWidth + 14, spread / (cards.length - 1)) : 0;
|
||||
const center = (cards.length - 1) / 2;
|
||||
container.style.setProperty("--dragon-card-width", `${cardWidth}px`);
|
||||
cards.forEach((card, index) => {
|
||||
const x = (index - center) * step;
|
||||
card.style.setProperty("--card-x", `${x.toFixed(2)}px`);
|
||||
card.style.setProperty("--card-rotation", "0deg");
|
||||
card.style.setProperty("--card-y", "0px");
|
||||
card.style.zIndex = String(index + 1);
|
||||
const hitZone = hitZones[index];
|
||||
if (hitZone) {
|
||||
const zoneWidth = index === cards.length - 1 ? cardWidth : Math.max(18, step);
|
||||
hitZone.style.left = `calc(50% + ${(x - cardWidth / 2).toFixed(2)}px)`;
|
||||
hitZone.style.width = `${zoneWidth.toFixed(2)}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderDragonTraderDetail(trader) {
|
||||
const container = document.querySelector("#dragonTraderDetail");
|
||||
if (!trader) {
|
||||
container.hidden = true;
|
||||
renderEmptyState(container, "选择一位游资查看操作明细", { className: "dragon-empty" });
|
||||
return;
|
||||
}
|
||||
container.hidden = false;
|
||||
container.innerHTML = `
|
||||
<header class="dragon-detail-header">
|
||||
<div><span>当日操作明细</span><h3>${escapeHtml(trader.name)}</h3><p>${escapeHtml(trader.description || "按当日公开龙虎榜席位汇总")}</p></div>
|
||||
<dl><div><dt>买入</dt><dd class="up">${formatMoneyMillion(trader.buy_million)}</dd></div><div><dt>卖出</dt><dd class="down">${formatMoneyMillion(trader.sell_million)}</dd></div><div><dt>净额</dt><dd class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</dd></div></dl>
|
||||
</header>
|
||||
<div class="trader-operations table-frame tbl-wrap">
|
||||
<table class="data-table tbl dragon-operation-table">
|
||||
<colgroup><col class="dragon-col-index"><col class="dragon-col-stock"><col class="dragon-col-direction"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-seat"><col class="dragon-col-reason"></colgroup>
|
||||
<thead><tr><th class="row-number num">序号</th><th>股票</th><th>方向</th><th class="number num">涨幅(%)</th><th class="number num">买入(百万)</th><th class="number num">卖出(百万)</th><th class="number num">净额(百万)</th><th>关联席位</th><th class="reason-column">标签 / 上榜原因</th></tr></thead>
|
||||
<tbody>${(trader.operations || []).map((operation, index) => `
|
||||
<tr data-code="${escapeHtml(operation.code)}">
|
||||
<td class="row-number num">${index + 1}</td>
|
||||
<td><strong class="sname">${escapeHtml(operation.name)}</strong><span class="scode">${escapeHtml(operation.code)}</span></td>
|
||||
<td><span class="direction-label ${changeClass(operation.net_buy_million)}">${escapeHtml(operation.direction)}</span></td>
|
||||
<td class="number num ${operation.change == null ? "" : changeClass(operation.change)}">${operation.change == null ? "" : signed(operation.change)}</td>
|
||||
<td class="number num">${operation.buy_million == null ? "" : formatNumber(operation.buy_million, 2)}</td>
|
||||
<td class="number num">${operation.sell_million == null ? "" : formatNumber(operation.sell_million, 2)}</td>
|
||||
<td class="number num ${operation.net_buy_million == null ? "" : changeClass(operation.net_buy_million)}">${operation.net_buy_million == null ? "" : signed(operation.net_buy_million)}</td>
|
||||
<td class="seat-cell" title="${escapeHtml(operation.seat_name)}">${escapeHtml(operation.seat_name)}</td>
|
||||
<td class="reason-column" title="${escapeHtml([operation.tag, operation.reason].filter((item) => item && item !== "--").join(" · "))}">${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason && operation.reason !== "--" ? operation.reason : "")}</td>
|
||||
</tr>`).join("")}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
bindStockRows(container);
|
||||
markAutoSortableHeaders(container);
|
||||
}
|
||||
|
||||
function renderUnclassifiedSeats() {
|
||||
const seats = state.dragonTiger?.unclassified_seats || [];
|
||||
const canManage = state.user?.role === "admin";
|
||||
document.querySelector("#dragonUnclassifiedSection").hidden = !canManage || seats.length === 0;
|
||||
document.querySelector("#dragonUnclassifiedFilter").hidden = !canManage || seats.length === 0;
|
||||
if (!seats.length && state.dragonFilter === "unclassified") {
|
||||
state.dragonFilter = "all";
|
||||
document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.dragonFilter === "all");
|
||||
});
|
||||
renderDragonTraderList();
|
||||
}
|
||||
setText("unclassifiedCount", `${seats.length} 个`);
|
||||
const list = document.querySelector("#unclassifiedSeatList");
|
||||
list.innerHTML = seats.map((seat, index) => `
|
||||
<form class="unclassified-seat-row" data-unclassified-index="${index}">
|
||||
<span class="unclassified-seat-name" title="${escapeHtml(seat.seat_name)}">${escapeHtml(seat.seat_name)}</span>
|
||||
<span class="unclassified-seat-stats">${number(seat.operation_count)} 笔 · ${number(seat.stock_count)} 股</span>
|
||||
<strong class="${changeClass(seat.net_buy_million)}">${formatMoneyMillion(seat.net_buy_million)}</strong>
|
||||
<input type="text" maxlength="50" placeholder="输入游资名" aria-label="${escapeHtml(seat.seat_name)}的游资名" required>
|
||||
<button class="button" type="submit">归类</button>
|
||||
</form>
|
||||
`).join("") || emptyStateHtml("当前席位均已归类");
|
||||
list.querySelectorAll(".unclassified-seat-row").forEach((form) => {
|
||||
form.addEventListener("submit", saveSeatAlias);
|
||||
});
|
||||
}
|
||||
|
||||
function dragonIdentityLabel(type) {
|
||||
return { trader: "游资", institution: "机构", channel: "通道", unclassified: "待归类" }[type] || "席位";
|
||||
}
|
||||
|
||||
async function saveSeatAlias(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const seat = state.dragonTiger?.unclassified_seats?.[number(form.dataset.unclassifiedIndex)];
|
||||
const alias = form.querySelector("input").value.trim();
|
||||
if (!seat || !alias) {
|
||||
showToast("请输入游资名");
|
||||
return;
|
||||
}
|
||||
const button = form.querySelector("button");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await apiRequest("/api/seat-aliases", "POST", { seat_name: seat.seat_name, alias });
|
||||
state.dragonTiger = null;
|
||||
await loadDragonTiger();
|
||||
showToast(`已将席位归类为 ${alias}`);
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2660-3028 */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2125-2216 */
|
||||
function renderLadderMini(ladders) {
|
||||
const container = document.querySelector("#ladderMini");
|
||||
const highest = ladders.length ? Math.max(...ladders.map((item) => number(item.level))) : 0;
|
||||
setText("maxHeight", highest ? `最高 ${highest} 板` : "暂无");
|
||||
container.innerHTML = ladders.slice(0, 5).map((group) => {
|
||||
const allNames = group.stocks.map((stock) => stock.name).filter(Boolean);
|
||||
const visibleNames = allNames.slice(0, 3).join("、");
|
||||
const suffix = allNames.length > 3 ? ` <em>等 ${number(group.count)} 只</em>` : "";
|
||||
return `<div class="pool-side-group">
|
||||
<div><strong>${escapeHtml(group.label)}</strong><small>${number(group.count)} 只</small></div>
|
||||
<p title="${escapeHtml(allNames.join("、"))}">${escapeHtml(visibleNames || "--")}${suffix}</p>
|
||||
</div>`;
|
||||
}).join("") || emptyStateHtml("暂无梯队数据");
|
||||
}
|
||||
|
||||
function renderSectorMini(sectors) {
|
||||
document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
|
||||
<div class="pool-hot-row"><strong title="${escapeHtml(sector.name)}">${escapeHtml(sector.name)}</strong><span>${number(sector.count)}</span></div>
|
||||
`).join("") || emptyStateHtml("暂无板块数据");
|
||||
}
|
||||
|
||||
function renderLadderBoard(ladders) {
|
||||
const container = document.querySelector("#ladderBoard");
|
||||
const insights = document.querySelector("#ladderInsights");
|
||||
const ordered = [...ladders].sort((left, right) => number(right.level) - number(left.level));
|
||||
const maxLevel = ordered.length ? Math.max(...ordered.map((group) => number(group.level))) : 0;
|
||||
const topVisibleLevel = Math.max(5, maxLevel);
|
||||
const groupMap = new Map(ordered.map((group) => [number(group.level), group]));
|
||||
const displayGroups = Array.from({ length: topVisibleLevel }, (_, index) => {
|
||||
const level = topVisibleLevel - index;
|
||||
return groupMap.get(level) || { level, label: level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`, count: 0, stocks: [] };
|
||||
});
|
||||
const total = ordered.reduce((sum, group) => sum + number(group.count), 0);
|
||||
const spaceStocks = ordered.find((group) => number(group.level) === maxLevel)?.stocks || [];
|
||||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||||
setText("ladderDateRange", `数据日期 ${currentDate}`);
|
||||
container.innerHTML = displayGroups.map((group) => {
|
||||
const level = number(group.level);
|
||||
const limit = level === 1 || level === 2 ? 8 : 99;
|
||||
const expanded = state.expandedLadderLevels.has(level);
|
||||
const groupStocks = [...(group.stocks || [])].sort((left, right) => {
|
||||
if (state.ladderSortMode === "open") {
|
||||
return number(left.open_times) - number(right.open_times)
|
||||
|| String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
|
||||
}
|
||||
return String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
|
||||
});
|
||||
const stocks = expanded ? groupStocks : groupStocks.slice(0, limit);
|
||||
const remaining = Math.max(0, groupStocks.length - stocks.length);
|
||||
const label = group.label || (level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`);
|
||||
const color = { 1: "#2563eb", 2: "#16a34a", 3: "#d97706", 4: "#e04536" }[level] || "#9ca3af";
|
||||
return `
|
||||
<section class="market-ladder-tier ${number(group.count) ? "" : "is-gap"}" data-ladder-level-card="${level}">
|
||||
<div class="market-ladder-label" style="--tier-color:${color}"><div class="market-ladder-level"><span class="market-ladder-dot"></span>${escapeHtml(label)}</div><div class="market-ladder-count">${number(group.count)} 只</div>${number(group.count) && level > 1 ? `<div class="market-ladder-rate">${escapeHtml(label)} · <b>${formatNumber(number(group.count) / Math.max(number(groupMap.get(level - 1)?.count), 1) * 100, 1)}%</b></div>` : ""}</div>
|
||||
<div class="market-ladder-stocks">${stocks.length ? stocks.map((stock) => {
|
||||
const onePrice = String(stock.first_time || "").startsWith("09:25") && number(stock.open_times) === 0;
|
||||
const broken = number(stock.open_times) >= 6;
|
||||
const amount = number(stock.seal_amount_million) ? `封单 ${formatNumber(stock.seal_amount_million, 0)} 万` : `成交 ${formatNumber(stock.amount_billion, 1)} 亿`;
|
||||
return `<button type="button" class="market-ladder-stock" data-code="${escapeHtml(stock.code)}" aria-label="查看 ${escapeHtml(stock.name)} ${escapeHtml(stock.code)}详情">
|
||||
<span class="market-ladder-stock-first"><strong>${escapeHtml(stock.name)}</strong><small class="stock-code">${escapeHtml(stock.code)}</small><span class="market-ladder-tags">${onePrice ? '<em class="market-ladder-tag one-price">一字</em>' : ""}${broken ? `<em class="market-ladder-tag broken">烂板×${number(stock.open_times)}</em>` : ""}</span></span>
|
||||
<span class="market-ladder-stock-second"><b>${escapeHtml(stock.sector || stock.reason || "其他")}</b><small>${stock.first_time && stock.first_time !== "--" ? escapeHtml(stock.first_time) : "时间待校正"}</small><small>${amount}</small></span>
|
||||
</button>`;
|
||||
}).join("") : `<div class="market-ladder-gap-note">${level >= maxLevel ? `断层 · ${escapeHtml(label)}及以上空缺` : "该层暂时空缺"}</div>`}${groupStocks.length > limit ? `<button class="market-ladder-more" type="button" data-ladder-level="${level}">${expanded ? "收起" : `展开剩余 ${remaining} 只`}<i data-lucide="chevron-${expanded ? "up" : "down"}"></i></button>` : ""}</div>
|
||||
</section>`;
|
||||
}).join("");
|
||||
const structureRows = displayGroups.filter((group) => number(group.count) || number(group.level) <= maxLevel + 1);
|
||||
const maxCount = Math.max(1, ...structureRows.map((group) => number(group.count)));
|
||||
const rateRows = (state.dashboard?.limit_performance || []).map((row) => ({
|
||||
label: `${row.label || (number(row.level) === 1 ? "昨日首板" : `昨日${number(row.level)}板`)} → 今日`,
|
||||
value: clamp(number(row.advance_rate), 0, 100),
|
||||
}));
|
||||
const previousMax = Math.max(0, ...(state.dashboard?.yesterday_limits || []).map((row) => number(row.prior_streak)));
|
||||
const spaceChange = previousMax && maxLevel < previousMax ? `较昨日 ${previousMax} 板 ↓ 空间压缩` : previousMax && maxLevel > previousMax ? `较昨日 ${previousMax} 板 ↑ 高度抬升` : "高度与昨日接近";
|
||||
const spaceNote = maxLevel >= 5 ? "高位梯队仍有辨识度,重点观察承接而非单看高度。" : maxLevel >= 3 ? "空间位于中段,梯队延续性比绝对高度更重要。" : "高度受到压缩,先观察首板向二板的结构修复。";
|
||||
const strongestGroup = structureRows.reduce((best, group) => number(group.count) > number(best?.count) ? group : best, structureRows[0]);
|
||||
insights.innerHTML = `
|
||||
<section class="market-ladder-insight-card market-ladder-apex-card"><header><h3>空间板</h3><span>市场高度</span></header><div class="market-ladder-apex"><div><strong>${maxLevel ? `${maxLevel} 板` : "--"}</strong><em>${escapeHtml(spaceChange)}</em></div><p>${spaceStocks.length ? spaceStocks.map((stock) => `<b>${escapeHtml(stock.name)}</b>(${escapeHtml(stock.sector || "其他")})`).join(" · ") : "暂无空间板"}</p></div><p>${spaceNote}</p></section>
|
||||
<section class="market-ladder-insight-card"><header><h3>梯队结构</h3><span>完整度</span></header><div class="market-ladder-pyramid">${structureRows.map((group) => `<div class="market-ladder-pyramid-row ${number(group.count) ? "" : "is-gap"}"><span>${escapeHtml(group.label || `${number(group.level)}板`)}</span><i><b style="width:${Math.max(number(group.count) ? 8 : 100, number(group.count) / maxCount * 100)}%"></b></i><strong>${number(group.count) ? `${number(group.count)} 只` : "断层"}</strong></div>`).join("")}</div><p>断层越少,梯队从低位向高位传导越连贯。当前腰部为 <b>${escapeHtml(strongestGroup?.label || "--")}</b>。</p></section>
|
||||
<section class="market-ladder-insight-card"><header><h3>晋级率参考</h3><span>昨日梯队 → 今日</span></header><div class="market-ladder-rate-list">${rateRows.length ? rateRows.map((row) => `<div><span>${escapeHtml(row.label)}</span><i><b class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}" style="width:${Math.max(row.value, row.value > 0 ? 2 : 0)}%"></b></i><strong class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}">${formatNumber(row.value, 1)}%</strong></div>`).join("") : '<div class="empty-state">暂无可比梯队</div>'}</div><small class="market-ladder-source">数据来自“涨停表现”页 · 昨日梯队样本</small></section>`;
|
||||
container.querySelectorAll("[data-ladder-level]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const level = number(button.dataset.ladderLevel);
|
||||
if (state.expandedLadderLevels.has(level)) state.expandedLadderLevels.delete(level);
|
||||
else state.expandedLadderLevels.add(level);
|
||||
renderLadderBoard(state.dashboard?.ladders || []);
|
||||
});
|
||||
});
|
||||
bindStockRows(container);
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2125-2216 */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,497 @@
|
||||
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
|
||||
enter: ["loadMentor"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:4337-4827 */
|
||||
async function loadMentorSetup(force = false) {
|
||||
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
|
||||
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
|
||||
renderMentorWorkspace();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
const payload = await apiRequest(`/api/mentors/setup?${query}`);
|
||||
payload.requestedDate = requestedDate;
|
||||
if (!payload.preferences_configured) {
|
||||
payload.mentors.sort((first, second) => {
|
||||
if (Boolean(first.private) !== Boolean(second.private)) return first.private ? -1 : 1;
|
||||
return String(first.name || "").localeCompare(String(second.name || ""), "zh-CN");
|
||||
});
|
||||
payload.mentors.forEach((mentor, index) => { mentor.sort_order = index; });
|
||||
}
|
||||
state.mentorSetup = payload;
|
||||
const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId);
|
||||
state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || "";
|
||||
state.mentorMessages = await loadMentorMessages();
|
||||
renderMentorWorkspace();
|
||||
} catch (error) {
|
||||
showMentorNotice(error.message || "问师模块加载失败");
|
||||
showToast(error.message || "问师模块加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderMentorWorkspace() {
|
||||
const setup = state.mentorSetup;
|
||||
if (!setup) return;
|
||||
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
|
||||
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
|
||||
setText("activeMentorName", selected?.name || "--");
|
||||
setText("mobileActiveMentorName", selected?.name || "选择思维模型");
|
||||
document.querySelector("#activeMentorBadges").innerHTML = selected ? renderMentorBadges(selected, true) : "";
|
||||
setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--");
|
||||
document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4)
|
||||
.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
|
||||
renderMentorDirectory();
|
||||
renderMentorMessages();
|
||||
}
|
||||
|
||||
function renderMentorDirectory() {
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const query = state.mentorQuery;
|
||||
const filtered = mentors.filter((mentor) => {
|
||||
if (state.mentorSortMode) return true;
|
||||
if (state.mentorGrade !== "all" && mentor.evidence?.grade !== state.mentorGrade) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
mentor.name,
|
||||
mentor.description,
|
||||
mentor.tagline,
|
||||
mentor.evidence?.label,
|
||||
mentor.evidence?.note,
|
||||
...(mentor.focus || []),
|
||||
].filter(Boolean).join(" ").toLocaleLowerCase("zh-CN");
|
||||
return haystack.includes(query);
|
||||
});
|
||||
setText("mentorCount", filtered.length === mentors.length ? `${mentors.length} 位` : `${filtered.length} / ${mentors.length} 位`);
|
||||
const sortToggle = document.querySelector("#mentorSortToggle");
|
||||
sortToggle.classList.toggle("active", state.mentorSortMode);
|
||||
sortToggle.setAttribute("aria-pressed", String(state.mentorSortMode));
|
||||
sortToggle.querySelector("span").textContent = state.mentorSortMode ? "完成" : "整理";
|
||||
document.querySelector("#mentorSortHint").hidden = !state.mentorSortMode;
|
||||
document.querySelector("#mentorSearchInput").disabled = state.mentorSortMode;
|
||||
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
||||
button.disabled = state.mentorSortMode;
|
||||
});
|
||||
const container = document.querySelector("#mentorList");
|
||||
container.classList.toggle("is-sorting", state.mentorSortMode);
|
||||
container.innerHTML = filtered.map((mentor) => {
|
||||
const group = mentors.filter((item) => Boolean(item.pinned) === Boolean(mentor.pinned));
|
||||
const groupIndex = group.findIndex((item) => item.id === mentor.id);
|
||||
return `
|
||||
<article class="mentor-option ${mentor.id === state.selectedMentorId ? "active" : ""} ${mentor.pinned ? "is-pinned" : ""}"
|
||||
data-mentor-card="${escapeHtml(mentor.id)}" draggable="${state.mentorSortMode && !state.mentorSavingPreferences}">
|
||||
<button type="button" class="mentor-option-main" data-mentor-id="${escapeHtml(mentor.id)}" aria-pressed="${mentor.id === state.selectedMentorId}" ${state.mentorLoading ? "disabled" : ""}>
|
||||
<span class="mentor-option-copy">
|
||||
<span class="mentor-option-heading">
|
||||
<strong>${escapeHtml(mentor.name)}</strong>
|
||||
<span class="mentor-option-badges">${renderMentorBadges(mentor)}</span>
|
||||
</span>
|
||||
<em title="${escapeHtml(mentor.description || "")}">${escapeHtml(mentor.description || mentor.tagline || "思维模型")}</em>
|
||||
<span class="mentor-option-meta">
|
||||
${mentor.evidence?.label ? `<span class="mentor-evidence-source" title="${escapeHtml(mentor.evidence?.note || "素材说明")}">${escapeHtml(mentor.evidence.label)}</span>` : ""}
|
||||
${(mentor.focus || []).slice(0, 2).map((item) => `<span>#${escapeHtml(item)}</span>`).join("")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="mentor-option-tools">
|
||||
<button type="button" class="mentor-pin-button ${mentor.pinned ? "active" : ""}" data-mentor-pin="${escapeHtml(mentor.id)}"
|
||||
aria-label="${mentor.pinned ? "取消置顶" : "置顶"}${escapeHtml(mentor.name)}" title="${mentor.pinned ? "取消置顶" : "置顶"}" ${state.mentorSavingPreferences ? "disabled" : ""}>
|
||||
<i data-lucide="pin"></i>
|
||||
</button>
|
||||
${state.mentorSortMode ? `
|
||||
<button type="button" class="mentor-order-button" data-mentor-move="up" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="上移${escapeHtml(mentor.name)}" title="上移" ${groupIndex <= 0 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-up"></i></button>
|
||||
<button type="button" class="mentor-order-button" data-mentor-move="down" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="下移${escapeHtml(mentor.name)}" title="下移" ${groupIndex >= group.length - 1 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-down"></i></button>
|
||||
` : ""}
|
||||
</span>
|
||||
</article>
|
||||
`;
|
||||
}).join("");
|
||||
document.querySelector("#mentorListEmpty").hidden = filtered.length > 0;
|
||||
document.querySelectorAll("[data-mentor-id]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectMentor(button.dataset.mentorId));
|
||||
});
|
||||
document.querySelectorAll("[data-mentor-pin]").forEach((button) => {
|
||||
button.addEventListener("click", () => toggleMentorPin(button.dataset.mentorPin));
|
||||
});
|
||||
document.querySelectorAll("[data-mentor-move]").forEach((button) => {
|
||||
button.addEventListener("click", () => moveMentor(button.dataset.mentorTarget, button.dataset.mentorMove));
|
||||
});
|
||||
document.querySelectorAll("[data-mentor-card]").forEach((card) => {
|
||||
card.addEventListener("dragstart", handleMentorDragStart);
|
||||
card.addEventListener("dragover", handleMentorDragOver);
|
||||
card.addEventListener("drop", handleMentorDrop);
|
||||
card.addEventListener("dragend", clearMentorDragState);
|
||||
});
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function toggleMentorSortMode() {
|
||||
state.mentorSortMode = !state.mentorSortMode;
|
||||
if (state.mentorSortMode) {
|
||||
state.mentorQuery = "";
|
||||
state.mentorGrade = "all";
|
||||
document.querySelector("#mentorSearchInput").value = "";
|
||||
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.mentorGrade === "all");
|
||||
});
|
||||
}
|
||||
renderMentorDirectory();
|
||||
}
|
||||
|
||||
async function toggleMentorPin(mentorId) {
|
||||
if (state.mentorSavingPreferences) return;
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const index = mentors.findIndex((item) => item.id === mentorId);
|
||||
if (index < 0) return;
|
||||
const [mentor] = mentors.splice(index, 1);
|
||||
mentor.pinned = !mentor.pinned;
|
||||
if (mentor.pinned) {
|
||||
mentors.unshift(mentor);
|
||||
} else {
|
||||
const firstUnpinned = mentors.findIndex((item) => !item.pinned);
|
||||
mentors.splice(firstUnpinned < 0 ? mentors.length : firstUnpinned, 0, mentor);
|
||||
}
|
||||
normalizeMentorOrder();
|
||||
renderMentorWorkspace();
|
||||
await persistMentorPreferences();
|
||||
}
|
||||
|
||||
async function moveMentor(mentorId, direction) {
|
||||
if (state.mentorSavingPreferences) return;
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const index = mentors.findIndex((item) => item.id === mentorId);
|
||||
if (index < 0) return;
|
||||
const step = direction === "up" ? -1 : 1;
|
||||
const targetIndex = index + step;
|
||||
if (targetIndex < 0 || targetIndex >= mentors.length) return;
|
||||
if (Boolean(mentors[index].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
|
||||
[mentors[index], mentors[targetIndex]] = [mentors[targetIndex], mentors[index]];
|
||||
normalizeMentorOrder();
|
||||
renderMentorDirectory();
|
||||
await persistMentorPreferences();
|
||||
}
|
||||
|
||||
function handleMentorDragStart(event) {
|
||||
if (!state.mentorSortMode || state.mentorSavingPreferences) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
state.mentorDragId = event.currentTarget.dataset.mentorCard || "";
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", state.mentorDragId);
|
||||
event.currentTarget.classList.add("is-dragging");
|
||||
}
|
||||
|
||||
function handleMentorDragOver(event) {
|
||||
const source = state.mentorSetup?.mentors.find((item) => item.id === state.mentorDragId);
|
||||
const target = state.mentorSetup?.mentors.find((item) => item.id === event.currentTarget.dataset.mentorCard);
|
||||
if (!source || !target || Boolean(source.pinned) !== Boolean(target.pinned)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
event.currentTarget.classList.add("is-drag-over");
|
||||
}
|
||||
|
||||
async function handleMentorDrop(event) {
|
||||
event.preventDefault();
|
||||
const sourceId = state.mentorDragId || event.dataTransfer.getData("text/plain");
|
||||
const targetId = event.currentTarget.dataset.mentorCard || "";
|
||||
clearMentorDragState();
|
||||
if (!sourceId || !targetId || sourceId === targetId) return;
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
const sourceIndex = mentors.findIndex((item) => item.id === sourceId);
|
||||
const targetIndex = mentors.findIndex((item) => item.id === targetId);
|
||||
if (sourceIndex < 0 || targetIndex < 0) return;
|
||||
if (Boolean(mentors[sourceIndex].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
|
||||
const [mentor] = mentors.splice(sourceIndex, 1);
|
||||
const insertionIndex = mentors.findIndex((item) => item.id === targetId);
|
||||
mentors.splice(insertionIndex, 0, mentor);
|
||||
normalizeMentorOrder();
|
||||
renderMentorDirectory();
|
||||
await persistMentorPreferences();
|
||||
}
|
||||
|
||||
function clearMentorDragState() {
|
||||
state.mentorDragId = "";
|
||||
document.querySelectorAll(".mentor-option.is-dragging, .mentor-option.is-drag-over").forEach((item) => {
|
||||
item.classList.remove("is-dragging", "is-drag-over");
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMentorOrder() {
|
||||
(state.mentorSetup?.mentors || []).forEach((mentor, index) => {
|
||||
mentor.sort_order = index;
|
||||
});
|
||||
}
|
||||
|
||||
async function persistMentorPreferences() {
|
||||
const mentors = state.mentorSetup?.mentors || [];
|
||||
state.mentorSavingPreferences = true;
|
||||
renderMentorDirectory();
|
||||
try {
|
||||
await apiRequest("/api/mentors/preferences", "POST", {
|
||||
order: mentors.map((item) => item.id),
|
||||
pinned: mentors.filter((item) => item.pinned).map((item) => item.id),
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(error.message || "问师顺序保存失败");
|
||||
await loadMentorSetup(true);
|
||||
} finally {
|
||||
state.mentorSavingPreferences = false;
|
||||
renderMentorDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
function renderMentorBadges(mentor, expanded = false) {
|
||||
const badges = [];
|
||||
if (mentor.private) {
|
||||
badges.push('<span class="mentor-badge private" title="仅管理员本人可见"><i data-lucide="lock-keyhole"></i>仅自己</span>');
|
||||
}
|
||||
const grade = mentor.evidence?.grade;
|
||||
if (grade) {
|
||||
badges.push(`<span class="mentor-badge evidence grade-${escapeHtml(grade.toLowerCase())}" title="${escapeHtml(mentor.evidence?.note || "素材等级")}">${escapeHtml(grade)}</span>`);
|
||||
}
|
||||
return badges.join("");
|
||||
}
|
||||
|
||||
function toggleMentorDirectory(open) {
|
||||
const mobileOpen = Boolean(open) && window.innerWidth <= 720;
|
||||
state.mentorDirectoryOpen = mobileOpen;
|
||||
const sidebar = document.querySelector("#mentorView .mentor-sidebar");
|
||||
const backdrop = document.querySelector("#mentorDirectoryBackdrop");
|
||||
const toggle = document.querySelector("#mentorDirectoryToggle");
|
||||
sidebar.classList.toggle("is-open", mobileOpen);
|
||||
backdrop.hidden = !mobileOpen;
|
||||
toggle.setAttribute("aria-expanded", String(mobileOpen));
|
||||
document.body.classList.toggle("mentor-directory-open", mobileOpen);
|
||||
if (mobileOpen) requestAnimationFrame(() => document.querySelector("#mentorSearchInput").focus());
|
||||
}
|
||||
|
||||
async function selectMentor(mentorId) {
|
||||
if (mentorId === state.selectedMentorId) {
|
||||
toggleMentorDirectory(false);
|
||||
return;
|
||||
}
|
||||
state.selectedMentorId = mentorId;
|
||||
state.mentorMessages = [];
|
||||
hideMentorNotice();
|
||||
renderMentorWorkspace();
|
||||
toggleMentorDirectory(false);
|
||||
state.mentorMessages = await loadMentorMessages();
|
||||
renderMentorMessages();
|
||||
}
|
||||
|
||||
function renderMentorMessages() {
|
||||
const container = document.querySelector("#mentorMessages");
|
||||
const selected = state.mentorSetup?.mentors.find((item) => item.id === state.selectedMentorId);
|
||||
if (!state.mentorMessages.length && !state.mentorLoading) {
|
||||
container.innerHTML = `
|
||||
<div class="mentor-empty-state">
|
||||
<span class="mentor-empty-mark" aria-hidden="true"><i data-lucide="messages-square"></i></span>
|
||||
<strong>向「${escapeHtml(selected?.name || "问师")}」请教</strong>
|
||||
<p>${escapeHtml(selected?.tagline || selected?.description || "选择一个问题开始对话")}</p>
|
||||
</div>
|
||||
`;
|
||||
refreshIcons();
|
||||
} else {
|
||||
container.innerHTML = state.mentorMessages.map((message) => `
|
||||
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
|
||||
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
|
||||
<div class="mentor-message-content">${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}</div>
|
||||
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
||||
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
|
||||
</article>
|
||||
`).join("");
|
||||
if (state.mentorLoading && !state.mentorMessages.some((message) => message.streaming)) {
|
||||
container.insertAdjacentHTML("beforeend", `
|
||||
<article class="mentor-message assistant loading-message">
|
||||
<div class="mentor-message-label">${escapeHtml(selected?.name || "问师")}</div>
|
||||
<p>正在读取复盘数据并推演...</p>
|
||||
</article>
|
||||
`);
|
||||
}
|
||||
}
|
||||
document.querySelector("#clearMentorChatButton").disabled = !state.mentorMessages.length || state.mentorLoading;
|
||||
document.querySelector("#mentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
|
||||
document.querySelector("#sendMentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
|
||||
document.querySelector("#mentorSortToggle").disabled = state.mentorLoading;
|
||||
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
||||
}
|
||||
|
||||
async function sendMentorQuestion(event) {
|
||||
event.preventDefault();
|
||||
if (state.mentorLoading || !state.selectedMentorId) return;
|
||||
const input = document.querySelector("#mentorQuestion");
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
const history = state.mentorMessages.slice(-6).map((item) => ({
|
||||
role: item.role,
|
||||
content: item.content.slice(0, 3500),
|
||||
}));
|
||||
state.mentorMessages.push({ role: "user", content: question });
|
||||
const responseMessage = { role: "assistant", content: "", streaming: true, meta: "" };
|
||||
state.mentorMessages.push(responseMessage);
|
||||
input.value = "";
|
||||
state.mentorLoading = true;
|
||||
state.mentorController = new AbortController();
|
||||
hideMentorNotice();
|
||||
renderMentorMessages();
|
||||
renderMentorDirectory();
|
||||
setStatus("问师正在读取复盘数据");
|
||||
try {
|
||||
await streamMentorRequest(
|
||||
{
|
||||
mentor_id: state.selectedMentorId,
|
||||
trade_date: elements.tradeDate.value,
|
||||
question,
|
||||
history,
|
||||
},
|
||||
state.mentorController.signal,
|
||||
(chunk) => {
|
||||
responseMessage.content += chunk;
|
||||
scheduleMentorRender();
|
||||
},
|
||||
(meta) => {
|
||||
responseMessage.meta = `${displayCompactDate(meta.data_trade_date || elements.tradeDate.value)} · 回答完成`;
|
||||
if (meta.notice) showMentorNotice(meta.notice);
|
||||
},
|
||||
);
|
||||
responseMessage.streaming = false;
|
||||
setStatus("问师回答完成");
|
||||
} catch (error) {
|
||||
responseMessage.streaming = false;
|
||||
responseMessage.error = true;
|
||||
if (!responseMessage.content) {
|
||||
state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage);
|
||||
}
|
||||
showMentorNotice(error.message || "问师回答失败");
|
||||
showToast(error.message || "问师回答失败");
|
||||
setStatus("问师回答失败");
|
||||
} finally {
|
||||
state.mentorLoading = false;
|
||||
state.mentorController = null;
|
||||
renderMentorMessages();
|
||||
renderMentorDirectory();
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
let mentorRenderFrame = 0;
|
||||
|
||||
function scheduleMentorRender() {
|
||||
if (mentorRenderFrame) return;
|
||||
mentorRenderFrame = requestAnimationFrame(() => {
|
||||
mentorRenderFrame = 0;
|
||||
renderMentorMessages();
|
||||
});
|
||||
}
|
||||
|
||||
async function streamMentorRequest(body, signal, onDelta, onMeta) {
|
||||
await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", {
|
||||
method: "POST",
|
||||
body,
|
||||
signal,
|
||||
errorMessage: "问师暂不可用",
|
||||
onEvent: (event) => {
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
if (event.type === "meta") onMeta(event);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useMentorQuickPrompt(prompt) {
|
||||
const input = document.querySelector("#mentorQuestion");
|
||||
input.value = prompt || "";
|
||||
input.focus();
|
||||
}
|
||||
|
||||
async function clearMentorConversation() {
|
||||
if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return;
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
mentor_id: state.selectedMentorId,
|
||||
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
||||
});
|
||||
await apiRequest(`/api/mentors/messages?${query}`, "DELETE");
|
||||
state.mentorMessages = [];
|
||||
hideMentorNotice();
|
||||
renderMentorMessages();
|
||||
} catch (error) {
|
||||
showToast(error.message || "对话记录清空失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMentorMessages() {
|
||||
if (!state.selectedMentorId) return [];
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
mentor_id: state.selectedMentorId,
|
||||
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
||||
});
|
||||
const payload = await apiRequest(`/api/mentors/messages?${query}`);
|
||||
return (payload.items || []).filter(
|
||||
(item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string",
|
||||
).slice(-100);
|
||||
} catch (error) {
|
||||
showMentorNotice(error.message || "对话记录加载失败");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function showMentorNotice(message) {
|
||||
const notice = document.querySelector("#mentorNotice");
|
||||
notice.textContent = message;
|
||||
notice.hidden = false;
|
||||
}
|
||||
|
||||
function hideMentorNotice() {
|
||||
document.querySelector("#mentorNotice").hidden = true;
|
||||
}
|
||||
|
||||
function formatMentorAnswer(content) {
|
||||
const blocks = [];
|
||||
let listType = "";
|
||||
let listItems = [];
|
||||
const flushList = () => {
|
||||
if (!listItems.length) return;
|
||||
blocks.push(`<${listType} class="mentor-answer-list">${listItems.map((item) => `<li>${item}</li>`).join("")}</${listType}>`);
|
||||
listItems = [];
|
||||
listType = "";
|
||||
};
|
||||
String(content || "").replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").split("\n").forEach((rawLine) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
flushList();
|
||||
return;
|
||||
}
|
||||
const heading = line.match(/^#{1,3}\s+(.+)$/);
|
||||
const bullet = line.match(/^[-*]\s+(.+)$/);
|
||||
const ordered = line.match(/^\d+[.、]\s*(.+)$/);
|
||||
if (heading) {
|
||||
flushList();
|
||||
blocks.push(`<strong class="mentor-answer-heading">${formatMentorInline(escapeHtml(heading[1]))}</strong>`);
|
||||
} else if (/^-{3,}$/.test(line)) {
|
||||
flushList();
|
||||
blocks.push('<span class="mentor-answer-rule"></span>');
|
||||
} else if (line.startsWith("> ")) {
|
||||
flushList();
|
||||
blocks.push(`<span class="mentor-answer-quote">${formatMentorInline(escapeHtml(line.slice(2)))}</span>`);
|
||||
} else if (bullet || ordered) {
|
||||
const nextType = bullet ? "ul" : "ol";
|
||||
if (listType && listType !== nextType) flushList();
|
||||
listType = nextType;
|
||||
listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1])));
|
||||
} else {
|
||||
flushList();
|
||||
blocks.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
|
||||
}
|
||||
});
|
||||
flushList();
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
function formatMentorInline(content) {
|
||||
return content.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:4337-4827 */
|
||||
@@ -0,0 +1,409 @@
|
||||
window.XiaobaiPageModules.register("pools", [
|
||||
"limitPool",
|
||||
"brokenView",
|
||||
"downView",
|
||||
"yesterdayView",
|
||||
"performanceView",
|
||||
]);
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:1517-1915 */
|
||||
function getVisibleStocks() {
|
||||
if (!state.dashboard) return [];
|
||||
let rows = [...(state.dashboard.limits || [])];
|
||||
if (state.filter === "1") rows = rows.filter((row) => number(row.streak) === 1);
|
||||
if (state.filter === "2") rows = rows.filter((row) => number(row.streak) === 2);
|
||||
if (state.filter === "3") rows = rows.filter((row) => number(row.streak) >= 3);
|
||||
if (state.query) {
|
||||
rows = rows.filter((row) => {
|
||||
const haystack = `${row.code} ${row.name} ${row.sector} ${row.reason}`.toLowerCase();
|
||||
return haystack.includes(state.query);
|
||||
});
|
||||
}
|
||||
return rows.sort((left, right) => compareRows(left, right));
|
||||
}
|
||||
|
||||
function renderLimitTable() {
|
||||
if (!state.dashboard) return;
|
||||
const rows = getVisibleStocks();
|
||||
const allRows = state.dashboard.limits || [];
|
||||
const body = document.querySelector("#limitTableBody");
|
||||
body.innerHTML = rows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num"><span class="pool-streak-tag tag red">${streakLabel(row.streak)}</span></td>
|
||||
<td class="number num up">${signed(row.change)}</td>
|
||||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
|
||||
<td class="number num muted">${escapeHtml(row.last_time || "")}</td>
|
||||
<td class="number num">${limitOpenState(row)}</td>
|
||||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||||
<td class="number num">${formatLimitSealAmount(row.seal_amount_million)}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
setText("resultCount", `${rows.length} 只`);
|
||||
setText("limitPoolSubtitle", `${allRows.length} 只 · 数据日期 ${displayCompactDate(state.dashboard.meta?.trade_date || elements.tradeDate.value)}`);
|
||||
setText("limitAllCount", allRows.length);
|
||||
setText("limitFirstCount", allRows.filter((row) => number(row.streak) === 1).length);
|
||||
setText("limitSecondCount", allRows.filter((row) => number(row.streak) === 2).length);
|
||||
setText("limitThreePlusCount", allRows.filter((row) => number(row.streak) >= 3).length);
|
||||
document.querySelector("#emptyState").hidden = rows.length !== 0;
|
||||
updateSortHeaders();
|
||||
}
|
||||
|
||||
function limitOpenState(row) {
|
||||
const openTimes = number(row.open_times);
|
||||
const firstTime = String(row.first_time || "");
|
||||
if (firstTime.startsWith("09:25") && openTimes === 0) return '<span class="pool-state-tag one-word">一字</span>';
|
||||
if (openTimes >= 6) return `<span class="pool-state-tag broken">烂板×${openTimes}</span>`;
|
||||
return String(openTimes);
|
||||
}
|
||||
|
||||
function formatLimitSealAmount(value) {
|
||||
const amount = number(value);
|
||||
if (!amount) return "";
|
||||
return Math.round(amount).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function renderBrokenTable(rows) {
|
||||
const visibleRows = getVisibleBrokenRows(rows);
|
||||
setText("brokenCount", `${rows.length} 只`);
|
||||
setText("brokenMeta", ` · 触及涨停后未能封住 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
|
||||
const body = document.querySelector("#brokenTableBody");
|
||||
body.innerHTML = visibleRows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num ${changeClass(row.change)}" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
|
||||
<td class="number num broken-limit-gap" data-sort-value="${row.limitGap}">${formatNumber(row.limitGap, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
|
||||
<td class="number num" data-sort-value="${number(row.open_times)}">${brokenOpenState(row)}</td>
|
||||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
document.querySelector("#brokenEmptyState").hidden = visibleRows.length !== 0;
|
||||
updateBrokenSortHeaders();
|
||||
}
|
||||
|
||||
function getVisibleBrokenRows(rows = state.dashboard?.broken || []) {
|
||||
let visibleRows = rows.map((row) => ({ ...row, limitGap: brokenLimitGap(row) }));
|
||||
if (state.brokenQuery) {
|
||||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.brokenQuery));
|
||||
}
|
||||
if (!state.brokenSortKey) return visibleRows;
|
||||
return visibleRows.sort((left, right) => {
|
||||
const result = number(left[state.brokenSortKey]) - number(right[state.brokenSortKey]);
|
||||
return state.brokenSortDirection === "asc" ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
function brokenLimitRate(row) {
|
||||
const name = String(row.name || "").toUpperCase();
|
||||
const code = String(row.code || "").replace(/\D/g, "");
|
||||
if (name.includes("ST")) return 10;
|
||||
if (/^(300|301|688|689)/.test(code)) return 20;
|
||||
if (/^(4|8|92)/.test(code)) return 30;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function brokenLimitGap(row) {
|
||||
return Math.max(0, brokenLimitRate(row) - number(row.change));
|
||||
}
|
||||
|
||||
function brokenOpenState(row) {
|
||||
const openTimes = number(row.open_times);
|
||||
return openTimes >= 6
|
||||
? `<span class="broken-repeat-tag">反复炸 ×${openTimes}</span>`
|
||||
: String(openTimes);
|
||||
}
|
||||
|
||||
function changeBrokenSort(key) {
|
||||
if (state.brokenSortKey === key) state.brokenSortDirection = state.brokenSortDirection === "asc" ? "desc" : "asc";
|
||||
else {
|
||||
state.brokenSortKey = key;
|
||||
state.brokenSortDirection = "desc";
|
||||
}
|
||||
renderBrokenTable(state.dashboard?.broken || []);
|
||||
}
|
||||
|
||||
function updateBrokenSortHeaders() {
|
||||
document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => {
|
||||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", "none");
|
||||
if (header.dataset.brokenSort === state.brokenSortKey) {
|
||||
header.classList.add(state.brokenSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", state.brokenSortDirection === "asc" ? "ascending" : "descending");
|
||||
}
|
||||
const arrow = header.querySelector(".arr");
|
||||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.brokenSortDirection === "asc" ? "▲" : "▼") : "↕";
|
||||
});
|
||||
}
|
||||
|
||||
function renderDownTable(rows) {
|
||||
const visibleRows = getVisibleDownRows(rows);
|
||||
setText("downCount", `${rows.length} 只`);
|
||||
setText("downMeta", ` · 观察退潮、高位风险与亏钱效应 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
|
||||
renderDownSectorCluster(rows);
|
||||
const body = document.querySelector("#downTableBody");
|
||||
body.innerHTML = visibleRows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num down" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
|
||||
<td class="number num">${formatNumber(row.price, 2)}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
|
||||
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
|
||||
<td class="number num">${number(row.streak) > 0 ? number(row.streak) : ""}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
document.querySelector("#downEmptyState").hidden = visibleRows.length !== 0;
|
||||
updateDownSortHeaders();
|
||||
}
|
||||
|
||||
function getVisibleDownRows(rows = state.dashboard?.down_limits || []) {
|
||||
let visibleRows = [...rows];
|
||||
if (state.downQuery) {
|
||||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.downQuery));
|
||||
}
|
||||
if (!state.downSortKey) return visibleRows;
|
||||
return visibleRows.sort((left, right) => {
|
||||
const result = number(left[state.downSortKey]) - number(right[state.downSortKey]);
|
||||
return state.downSortDirection === "asc" ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDownSectorCluster(rows) {
|
||||
const counts = new Map();
|
||||
rows.forEach((row) => {
|
||||
const sector = String(row.sector || "其他").trim() || "其他";
|
||||
if (sector === "其他") return;
|
||||
counts.set(sector, (counts.get(sector) || 0) + 1);
|
||||
});
|
||||
const cluster = [...counts.entries()].sort((left, right) => right[1] - left[1])[0];
|
||||
const element = document.querySelector("#downSectorCluster");
|
||||
element.hidden = !cluster || cluster[1] < 2;
|
||||
element.textContent = cluster && cluster[1] >= 2 ? `${cluster[0]}集中跌停 ×${cluster[1]}` : "";
|
||||
}
|
||||
|
||||
function changeDownSort(key) {
|
||||
if (state.downSortKey === key) state.downSortDirection = state.downSortDirection === "asc" ? "desc" : "asc";
|
||||
else {
|
||||
state.downSortKey = key;
|
||||
state.downSortDirection = "asc";
|
||||
}
|
||||
renderDownTable(state.dashboard?.down_limits || []);
|
||||
}
|
||||
|
||||
function updateDownSortHeaders() {
|
||||
document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => {
|
||||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", "none");
|
||||
if (header.dataset.downSort === state.downSortKey) {
|
||||
header.classList.add(state.downSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", state.downSortDirection === "asc" ? "ascending" : "descending");
|
||||
}
|
||||
const arrow = header.querySelector(".arr");
|
||||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.downSortDirection === "asc" ? "▲" : "▼") : "↕";
|
||||
});
|
||||
}
|
||||
|
||||
function renderYesterdayTable(rows) {
|
||||
const visibleRows = getVisibleYesterdayRows(rows);
|
||||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||||
setText("yesterdayCount", `${rows.length} 只`);
|
||||
setText("yesterdayMeta", ` · 昨日 ${previousDate} → 今日 ${currentDate}`);
|
||||
renderYesterdaySummary(rows);
|
||||
const body = document.querySelector("#yesterdayTableBody");
|
||||
body.innerHTML = visibleRows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num">${number(row.prior_streak)}</td>
|
||||
<td class="number num ${changeClass(row.current_change)}">${signed(row.current_change)}</td>
|
||||
<td><span class="yesterday-outcome-tag ${yesterdayOutcomeClass(row.outcome)}">${escapeHtml(row.outcome)}</span></td>
|
||||
<td class="number num">${number(row.current_streak) ? `<span class="yesterday-height-tag">${number(row.current_streak)}</span>` : ""}</td>
|
||||
<td>${escapeHtml(row.sector || "其他")}</td>
|
||||
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(body);
|
||||
document.querySelector("#yesterdayEmptyState").hidden = visibleRows.length !== 0;
|
||||
updateYesterdayControls();
|
||||
}
|
||||
|
||||
function getVisibleYesterdayRows(rows = state.dashboard?.yesterday_limits || []) {
|
||||
let visibleRows = rows.filter((row) => {
|
||||
if (state.yesterdayFilter === "advance") return row.outcome === "晋级";
|
||||
if (state.yesterdayFilter === "positive") return number(row.current_change) > 0;
|
||||
if (state.yesterdayFilter === "fail") return row.outcome === "断板";
|
||||
if (state.yesterdayFilter === "risk") return ["炸板", "跌停"].includes(row.outcome);
|
||||
return true;
|
||||
});
|
||||
if (state.yesterdayQuery) {
|
||||
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.yesterdayQuery));
|
||||
}
|
||||
if (!state.yesterdaySortKey) return visibleRows;
|
||||
return visibleRows.sort((left, right) => {
|
||||
const result = number(left[state.yesterdaySortKey]) - number(right[state.yesterdaySortKey]);
|
||||
return state.yesterdaySortDirection === "asc" ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
function renderYesterdaySummary(rows) {
|
||||
const total = rows.length;
|
||||
const advance = rows.filter((row) => row.outcome === "晋级").length;
|
||||
const positive = rows.filter((row) => number(row.current_change) > 0).length;
|
||||
const fail = rows.filter((row) => row.outcome === "断板").length;
|
||||
const risk = rows.filter((row) => ["炸板", "跌停"].includes(row.outcome)).length;
|
||||
const rate = (value) => total ? value / total * 100 : 0;
|
||||
setText("yesterdayAllCount", total);
|
||||
setText("yesterdayAdvanceCount", advance);
|
||||
setText("yesterdayAdvanceRate", `晋级率 ${formatNumber(rate(advance), 1)}%`);
|
||||
setText("yesterdayPositiveCount", positive);
|
||||
setText("yesterdayPositiveRate", `兑现率 ${formatNumber(rate(positive), 1)}%`);
|
||||
setText("yesterdayFailCount", fail);
|
||||
setText("yesterdayFailRate", `占 ${formatNumber(rate(fail), 1)}%`);
|
||||
setText("yesterdayRiskCount", risk);
|
||||
setText("yesterdayRiskRate", `亏钱效应 ${formatNumber(rate(risk), 1)}%`);
|
||||
}
|
||||
|
||||
function yesterdayOutcomeClass(outcome) {
|
||||
return { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" }[outcome] || "fail";
|
||||
}
|
||||
|
||||
function changeYesterdaySort(key) {
|
||||
if (state.yesterdaySortKey === key) state.yesterdaySortDirection = state.yesterdaySortDirection === "asc" ? "desc" : "asc";
|
||||
else {
|
||||
state.yesterdaySortKey = key;
|
||||
state.yesterdaySortDirection = "desc";
|
||||
}
|
||||
renderYesterdayTable(state.dashboard?.yesterday_limits || []);
|
||||
}
|
||||
|
||||
function updateYesterdayControls() {
|
||||
document.querySelectorAll("[data-yesterday-filter]").forEach((button) => {
|
||||
const active = button.dataset.yesterdayFilter === state.yesterdayFilter;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => {
|
||||
header.classList.remove("sort-asc", "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", "none");
|
||||
if (header.dataset.yesterdaySort === state.yesterdaySortKey) {
|
||||
header.classList.add(state.yesterdaySortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
|
||||
header.setAttribute("aria-sort", state.yesterdaySortDirection === "asc" ? "ascending" : "descending");
|
||||
}
|
||||
const arrow = header.querySelector(".arr");
|
||||
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.yesterdaySortDirection === "asc" ? "▲" : "▼") : "↕";
|
||||
});
|
||||
}
|
||||
|
||||
function renderPerformance(rows) {
|
||||
rows = normalizePerformanceRows(rows);
|
||||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||||
setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
|
||||
document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
|
||||
<article class="performance-stage-card" title="收红 ${formatNumber(row.positive_rate, 1)}% · 平均涨幅 ${signed(row.average_change)}%"
|
||||
aria-label="${escapeHtml(row.label)},晋级率 ${formatNumber(row.advance_rate, 1)}%,晋级 ${number(row.advanced)} 只,共 ${number(row.count)} 只,收红率 ${formatNumber(row.positive_rate, 1)}%,平均涨幅 ${signed(row.average_change)}%">
|
||||
<div class="performance-stage-label"><span>${escapeHtml(row.label)} → 今日</span><i class="performance-status-tag ${performanceRateState(row.advance_rate).className}">${performanceRateState(row.advance_rate).label}</i></div>
|
||||
<strong class="performance-stage-rate ${performanceRateState(row.advance_rate).className}">${formatNumber(row.advance_rate, 1)}%</strong>
|
||||
<span class="performance-stage-count">晋级 ${number(row.advanced)} / 共 ${number(row.count)} 只</span>
|
||||
<div class="performance-stage-track" aria-hidden="true"><i class="${performanceRateState(row.advance_rate).className}" style="width:${Math.max(number(row.advance_rate), number(row.advance_rate) > 0 ? 2 : 0)}%"></i></div>
|
||||
</article>
|
||||
`).join("") || '<div class="performance-empty-state">暂无昨日涨停统计</div>';
|
||||
renderPerformanceConclusion(rows);
|
||||
renderMarketBreadth(state.dashboard?.overview || {});
|
||||
}
|
||||
|
||||
function normalizePerformanceRows(rows) {
|
||||
const groups = new Map();
|
||||
(rows || []).forEach((row) => {
|
||||
const level = Math.max(1, number(row.level));
|
||||
const displayLevel = Math.min(level, 5);
|
||||
const group = groups.get(displayLevel) || {
|
||||
level: displayLevel,
|
||||
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : `昨日${displayLevel}板`,
|
||||
count: 0,
|
||||
advanced: 0,
|
||||
positive: 0,
|
||||
changeTotal: 0,
|
||||
};
|
||||
const count = number(row.count);
|
||||
group.count += count;
|
||||
group.advanced += number(row.advanced);
|
||||
group.positive += count * number(row.positive_rate) / 100;
|
||||
group.changeTotal += count * number(row.average_change);
|
||||
groups.set(displayLevel, group);
|
||||
});
|
||||
return [...groups.values()]
|
||||
.sort((left, right) => right.level - left.level)
|
||||
.map((group) => ({
|
||||
level: group.level,
|
||||
label: group.label,
|
||||
count: group.count,
|
||||
advanced: group.advanced,
|
||||
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
|
||||
positive_rate: group.count ? group.positive / group.count * 100 : 0,
|
||||
average_change: group.count ? group.changeTotal / group.count : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function performanceRateState(rate) {
|
||||
const value = number(rate);
|
||||
if (value === 0) return { label: "失效", className: "is-neutral" };
|
||||
if (value < 20) return { label: "危险", className: "is-warning" };
|
||||
return { label: "活跃", className: "is-active" };
|
||||
}
|
||||
|
||||
function renderPerformanceConclusion(rows) {
|
||||
const container = document.querySelector("#performanceConclusion");
|
||||
if (!rows.length) {
|
||||
container.innerHTML = '<div class="empty-state">暂无昨日梯队数据,暂不生成结论</div>';
|
||||
return;
|
||||
}
|
||||
const sorted = [...rows].sort((left, right) => number(right.level) - number(left.level));
|
||||
const highRows = sorted.filter((row) => number(row.level) >= 4);
|
||||
const highAdvanced = highRows.reduce((total, row) => total + number(row.advanced), 0);
|
||||
const highSamples = highRows.map((row) => escapeHtml(row.label)).join("、");
|
||||
const strongest = [...rows].sort((left, right) => (
|
||||
number(right.advance_rate) - number(left.advance_rate) || number(right.level) - number(left.level)
|
||||
))[0];
|
||||
const firstBoard = rows.find((row) => number(row.level) === 1);
|
||||
const overview = state.dashboard?.overview || {};
|
||||
const phase = overview.sentiment_phase || "观察";
|
||||
const up = number(overview.up_count);
|
||||
const down = number(overview.down_count);
|
||||
const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50;
|
||||
const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队";
|
||||
const highText = highRows.length
|
||||
? `高位晋级率<b class="${highAdvanced ? "up" : "is-neutral"}">${highAdvanced ? "仍有承接" : "全线失效"}</b>:${highSamples}${highAdvanced ? `共晋级 ${highAdvanced} 只` : "今日均未晋级"};`
|
||||
: "高位梯队暂无昨日样本,空间信号仍待确认;";
|
||||
const strongestText = strongest
|
||||
? `<b>${escapeHtml(strongest.label)}</b>晋级率最高,为 <b class="up">${formatNumber(strongest.advance_rate, 1)}%</b>(${number(strongest.advanced)} 只晋级 / 共 ${number(strongest.count)} 只);`
|
||||
: "暂无相对占优梯队;";
|
||||
const firstBoardText = firstBoard
|
||||
? `首板基数 ${number(firstBoard.count)} 只,晋级率 <b class="${performanceRateState(firstBoard.advance_rate).className}">${formatNumber(firstBoard.advance_rate, 1)}%</b>,低位接力${number(firstBoard.advance_rate) < 20 ? "胜率偏低" : "仍有活跃度"};`
|
||||
: "首板梯队暂无有效样本;";
|
||||
container.innerHTML = `
|
||||
<div>· ${highText}</div>
|
||||
<div>· ${strongestText}</div>
|
||||
<div>· ${firstBoardText}</div>
|
||||
<div>· 结论:<b>${stance}</b>,当前情绪周期「${escapeHtml(phase)}」。</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:1517-1915 */
|
||||
@@ -0,0 +1,82 @@
|
||||
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
|
||||
enter: ["loadPopularity"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2584-2659 */
|
||||
async function loadPopularity(force = false) {
|
||||
if (state.popularityLoading) return;
|
||||
state.popularityLoading = true;
|
||||
const button = document.querySelector("#popularityRefreshButton");
|
||||
button.disabled = true;
|
||||
setText("popularityDateLabel", "正在读取人气榜");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
if (force) query.set("force", "1");
|
||||
state.popularityData = await apiRequest(`/api/popularity?${query}`);
|
||||
renderPopularity();
|
||||
} catch (error) {
|
||||
setText("popularityDateLabel", error.message || "人气榜暂不可用");
|
||||
document.querySelector("#popularityTableBody").innerHTML = "";
|
||||
document.querySelector("#popularityEmpty").hidden = false;
|
||||
showToast(error.message || "人气榜加载失败");
|
||||
} finally {
|
||||
state.popularityLoading = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPopularity() {
|
||||
const payload = state.popularityData;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
setText("popularityDateLabel", `${payload.meta?.carried_forward ? "最近有效榜单" : "榜单日期"} ${payload.meta?.trade_date || "--"}`);
|
||||
const topNames = (rows) => (rows || []).slice(0, 3).map((item) => item.name).filter(Boolean).join(" · ") || "--";
|
||||
document.querySelector("#popularitySummary").innerHTML = [
|
||||
["同花顺热度 Top3", topNames(payload.ths), `共 ${number(summary.ths_count)} 只上榜`],
|
||||
["东方财富热度 Top3", topNames(payload.dc), `共 ${number(summary.dc_count)} 只上榜`],
|
||||
["双榜共识", `${number(summary.dual_count)} 只`, "同时进入两榜,共识度更高"],
|
||||
].map(([label, value, detail], index) => `<article class="${index === 2 ? "consensus" : ""}"><span>${label}</span><strong>${escapeHtml(value)}</strong><small>${escapeHtml(detail)}</small></article>`).join("");
|
||||
renderPopularityTable();
|
||||
}
|
||||
|
||||
function renderPopularityTable() {
|
||||
const source = state.popularitySource;
|
||||
let rows = [...(state.popularityData?.[source] || [])];
|
||||
if (state.popularityQuery) {
|
||||
rows = rows.filter((item) => `${item.code} ${item.name} ${(item.concepts || []).join(" ")}`.toLocaleLowerCase("zh-CN").includes(state.popularityQuery));
|
||||
}
|
||||
const combined = source === "combined";
|
||||
const sourceName = source === "ths" ? "同花顺" : source === "dc" ? "东方财富" : "双榜综合";
|
||||
setText("popularityTableTitle", `${sourceName}榜`);
|
||||
setText("popularityTableNote", combined ? "按双榜排名综合排序 · 已隐藏重复的榜单状态" : "按榜单名次排序 · 状态显示是否同时进入另一榜");
|
||||
const headers = [
|
||||
["排名", "number num"], ["股票", ""], ["最新价(元)", "number num"], ["涨跌幅(%)", "number num"],
|
||||
...(source !== "dc" ? [["同花顺", "number num"]] : []),
|
||||
...(source !== "ths" ? [["东方财富", "number num"]] : []),
|
||||
["排名变化", "number num"], ["热门概念", ""], ...(!combined ? [["榜单状态", ""]] : []),
|
||||
];
|
||||
document.querySelector("#popularityTableHead").innerHTML = headers.map(([label, className]) => `<th scope="col" class="${className}">${label}</th>`).join("");
|
||||
const body = document.querySelector("#popularityTableBody");
|
||||
body.innerHTML = rows.map((row, index) => {
|
||||
const thsRank = source === "ths" ? row.rank : row.ths_rank;
|
||||
const dcRank = source === "dc" ? row.rank : row.dc_rank;
|
||||
const move = row.rank_change;
|
||||
const movement = move === null || move === undefined ? "新" : number(move) > 0 ? `↑${number(move)}` : number(move) < 0 ? `↓${Math.abs(number(move))}` : "持平";
|
||||
return `<tr data-code="${escapeHtml(row.code)}">
|
||||
<td class="number num popularity-rank-v2"><b>${index + 1}</b>${index < 3 ? '<span>热</span>' : ""}</td>
|
||||
<td><div class="popularity-stock-v2"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><span class="stock-code scode">${escapeHtml(row.code)}</span></div></td>
|
||||
<td class="number num">${row.price == null ? "" : formatNumber(row.price, 2)}</td>
|
||||
<td class="number num ${row.change == null ? "" : changeClass(row.change)}">${row.change == null ? "" : signed(row.change)}</td>
|
||||
${source !== "dc" ? `<td class="number num popularity-list-rank-v2">${thsRank ? number(thsRank) : ""}</td>` : ""}
|
||||
${source !== "ths" ? `<td class="number num popularity-list-rank-v2">${dcRank ? number(dcRank) : ""}</td>` : ""}
|
||||
<td class="number num popularity-movement-v2 ${number(move) > 0 ? "up" : number(move) < 0 ? "down" : ""}">${movement}</td>
|
||||
<td class="popularity-concepts-v2" title="${escapeHtml((row.concepts || []).join("、"))}">${escapeHtml((row.concepts || []).slice(0, 3).join("、"))}</td>
|
||||
${!combined ? `<td><span class="popularity-source-tag-v2 ${row.dual_source ? "dual" : ""}">${row.dual_source ? "双榜共识" : "单榜入选"}</span></td>` : ""}
|
||||
</tr>`;
|
||||
}).join("");
|
||||
bindStockRows(body);
|
||||
markAutoSortableHeaders(body.closest("table"));
|
||||
document.querySelector("#popularityEmpty").hidden = rows.length > 0;
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2584-2659 */
|
||||
@@ -0,0 +1,699 @@
|
||||
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
|
||||
enter: ["loadReview"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:3029-3470 */
|
||||
async function loadReviewWorkspace() {
|
||||
try {
|
||||
const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([
|
||||
apiRequest(`/api/watchlist?trade_date=${encodeURIComponent(elements.tradeDate.value)}`),
|
||||
apiRequest("/api/notes?scope=daily"),
|
||||
apiRequest("/api/trades"),
|
||||
]);
|
||||
state.watchlist = watchlistPayload.items || [];
|
||||
state.notes = notesPayload.items || [];
|
||||
state.tradeEntries = tradesPayload.items || [];
|
||||
state.tradeSummary = tradesPayload.summary || {};
|
||||
setText("reviewDataDate", displayCompactDate(elements.tradeDate.value));
|
||||
renderWatchlist();
|
||||
renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false);
|
||||
setText("notesCount", `${state.notes.length} 条`);
|
||||
renderTradeLog();
|
||||
populateJournalForm();
|
||||
} catch (error) {
|
||||
showToast(error.message || "我的复盘加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderWatchlist() {
|
||||
setText("watchlistCount", `${state.watchlist.length} 只`);
|
||||
const body = document.querySelector("#watchlistTableBody");
|
||||
body.innerHTML = state.watchlist.map((item) => `
|
||||
<tr data-code="${escapeHtml(item.code)}"><td><span class="review-watch-mark ${escapeHtml(item.color)}" title="${escapeHtml(item.color)}">★</span></td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
|
||||
<td>${escapeHtml(item.sector || "其他")}</td>
|
||||
<td class="number num ${item.change == null ? "" : changeClass(item.change)}">${formatWatchMetric(item.change)}</td>
|
||||
<td class="number num ${item.return_5d == null ? "" : changeClass(item.return_5d)}">${formatWatchMetric(item.return_5d)}</td>
|
||||
<td class="number num"><strong class="watch-attention-score">${item.attention_score == null ? "" : formatNumber(item.attention_score, 1)}</strong></td>
|
||||
<td><span class="watch-remark" title="${escapeHtml(item.remark || "尚未填写跟踪备注")}">${escapeHtml(item.remark || "尚未填写")}</span></td>
|
||||
<td><span class="review-row-actions"><button class="table-action" type="button" data-watch-remark="${escapeHtml(item.code)}">备注</button>
|
||||
<button class="table-action down" type="button" data-watch-delete="${escapeHtml(item.code)}" aria-label="移除 ${escapeHtml(item.name)}">移除</button></span></td></tr>
|
||||
`).join("");
|
||||
document.querySelector("#watchlistEmpty").hidden = state.watchlist.length > 0;
|
||||
body.querySelectorAll("[data-watch-remark]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const item = state.watchlist.find((row) => row.code === button.dataset.watchRemark);
|
||||
openWatchlistDialog(item);
|
||||
});
|
||||
});
|
||||
body.querySelectorAll("[data-watch-delete]").forEach((button) => {
|
||||
button.addEventListener("click", () => removeWatchlist(button.dataset.watchDelete));
|
||||
});
|
||||
bindStockRows(body);
|
||||
}
|
||||
|
||||
function formatWatchMetric(value) {
|
||||
if (value == null || !Number.isFinite(Number(value))) return "";
|
||||
return signed(value);
|
||||
}
|
||||
|
||||
function openWatchlistDialog(item = null) {
|
||||
clearTimeout(watchlistSearchTimer);
|
||||
state.watchlistSelection = item ? {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
sector: item.sector || "其他",
|
||||
color: item.color || "red",
|
||||
} : null;
|
||||
state.watchlistSearchResults = [];
|
||||
setText("watchlistDialogTitle", item ? "编辑跟踪备注" : "添加自选");
|
||||
document.querySelector("#watchlistRemark").value = item?.remark || "";
|
||||
document.querySelector("#watchlistSearchInput").value = "";
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = "";
|
||||
syncWatchlistSelection(Boolean(item));
|
||||
openModalDialog(elements.watchlistDialog);
|
||||
requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus());
|
||||
}
|
||||
|
||||
function closeWatchlistDialog() {
|
||||
clearTimeout(watchlistSearchTimer);
|
||||
if (elements.watchlistDialog.open) elements.watchlistDialog.close();
|
||||
}
|
||||
|
||||
function clearWatchlistSelection() {
|
||||
state.watchlistSelection = null;
|
||||
syncWatchlistSelection(false);
|
||||
document.querySelector("#watchlistSearchInput").focus();
|
||||
}
|
||||
|
||||
function syncWatchlistSelection(editing = false) {
|
||||
const item = state.watchlistSelection;
|
||||
document.querySelector("#watchlistSearchField").hidden = Boolean(item);
|
||||
document.querySelector("#watchlistSelection").hidden = !item;
|
||||
document.querySelector("#changeWatchlistSelection").hidden = editing;
|
||||
document.querySelector("#saveWatchlist").disabled = !item;
|
||||
if (!item) return;
|
||||
setText("watchlistSelectionName", item.name || "--");
|
||||
setText("watchlistSelectionCode", item.code || "--");
|
||||
setText("watchlistSelectionSector", item.sector || "其他");
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function scheduleWatchlistSearch() {
|
||||
clearTimeout(watchlistSearchTimer);
|
||||
const query = document.querySelector("#watchlistSearchInput").value.trim();
|
||||
if (!query) {
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = "";
|
||||
return;
|
||||
}
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = '<div class="watchlist-search-status">正在查找股票</div>';
|
||||
watchlistSearchTimer = setTimeout(() => runWatchlistSearch(query), 160);
|
||||
}
|
||||
|
||||
async function runWatchlistSearch(query) {
|
||||
const sequence = ++state.watchlistSearchRequestSequence;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value });
|
||||
const payload = await apiRequest(`/api/search?${params}`);
|
||||
if (sequence !== state.watchlistSearchRequestSequence) return;
|
||||
state.watchlistSearchResults = payload.groups?.stocks || [];
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = state.watchlistSearchResults.map((item, index) => `
|
||||
<button type="button" data-watchlist-result="${index}"><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.industry || "其他")}</small></span><b>${escapeHtml(item.code)}</b></button>
|
||||
`).join("") || '<div class="watchlist-search-status">没有找到匹配的股票</div>';
|
||||
} catch (error) {
|
||||
document.querySelector("#watchlistSearchResults").innerHTML = `<div class="watchlist-search-status">${escapeHtml(error.message || "搜索失败")}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWatchlistSearchResult(event) {
|
||||
const button = event.target.closest("[data-watchlist-result]");
|
||||
if (!button) return;
|
||||
const item = state.watchlistSearchResults[number(button.dataset.watchlistResult)];
|
||||
if (!item) return;
|
||||
state.watchlistSelection = {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
sector: item.industry || "其他",
|
||||
color: "red",
|
||||
};
|
||||
syncWatchlistSelection(false);
|
||||
}
|
||||
|
||||
async function saveWatchlistFromDialog(event) {
|
||||
event.preventDefault();
|
||||
const item = state.watchlistSelection;
|
||||
if (!item) return;
|
||||
const button = document.querySelector("#saveWatchlist");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await apiRequest("/api/watchlist", "POST", {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
sector: item.sector || "其他",
|
||||
color: item.color || "red",
|
||||
remark: document.querySelector("#watchlistRemark").value.trim(),
|
||||
});
|
||||
closeWatchlistDialog();
|
||||
await loadReviewWorkspace();
|
||||
showToast(state.watchlist.some((row) => row.code === item.code) ? "自选跟踪已保存" : "已加入自选");
|
||||
} catch (error) {
|
||||
showToast(error.message || "自选保存失败");
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActiveWatchlist() {
|
||||
const stock = state.activeStock;
|
||||
if (!stock?.code) return;
|
||||
const isWatched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === stock.code));
|
||||
try {
|
||||
if (isWatched) {
|
||||
await apiRequest(`/api/watchlist/${stock.code}`, "DELETE");
|
||||
state.watchlist = state.watchlist.filter((item) => item.code !== stock.code);
|
||||
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = null;
|
||||
showToast("已移出自选");
|
||||
} else {
|
||||
const payload = await apiRequest("/api/watchlist", "POST", {
|
||||
code: stock.code,
|
||||
name: stock.name || "--",
|
||||
sector: stock.sector || "其他",
|
||||
color: "red",
|
||||
});
|
||||
state.watchlist = payload.items || state.watchlist;
|
||||
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = state.watchlist.find((item) => item.code === stock.code);
|
||||
showToast("已加入自选");
|
||||
}
|
||||
updateWatchButton();
|
||||
renderWatchlist();
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateWatchButton() {
|
||||
const code = state.activeStock?.code;
|
||||
const watched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === code));
|
||||
setText("watchStockButton", watched ? "移出自选" : "加入自选");
|
||||
}
|
||||
|
||||
async function removeWatchlist(code) {
|
||||
try {
|
||||
await apiRequest(`/api/watchlist/${code}`, "DELETE");
|
||||
state.watchlist = state.watchlist.filter((item) => item.code !== code);
|
||||
renderWatchlist();
|
||||
showToast("已移出自选");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveJournal(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await apiRequest("/api/notes", "POST", {
|
||||
trade_date: document.querySelector("#journalDate").value,
|
||||
id: state.editingDailyNoteId || undefined,
|
||||
summary: document.querySelector("#journalSummary").value,
|
||||
content: document.querySelector("#journalContent").value,
|
||||
plan: document.querySelector("#journalPlan").value,
|
||||
});
|
||||
await loadReviewWorkspace();
|
||||
showToast("每日复盘已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function populateJournalForm() {
|
||||
const selectedDate = document.querySelector("#journalDate").value.replaceAll("-", "");
|
||||
const note = state.notes.find((item) => String(item.trade_date).replaceAll("-", "") === selectedDate);
|
||||
state.editingDailyNoteId = number(note?.id);
|
||||
document.querySelector("#journalSummary").value = note?.summary || "";
|
||||
document.querySelector("#journalContent").value = note?.content || "";
|
||||
document.querySelector("#journalPlan").value = note?.plan || "";
|
||||
}
|
||||
|
||||
function openTradeLogDialog() {
|
||||
resetTradeLogForm();
|
||||
openModalDialog(elements.tradeLogDialog);
|
||||
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
|
||||
}
|
||||
|
||||
function closeTradeLogDialog() {
|
||||
if (elements.tradeLogDialog.open) elements.tradeLogDialog.close();
|
||||
else resetTradeLogForm();
|
||||
}
|
||||
|
||||
async function saveTradeLog(event) {
|
||||
event.preventDefault();
|
||||
const button = document.querySelector("#saveTradeLog");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const payload = await apiRequest("/api/trades", "POST", {
|
||||
id: state.editingTradeId || undefined,
|
||||
trade_date: document.querySelector("#tradeLogDate").value,
|
||||
code: document.querySelector("#tradeLogCode").value.trim(),
|
||||
name: document.querySelector("#tradeLogName").value.trim(),
|
||||
action: document.querySelector("#tradeLogAction").value,
|
||||
price: document.querySelector("#tradeLogPrice").value,
|
||||
quantity: document.querySelector("#tradeLogQuantity").value,
|
||||
position_pct: document.querySelector("#tradeLogPosition").value,
|
||||
pnl_amount: document.querySelector("#tradeLogPnlAmount").value,
|
||||
pnl_pct: document.querySelector("#tradeLogPnlPct").value,
|
||||
emotion: document.querySelector("#tradeLogEmotion").value,
|
||||
tags: document.querySelector("#tradeLogTags").value,
|
||||
thesis: document.querySelector("#tradeLogThesis").value,
|
||||
execution: document.querySelector("#tradeLogExecution").value,
|
||||
});
|
||||
state.tradeEntries = payload.items || [];
|
||||
state.tradeSummary = payload.summary || {};
|
||||
renderTradeLog();
|
||||
closeTradeLogDialog();
|
||||
showToast("交易记录已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message || "交易记录保存失败");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetTradeLogForm() {
|
||||
state.editingTradeId = 0;
|
||||
document.querySelector("#tradeLogForm").reset();
|
||||
document.querySelector("#tradeLogDate").value = elements.tradeDate.value || todayString();
|
||||
document.querySelector("#tradeLogQuantity").value = "0";
|
||||
document.querySelector("#tradeLogPosition").value = "0";
|
||||
setText("tradeLogDialogTitle", "交易日志");
|
||||
setText("saveTradeLog", "保存交易");
|
||||
}
|
||||
|
||||
function editTradeLog(id) {
|
||||
const item = state.tradeEntries.find((entry) => number(entry.id) === id);
|
||||
if (!item) return;
|
||||
state.editingTradeId = id;
|
||||
document.querySelector("#tradeLogDate").value = displayCompactDate(item.trade_date);
|
||||
document.querySelector("#tradeLogCode").value = item.code;
|
||||
document.querySelector("#tradeLogName").value = item.name;
|
||||
document.querySelector("#tradeLogAction").value = item.action;
|
||||
document.querySelector("#tradeLogPrice").value = item.price;
|
||||
document.querySelector("#tradeLogQuantity").value = item.quantity;
|
||||
document.querySelector("#tradeLogPosition").value = item.position_pct;
|
||||
document.querySelector("#tradeLogPnlAmount").value = item.pnl_amount ?? "";
|
||||
document.querySelector("#tradeLogPnlPct").value = item.pnl_pct ?? "";
|
||||
document.querySelector("#tradeLogEmotion").value = item.emotion;
|
||||
document.querySelector("#tradeLogTags").value = (item.tags || []).join(", ");
|
||||
document.querySelector("#tradeLogThesis").value = item.thesis || "";
|
||||
document.querySelector("#tradeLogExecution").value = item.execution || "";
|
||||
setText("tradeLogDialogTitle", "编辑交易日志");
|
||||
setText("saveTradeLog", "保存修改");
|
||||
openModalDialog(elements.tradeLogDialog);
|
||||
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
|
||||
}
|
||||
|
||||
async function handleTradeLogAction(event) {
|
||||
const button = event.target.closest("[data-trade-action]");
|
||||
if (!button) return;
|
||||
const id = number(button.dataset.tradeId);
|
||||
if (button.dataset.tradeAction === "edit") {
|
||||
editTradeLog(id);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("确定删除这条交易记录吗?")) return;
|
||||
try {
|
||||
const payload = await apiRequest(`/api/trades/${id}`, "DELETE");
|
||||
state.tradeEntries = payload.items || [];
|
||||
state.tradeSummary = payload.summary || {};
|
||||
if (state.editingTradeId === id) resetTradeLogForm();
|
||||
renderTradeLog();
|
||||
showToast("交易记录已删除");
|
||||
} catch (error) {
|
||||
showToast(error.message || "交易记录删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderTradeLog() {
|
||||
const summary = state.tradeSummary || {};
|
||||
setText("tradeLogCount", `${state.tradeEntries.length} 条`);
|
||||
document.querySelector("#tradeLogSummary").innerHTML = [
|
||||
["记录", `${number(summary.total)} 条`],
|
||||
["已实现", `${number(summary.realized)} 条`],
|
||||
["胜率", summary.win_rate == null ? "--" : `${formatNumber(summary.win_rate, 1)}%`],
|
||||
["累计盈亏", summary.pnl_amount == null ? "--" : `${number(summary.pnl_amount) > 0 ? "+" : ""}${formatNumber(summary.pnl_amount, 2)}`],
|
||||
["平均仓位", summary.average_position == null ? "--" : `${formatNumber(summary.average_position, 1)}%`],
|
||||
].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join("");
|
||||
document.querySelector("#tradeLogEmpty").hidden = state.tradeEntries.length > 0;
|
||||
document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => `
|
||||
<tr data-code="${escapeHtml(item.code)}">
|
||||
<td>${displayCompactDate(item.trade_date)}</td>
|
||||
<td><span class="stock-cell"><strong class="sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
|
||||
<td><span class="trade-action trade-action-${escapeHtml(item.action)}">${escapeHtml(item.action_label)}</span></td>
|
||||
<td class="number num">${item.position_pct == null ? "" : formatNumber(item.position_pct, 1)}</td>
|
||||
<td class="number num ${item.pnl_pct == null ? "" : changeClass(item.pnl_pct)}">${item.pnl_pct == null ? "" : signed(item.pnl_pct)}</td>
|
||||
<td class="number num ${item.pnl_amount == null ? "" : changeClass(item.pnl_amount)}">${item.pnl_amount == null ? "" : signed(item.pnl_amount)}</td>
|
||||
<td><span class="trade-emotion">${escapeHtml(item.emotion_label)}</span><div class="trade-tags">${(item.tags || []).map((tag) => `<em>${escapeHtml(tag)}</em>`).join("")}</div></td>
|
||||
<td class="trade-copy" title="交易逻辑:${escapeHtml(item.thesis || "")};执行复核:${escapeHtml(item.execution || "")}"><strong>${escapeHtml(item.thesis || "")}</strong><small>${escapeHtml(item.execution || "尚未填写执行复核")}</small></td>
|
||||
<td><div class="trade-row-actions"><button class="table-action" type="button" data-trade-action="edit" data-trade-id="${number(item.id)}">编辑</button><button class="table-action down" type="button" data-trade-action="delete" data-trade-id="${number(item.id)}">删除</button></div></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindStockRows(document.querySelector("#tradeLogTableBody"));
|
||||
}
|
||||
|
||||
async function saveStockNote(event) {
|
||||
event.preventDefault();
|
||||
if (!state.activeStock?.code) return;
|
||||
try {
|
||||
await apiRequest("/api/notes", "POST", {
|
||||
code: state.activeStock.code,
|
||||
stock_name: state.activeStock.name || "--",
|
||||
trade_date: elements.tradeDate.value,
|
||||
content: document.querySelector("#stockNoteContent").value,
|
||||
plan: document.querySelector("#stockNotePlan").value,
|
||||
});
|
||||
document.querySelector("#stockNoteContent").value = "";
|
||||
document.querySelector("#stockNotePlan").value = "";
|
||||
const payload = await apiRequest(`/api/notes?scope=stock&code=${encodeURIComponent(state.activeStock.code)}`);
|
||||
state.stockDetail.notes = payload.items || [];
|
||||
renderStockNotes(state.stockDetail.notes);
|
||||
showToast("个股笔记已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveReasonOverride(event) {
|
||||
event.preventDefault();
|
||||
if (!state.activeStock?.code) return;
|
||||
const reason = document.querySelector("#reasonInput").value.trim();
|
||||
try {
|
||||
await apiRequest("/api/reasons", "POST", {
|
||||
trade_date: elements.tradeDate.value,
|
||||
code: state.activeStock.code,
|
||||
reason,
|
||||
});
|
||||
state.activeStock.reason = reason;
|
||||
for (const key of ["limits", "broken", "down_limits"]) {
|
||||
const row = state.dashboard?.[key]?.find((item) => item.code === state.activeStock.code);
|
||||
if (row) row.reason = reason;
|
||||
}
|
||||
setText("detailReason", reason);
|
||||
renderDashboard();
|
||||
showToast("事件逻辑已修订");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMoneyflow(flow) {
|
||||
for (const [id, value] of [["flowNet", flow.net_million], ["flowLarge", flow.large_million], ["flowMedium", flow.medium_million], ["flowSmall", flow.small_million]]) {
|
||||
const element = document.getElementById(id);
|
||||
element.textContent = formatMoneyMillion(value);
|
||||
element.className = changeClass(value);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStockNotes(notes) {
|
||||
renderNotesHistory(notes, document.querySelector("#stockNotes"), true);
|
||||
}
|
||||
|
||||
function renderNotesHistory(notes, container, compact) {
|
||||
container.innerHTML = notes.map((note) => `
|
||||
<article class="note-row">
|
||||
<div><time>${displayCompactDate(note.trade_date)}</time>${note.stock_name ? `<small>${escapeHtml(note.stock_name)}</small>` : ""}</div>
|
||||
${!compact ? `<div class="note-block note-summary"><strong>盘面</strong><p>${escapeHtml(note.summary || "--")}</p></div>` : ""}
|
||||
<div class="note-block"><strong>复盘</strong><p>${escapeHtml(note.content || "--")}</p></div>
|
||||
<div class="note-block"><strong>计划</strong><p>${escapeHtml(note.plan || "--")}</p></div>
|
||||
<button class="table-action down" type="button" data-note-delete="${number(note.id)}">删除</button>
|
||||
</article>
|
||||
`).join("") || emptyStateHtml("暂无复盘记录");
|
||||
container.querySelectorAll("[data-note-delete]").forEach((button) => {
|
||||
button.addEventListener("click", () => deleteNote(number(button.dataset.noteDelete), compact));
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteNote(noteId, compact) {
|
||||
try {
|
||||
await apiRequest(`/api/notes/${noteId}`, "DELETE");
|
||||
if (compact && state.activeStock) {
|
||||
state.stockDetail.notes = state.stockDetail.notes.filter((note) => number(note.id) !== noteId);
|
||||
renderStockNotes(state.stockDetail.notes);
|
||||
} else {
|
||||
await loadReviewWorkspace();
|
||||
}
|
||||
showToast("笔记已删除");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:3029-3470 */
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:7720-7968 */
|
||||
async function loadAlerts(openDialog = false) {
|
||||
try {
|
||||
const query = new URLSearchParams({ status: state.alertFilter, as_of: todayString() });
|
||||
const payload = await apiRequest(`/api/alerts?${query}`);
|
||||
state.alerts = payload.items || [];
|
||||
state.alertUnreadCount = number(payload.unread_count);
|
||||
renderAlerts();
|
||||
if (openDialog) openModalDialog(elements.alertsDialog);
|
||||
} catch (error) {
|
||||
if (openDialog) showToast(error.message || "提醒加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function openAlerts() {
|
||||
toggleHeaderCommandMenu(false);
|
||||
toggleAccountDropdown(false);
|
||||
document.querySelector("#alertDate").value ||= todayString();
|
||||
openModalDialog(elements.alertsDialog);
|
||||
loadAlerts();
|
||||
}
|
||||
|
||||
function openStockReminder() {
|
||||
const stock = state.activeStock || {};
|
||||
document.querySelector("#alertTitle").value = `${stock.name || stock.code || "个股"}观察提醒`;
|
||||
document.querySelector("#alertCode").value = stock.code || "";
|
||||
document.querySelector("#alertDate").value = todayString();
|
||||
if (elements.stockDialog.open) elements.stockDialog.close();
|
||||
openAlerts();
|
||||
document.querySelector("#alertContent").focus();
|
||||
}
|
||||
|
||||
function selectAlertFilter(filter) {
|
||||
state.alertFilter = filter === "unread" ? "unread" : "all";
|
||||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
|
||||
});
|
||||
loadAlerts();
|
||||
}
|
||||
|
||||
async function saveAlert(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget.querySelector("button[type='submit']");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const payload = await apiRequest("/api/alerts", "POST", {
|
||||
title: document.querySelector("#alertTitle").value.trim(),
|
||||
remind_date: document.querySelector("#alertDate").value,
|
||||
code: document.querySelector("#alertCode").value.trim(),
|
||||
content: document.querySelector("#alertContent").value.trim(),
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
document.querySelector("#alertDate").value = todayString();
|
||||
state.alertFilter = "all";
|
||||
state.alerts = payload.items || [];
|
||||
state.alertUnreadCount = number(payload.unread_count);
|
||||
renderAlerts();
|
||||
showToast("提醒已保存");
|
||||
} catch (error) {
|
||||
showToast(error.message || "提醒保存失败");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAlertsRead() {
|
||||
try {
|
||||
await apiRequest("/api/alerts/read-all", "POST", { as_of: todayString() });
|
||||
await loadAlerts();
|
||||
} catch (error) {
|
||||
showToast(error.message || "提醒状态更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAlertAction(event) {
|
||||
const button = event.target.closest("[data-alert-action]");
|
||||
if (!button) return;
|
||||
const id = number(button.dataset.alertId);
|
||||
if (!id) return;
|
||||
try {
|
||||
if (button.dataset.alertAction === "delete") {
|
||||
await apiRequest(`/api/alerts/${id}`, "DELETE");
|
||||
} else {
|
||||
await apiRequest(`/api/alerts/${id}/read`, "POST", {});
|
||||
}
|
||||
await loadAlerts();
|
||||
} catch (error) {
|
||||
showToast(error.message || "提醒操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderAlerts() {
|
||||
const badge = document.querySelector("#alertBadge");
|
||||
badge.hidden = state.alertUnreadCount <= 0;
|
||||
badge.textContent = state.alertUnreadCount > 99 ? "99+" : String(state.alertUnreadCount);
|
||||
document.querySelector("#alertButton").classList.toggle("has-alerts", state.alertUnreadCount > 0);
|
||||
setText("alertListCount", `${state.alerts.length} 条`);
|
||||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
|
||||
});
|
||||
document.querySelector("#markAllAlertsRead").disabled = state.alertUnreadCount <= 0;
|
||||
const container = document.querySelector("#alertList");
|
||||
container.innerHTML = state.alerts.map((item) => {
|
||||
const upcoming = !item.due;
|
||||
const kindLabel = item.kind === "manual" ? "自定提醒" : item.kind === "strategy_t5" ? "跟踪完成" : "策略反馈";
|
||||
return `<article class="alert-item ${item.is_read ? "is-read" : "is-unread"} ${upcoming ? "is-upcoming" : ""}">
|
||||
<div class="alert-item-icon"><i data-lucide="${upcoming ? "calendar-clock" : item.kind === "manual" ? "bell" : "chart-no-axes-combined"}"></i></div>
|
||||
<div class="alert-item-copy">
|
||||
<div><span>${escapeHtml(kindLabel)}</span><time>${displayCompactDate(item.available_date)}</time></div>
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
${item.content ? `<p>${escapeHtml(item.content)}</p>` : ""}
|
||||
${item.code ? `<button class="stock-preview-trigger alert-stock-link" type="button" data-code="${escapeHtml(item.code)}">${escapeHtml(item.code)}</button>` : ""}
|
||||
</div>
|
||||
<div class="alert-item-actions">
|
||||
${!item.is_read && !upcoming ? `<button class="icon-button" type="button" data-alert-action="read" data-alert-id="${number(item.id)}" title="标为已读" aria-label="标为已读"><i data-lucide="check"></i></button>` : ""}
|
||||
<button class="icon-button" type="button" data-alert-action="delete" data-alert-id="${number(item.id)}" title="删除提醒" aria-label="删除提醒"><i data-lucide="trash-2"></i></button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join("") || emptyStateHtml("暂无提醒");
|
||||
bindStockRows(container);
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
async function openReviewAssistant() {
|
||||
toggleHeaderCommandMenu(false);
|
||||
toggleAccountDropdown(false);
|
||||
openModalDialog(elements.assistantDialog);
|
||||
updateAssistantControls();
|
||||
if (!hasMemberAccess()) {
|
||||
document.querySelector("#closeAssistantDialog").focus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await apiRequest("/api/assistant/messages");
|
||||
state.assistantMessages = payload.items || [];
|
||||
renderAssistantMessages();
|
||||
} catch (error) {
|
||||
showToast(error.message || "对话记录加载失败");
|
||||
}
|
||||
document.querySelector("#assistantQuestion").focus();
|
||||
}
|
||||
|
||||
function useAssistantPrompt(prompt) {
|
||||
const input = document.querySelector("#assistantQuestion");
|
||||
input.value = prompt;
|
||||
input.focus();
|
||||
}
|
||||
|
||||
async function sendAssistantQuestion(event) {
|
||||
event.preventDefault();
|
||||
if (state.assistantLoading) return;
|
||||
const input = document.querySelector("#assistantQuestion");
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
input.value = "";
|
||||
state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||||
state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||||
state.assistantLoading = true;
|
||||
state.assistantController = new AbortController();
|
||||
updateAssistantControls();
|
||||
renderAssistantMessages();
|
||||
try {
|
||||
await streamAssistantRequest(question, state.assistantController.signal, (chunk) => {
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message?.role === "assistant") message.content += chunk;
|
||||
scheduleAssistantRender();
|
||||
});
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message) message.streaming = false;
|
||||
setStatus("复盘助手回答完成");
|
||||
} catch (error) {
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message?.role === "assistant") {
|
||||
message.streaming = false;
|
||||
message.error = true;
|
||||
if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。";
|
||||
}
|
||||
if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败");
|
||||
} finally {
|
||||
state.assistantLoading = false;
|
||||
state.assistantController = null;
|
||||
updateAssistantControls();
|
||||
renderAssistantMessages();
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function streamAssistantRequest(question, signal, onDelta) {
|
||||
await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", {
|
||||
method: "POST",
|
||||
body: { question, trade_date: elements.tradeDate.value },
|
||||
signal,
|
||||
errorMessage: "复盘助手暂不可用",
|
||||
onEvent: (event) => {
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function stopAssistantResponse() {
|
||||
state.assistantController?.abort();
|
||||
}
|
||||
|
||||
async function clearAssistantConversation() {
|
||||
if (state.assistantLoading || !state.assistantMessages.length) return;
|
||||
if (!window.confirm("确定清空复盘助手的对话记录吗?")) return;
|
||||
try {
|
||||
await apiRequest("/api/assistant/messages", "DELETE");
|
||||
state.assistantMessages = [];
|
||||
renderAssistantMessages();
|
||||
} catch (error) {
|
||||
showToast(error.message || "对话记录清空失败");
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAssistantRender() {
|
||||
if (assistantRenderFrame) return;
|
||||
assistantRenderFrame = requestAnimationFrame(() => {
|
||||
assistantRenderFrame = 0;
|
||||
renderAssistantMessages();
|
||||
});
|
||||
}
|
||||
|
||||
function renderAssistantMessages() {
|
||||
const container = document.querySelector("#assistantMessages");
|
||||
container.innerHTML = state.assistantMessages.map((message) => `
|
||||
<article class="assistant-message ${message.role} ${message.error ? "is-error" : ""}">
|
||||
<div class="assistant-message-label">${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `<time>${displayCompactDate(message.context_date)}</time>` : ""}</div>
|
||||
<div class="assistant-message-content">${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '<span class="assistant-thinking">正在整理复盘数据</span>') : escapeHtml(message.content)}</div>
|
||||
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
||||
</article>
|
||||
`).join("") || emptyStateHtml("可以从市场、策略或自己的交易记录开始复盘");
|
||||
updateAssistantControls();
|
||||
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
||||
}
|
||||
|
||||
function updateAssistantControls() {
|
||||
const unlocked = hasMemberAccess();
|
||||
elements.assistantDialog.classList.toggle("member-locked", !unlocked);
|
||||
document.querySelector("#assistantMemberGate").hidden = unlocked;
|
||||
document.querySelector("#assistantMemberContent").setAttribute("aria-disabled", String(!unlocked));
|
||||
document.querySelector("#assistantQuestion").disabled = !unlocked || state.assistantLoading;
|
||||
document.querySelector("#sendAssistant").disabled = !unlocked || state.assistantLoading;
|
||||
document.querySelector("#stopAssistant").hidden = !unlocked || !state.assistantLoading;
|
||||
document.querySelector("#clearAssistantMessages").disabled = !unlocked || state.assistantLoading || !state.assistantMessages.length;
|
||||
document.querySelectorAll("[data-assistant-prompt]").forEach((button) => {
|
||||
button.disabled = !unlocked || state.assistantLoading;
|
||||
});
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:7720-7968 */
|
||||
@@ -0,0 +1,173 @@
|
||||
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
|
||||
enter: ["loadRotation"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:1958-2124 */
|
||||
async function loadRotationHistory(force = false) {
|
||||
if (!state.dashboard || state.rotationLoading) return;
|
||||
const key = `${elements.tradeDate.value}:9`;
|
||||
if (!force && state.rotationHistoryKey === key && state.rotationHistory) {
|
||||
renderRotationHistory();
|
||||
return;
|
||||
}
|
||||
state.rotationLoading = true;
|
||||
const container = document.querySelector("#rotationHistory");
|
||||
renderEmptyState(container, "正在读取轮动历史");
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
trade_date: elements.tradeDate.value,
|
||||
});
|
||||
state.rotationHistory = await apiRequest(`/api/rotation/history?${query}`);
|
||||
state.rotationHistoryKey = key;
|
||||
renderRotationHistory();
|
||||
} catch (error) {
|
||||
renderEmptyState(container, error.message || "轮动历史加载失败");
|
||||
showToast(error.message || "轮动历史加载失败");
|
||||
} finally {
|
||||
state.rotationLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRotationHistory() {
|
||||
const rows = state.rotationHistory?.rows || [];
|
||||
const selected = state.rotationSelectedSector;
|
||||
const container = document.querySelector("#rotationHistory");
|
||||
const tracker = document.querySelector("#rotationTracker");
|
||||
if (!rows.length) {
|
||||
renderEmptyState(container, "尚无连续交易日的板块数据");
|
||||
setText("rotationHistoryRange", "暂无轮动历史");
|
||||
tracker.hidden = true;
|
||||
return;
|
||||
}
|
||||
const chronological = [...rows]
|
||||
.sort((left, right) => String(left.trade_date).localeCompare(String(right.trade_date)))
|
||||
.slice(-9);
|
||||
const displayRows = state.rotationOrder === "latest" ? [...chronological].reverse() : chronological;
|
||||
document.querySelectorAll("[data-rotation-order]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.rotationOrder === state.rotationOrder);
|
||||
});
|
||||
setText(
|
||||
"rotationHistoryRange",
|
||||
`最近 ${chronological.length} 个交易日 · ${displayCompactDate(chronological[0].trade_date)} → ${displayCompactDate(chronological[chronological.length - 1].trade_date)} · ${state.rotationOrder === "latest" ? "由近到远,左侧为最新交易日" : "由远到近,右侧为最新交易日"}`,
|
||||
);
|
||||
setText("rotationSelectionHint", selected ? `已联动高亮 ${selected}` : "点击任意板块追踪其连续性");
|
||||
if (selected) {
|
||||
const sequence = displayRows.map((day) => {
|
||||
const sector = (day.sectors || []).find((item) => item.name === selected);
|
||||
return { tradeDate: day.trade_date, sector };
|
||||
});
|
||||
const appearances = sequence.filter((item) => item.sector);
|
||||
const bestRank = appearances.length ? Math.min(...appearances.map((item) => number(item.sector.rank))) : 0;
|
||||
tracker.hidden = false;
|
||||
const continuity = appearances.length >= 3 ? "主线候选" : appearances.length === 1 ? "单日异动,持续性待验证" : "间断活跃";
|
||||
tracker.innerHTML = `
|
||||
<div class="rotation-tracker-copy"><strong>${escapeHtml(selected)}</strong><span>近 9 日在榜 <b>${appearances.length}</b> 天 · 最高排名 <b>#${bestRank || "--"}</b> · ${continuity}</span></div>
|
||||
<div class="rotation-tracker-spark" aria-label="${escapeHtml(selected)}九日强度轨迹">
|
||||
${sequence.map((item) => item.sector
|
||||
? `<span style="--spark-height:${Math.max(18, clamp(number(item.sector.strength), 0, 100))}%" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 第 ${number(item.sector.rank)} 名 · 强度 ${formatNumber(item.sector.strength, 0)}"><i></i><small>#${number(item.sector.rank)}</small></span>`
|
||||
: `<span class="missing" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 未上榜"><i></i><small>--</small></span>`).join("")}
|
||||
</div>
|
||||
<button class="rotation-track-cancel" type="button">取消追踪</button>`;
|
||||
tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
|
||||
state.rotationSelectedSector = "";
|
||||
state.rotationSelectedDate = "";
|
||||
renderRotationHistory();
|
||||
loadRotationMembers("");
|
||||
});
|
||||
} else {
|
||||
tracker.hidden = true;
|
||||
tracker.innerHTML = "";
|
||||
}
|
||||
container.classList.toggle("tracking", Boolean(selected));
|
||||
const latestTradeDate = chronological[chronological.length - 1].trade_date;
|
||||
container.innerHTML = displayRows.map((day) => {
|
||||
const hasSelected = selected && (day.sectors || []).some((sector) => sector.name === selected);
|
||||
return `
|
||||
<article class="rotation-day ${selected ? "has-selection" : ""} ${hasSelected ? "selected-day" : ""} ${day.trade_date === latestTradeDate ? "latest-day" : ""}">
|
||||
<header><time>${escapeHtml(displayCompactDate(day.trade_date).slice(5))}</time><span>${(day.sectors || []).length} 个热点</span></header>
|
||||
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => {
|
||||
const strength = clamp(number(sector.strength), 0, 100);
|
||||
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
|
||||
return `
|
||||
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
|
||||
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · ${formatNumber(sector.strength, 0)}</small>
|
||||
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
|
||||
</button>`;
|
||||
}).join("")}</div>
|
||||
</article>`;
|
||||
}).join("");
|
||||
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const clickedSector = button.dataset.rotationSector;
|
||||
const clickedDate = button.dataset.rotationDate;
|
||||
const isSameSelection = clickedSector === state.rotationSelectedSector
|
||||
&& clickedDate === state.rotationSelectedDate;
|
||||
state.rotationSelectedSector = isSameSelection ? "" : clickedSector;
|
||||
state.rotationSelectedDate = isSameSelection ? "" : clickedDate;
|
||||
renderRotationHistory();
|
||||
loadRotationMembers(state.rotationSelectedSector);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRotationMembers(sector, force = false) {
|
||||
if (!sector) {
|
||||
state.rotationMembers = null;
|
||||
state.rotationMembersKey = "";
|
||||
renderRotationMembers();
|
||||
return;
|
||||
}
|
||||
const memberDate = state.rotationSelectedDate || elements.tradeDate.value;
|
||||
const key = `${memberDate}:${sector}`;
|
||||
if (!force && state.rotationMembersKey === key && state.rotationMembers) {
|
||||
renderRotationMembers();
|
||||
return;
|
||||
}
|
||||
state.rotationMembersLoading = true;
|
||||
renderRotationMembers();
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: memberDate, sector });
|
||||
state.rotationMembers = await apiRequest(`/api/rotation/members?${query}`);
|
||||
state.rotationMembersKey = key;
|
||||
} catch (error) {
|
||||
state.rotationMembers = { error: error.message || "成分股加载失败", rows: [] };
|
||||
state.rotationMembersKey = key;
|
||||
} finally {
|
||||
state.rotationMembersLoading = false;
|
||||
renderRotationMembers();
|
||||
}
|
||||
}
|
||||
|
||||
function renderRotationMembers() {
|
||||
const body = document.querySelector("#rotationTableBody");
|
||||
const empty = document.querySelector("#rotationMembersEmpty");
|
||||
if (state.rotationMembersLoading) {
|
||||
body.innerHTML = "";
|
||||
empty.textContent = `正在核验${state.rotationSelectedSector}成分股`;
|
||||
empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
const payload = state.rotationMembers;
|
||||
const rows = payload?.rows || [];
|
||||
if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) {
|
||||
body.innerHTML = "";
|
||||
empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股");
|
||||
empty.hidden = false;
|
||||
setText("rotationDetailTitle", "板块成分股");
|
||||
setText("rotationDetailMeta", state.rotationSelectedSector || "--");
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`);
|
||||
setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)} 只`);
|
||||
body.innerHTML = rows.map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}"><td class="number num muted">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
|
||||
<td class="number num ${row.quoted ? changeClass(row.change) : "muted"}" data-sort-value="${row.quoted ? number(row.change) : -999}">${row.quoted ? signed(row.change) : ""}</td>
|
||||
<td class="number num">${row.quoted ? formatNumber(row.open, 2) : ""}</td><td class="number num">${row.quoted ? formatNumber(row.close, 2) : ""}</td>
|
||||
<td class="number num" data-sort-value="${number(row.amount_billion)}">${row.quoted ? formatNumber(row.amount_billion, 2) : ""}</td><td>${row.quoted ? "正常交易" : "当日无行情"}</td></tr>
|
||||
`).join("");
|
||||
animateRows(body);
|
||||
bindStockRows(body);
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:1958-2124 */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
|
||||
enter: ["loadSentiment"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:1207-1516 */
|
||||
async function loadSentimentHistory(force = false) {
|
||||
if (!state.dashboard || state.sentimentLoading) return;
|
||||
const key = `${elements.tradeDate.value}:${state.sentimentRange}`;
|
||||
if (!force && state.sentimentHistoryKey === key && state.sentimentHistory) {
|
||||
renderSentimentHistory();
|
||||
return;
|
||||
}
|
||||
state.sentimentLoading = true;
|
||||
const notice = document.querySelector("#sentimentHistoryNotice");
|
||||
notice.hidden = true;
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
trade_date: elements.tradeDate.value,
|
||||
limit: String(state.sentimentRange),
|
||||
});
|
||||
state.sentimentHistory = await apiRequest(`/api/sentiment/history?${query}`);
|
||||
state.sentimentHistoryKey = key;
|
||||
renderSentimentHistory();
|
||||
} catch (error) {
|
||||
notice.textContent = error.message || "情绪周期数据加载失败";
|
||||
notice.hidden = false;
|
||||
showToast(notice.textContent);
|
||||
} finally {
|
||||
state.sentimentLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSentimentHistory() {
|
||||
const payload = state.sentimentHistory;
|
||||
if (!payload) return;
|
||||
const rows = payload.rows || [];
|
||||
const latest = rows[rows.length - 1];
|
||||
const body = document.querySelector("#sentimentHistoryBody");
|
||||
const empty = document.querySelector("#sentimentHistoryEmpty");
|
||||
empty.hidden = rows.length > 0;
|
||||
body.innerHTML = [...rows].reverse().map((row) => {
|
||||
return `
|
||||
<tr class="${row.trade_date === latest?.trade_date ? "latest-row" : ""}">
|
||||
<td class="sentiment-date-cell">${escapeHtml(displayCompactDate(row.trade_date))}</td>
|
||||
<td class="number sentiment-score-cell ${sentimentScoreClass(row.score)}">${number(row.score)}</td>
|
||||
<td><span class="sentiment-phase-badge ${sentimentPhaseClass(row.phase)}">${escapeHtml(row.phase)}</span></td>
|
||||
<td><span class="sentiment-direction ${trendClass(row.direction)}">${escapeHtml(row.direction)}</span></td>
|
||||
<td class="number">${number(row.limit_up_count)}</td>
|
||||
<td class="number">${number(row.first_board_count)}</td>
|
||||
<td class="number">${number(row.second_board_count)}</td>
|
||||
<td class="number">${number(row.three_plus_count)}</td>
|
||||
<td class="number">${number(row.max_height)}板</td>
|
||||
<td class="number">${number(row.broken_count)}</td>
|
||||
<td class="number">${number(row.limit_down_count)}</td>
|
||||
<td class="number">${number(row.previous_limit_count)}</td>
|
||||
<td class="number">${number(row.previous_positive_count)}</td>
|
||||
<td class="number">${formatNumber(row.previous_positive_rate, 1)}%</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
if (!latest) {
|
||||
setText("sentimentHistoryDateRange", "暂无历史数据");
|
||||
return;
|
||||
}
|
||||
setText(
|
||||
"sentimentHistoryDateRange",
|
||||
`${displayCompactDate(rows[0].trade_date)} 至 ${displayCompactDate(latest.trade_date)}`,
|
||||
);
|
||||
setText("sentimentCycleScore", number(latest.score));
|
||||
setText("sentimentCycleLabel", latest.label);
|
||||
setText("sentimentCycleDate", displayCompactDate(latest.trade_date));
|
||||
setText("sentimentCyclePhase", latest.phase);
|
||||
setText("sentimentCycleDirection", latest.direction);
|
||||
const dayChange = number(latest.day_change);
|
||||
const confidence = sentimentPhaseConfidence(latest);
|
||||
setText("sentimentPhaseConfidence", `置信度 ${confidence}%`);
|
||||
setText("sentimentDayChange", `${dayChange > 0 ? "+" : ""}${formatNumber(dayChange, 1)}`);
|
||||
setText("sentimentSealRate", `${formatNumber(latest.seal_rate, 1)}%`);
|
||||
setText("sentimentLimitUp", number(latest.limit_up_count));
|
||||
setText("sentimentBroken", number(latest.broken_count));
|
||||
setText("sentimentPhaseAdvice", sentimentPhaseAdvice(latest.phase));
|
||||
setText("sentimentCurrentTag", `当前 ${number(latest.score)} · ${latest.phase}`);
|
||||
setText("sentimentComponentSummary", `五维加权 → 温度 ${number(latest.score)}`);
|
||||
setText("sentimentPeriodNote", `近 ${state.sentimentRange} 个交易日,当前展示 ${rows.length} 日`);
|
||||
const changeElement = document.querySelector("#sentimentDayChange");
|
||||
changeElement.className = changeClass(dayChange);
|
||||
setText("sentimentPreviousPositive", `${number(latest.previous_positive_count)} / ${number(latest.previous_limit_count)} 只`);
|
||||
setText("sentimentPreviousAverage", `红盘率 ${formatNumber(latest.previous_positive_rate, 1)}% · 平均 ${signed(latest.average_previous_change)}%`);
|
||||
setText("sentimentHistoryDays", `${number(payload.available_days)} 个交易日`);
|
||||
setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length} 日`);
|
||||
const marker = document.querySelector("#sentimentCycleScoreMarker");
|
||||
marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`;
|
||||
document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
|
||||
<article class="sentiment-component-item">
|
||||
<div class="sentiment-component-main">
|
||||
<strong>${escapeHtml(item.label)}</strong>
|
||||
<div class="sentiment-component-track" aria-hidden="true"><i data-component-score="${clamp(item.score, 0, 100)}" style="width:0%"></i></div>
|
||||
<b>${formatNumber(item.score, 1)} <em>× ${number(item.weight)}%</em></b>
|
||||
</div>
|
||||
<small>${escapeHtml(item.summary)}</small>
|
||||
</article>
|
||||
`).join("");
|
||||
requestAnimationFrame(() => {
|
||||
animateSentimentComponents();
|
||||
animateSentimentTrendChart(rows);
|
||||
bindSentimentChartTooltip(rows);
|
||||
});
|
||||
animateRows(body);
|
||||
}
|
||||
|
||||
function animateSentimentComponents() {
|
||||
document.querySelectorAll("#sentimentComponentList [data-component-score]").forEach((bar, index) => {
|
||||
const width = `${number(bar.dataset.componentScore)}%`;
|
||||
if (!motionEnabled()) {
|
||||
bar.style.width = width;
|
||||
return;
|
||||
}
|
||||
setTimeout(() => { bar.style.width = width; }, index * 70);
|
||||
});
|
||||
}
|
||||
|
||||
function animateSentimentTrendChart(rows) {
|
||||
if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame);
|
||||
if (!motionEnabled()) {
|
||||
drawSentimentTrendChart(rows, 1);
|
||||
return;
|
||||
}
|
||||
const startedAt = performance.now();
|
||||
const duration = 780;
|
||||
const frame = (now) => {
|
||||
const rawProgress = Math.min(1, (now - startedAt) / duration);
|
||||
const progress = 1 - (1 - rawProgress) ** 3;
|
||||
drawSentimentTrendChart(rows, progress);
|
||||
if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame);
|
||||
else sentimentChartAnimationFrame = null;
|
||||
};
|
||||
sentimentChartAnimationFrame = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function drawSentimentTrendChart(rows, progress = 1) {
|
||||
const canvas = document.querySelector("#sentimentTrendChart");
|
||||
if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width) return;
|
||||
const width = Math.max(320, rect.width);
|
||||
const height = Math.max(220, rect.height);
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
const context = canvas.getContext("2d");
|
||||
const palette = currentChartPalette();
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = palette.background;
|
||||
context.fillRect(0, 0, width, height);
|
||||
const padding = { top: 18, right: 18, bottom: 34, left: 42 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
const x = (index) => padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
|
||||
const y = (score) => padding.top + (100 - clamp(score, 0, 100)) / 100 * chartHeight;
|
||||
|
||||
context.font = '10px "Microsoft YaHei UI", sans-serif';
|
||||
context.textAlign = "right";
|
||||
context.textBaseline = "middle";
|
||||
for (let score = 0; score <= 100; score += 20) {
|
||||
const lineY = y(score);
|
||||
context.strokeStyle = score === 40 || score === 80 ? palette.zero : palette.grid;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(padding.left, lineY);
|
||||
context.lineTo(width - padding.right, lineY);
|
||||
context.stroke();
|
||||
context.fillStyle = palette.axis;
|
||||
context.fillText(String(score), padding.left - 8, lineY);
|
||||
}
|
||||
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(padding.left - 6, padding.top - 8, (chartWidth + 12) * clamp(progress, 0, 1), chartHeight + 18);
|
||||
context.clip();
|
||||
|
||||
const finalPhase = rows[rows.length - 1]?.phase;
|
||||
let phaseStart = rows.length - 1;
|
||||
while (phaseStart > 0 && rows[phaseStart - 1]?.phase === finalPhase) phaseStart -= 1;
|
||||
if (["退潮", "冰点"].includes(finalPhase)) {
|
||||
const startX = phaseStart === 0 ? padding.left : (x(phaseStart - 1) + x(phaseStart)) / 2;
|
||||
context.fillStyle = palette.alertArea;
|
||||
context.fillRect(startX, padding.top, width - padding.right - startX, chartHeight);
|
||||
context.fillStyle = palette.up;
|
||||
context.font = '10px "Microsoft YaHei UI", sans-serif';
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "top";
|
||||
context.fillText(finalPhase, (startX + width - padding.right) / 2, padding.top + 4);
|
||||
}
|
||||
|
||||
const movingAverage = rows.map((_row, index) => {
|
||||
const start = Math.max(0, index - 4);
|
||||
const sample = rows.slice(start, index + 1);
|
||||
return sample.reduce((sum, item) => sum + number(item.score), 0) / sample.length;
|
||||
});
|
||||
context.beginPath();
|
||||
movingAverage.forEach((score, index) => {
|
||||
if (index === 0) context.moveTo(x(index), y(score));
|
||||
else context.lineTo(x(index), y(score));
|
||||
});
|
||||
context.strokeStyle = palette.movingAverage;
|
||||
context.lineWidth = 1.5;
|
||||
context.setLineDash([5, 4]);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
|
||||
context.beginPath();
|
||||
rows.forEach((row, index) => {
|
||||
const pointX = x(index);
|
||||
const pointY = y(row.score);
|
||||
if (index === 0) context.moveTo(pointX, pointY);
|
||||
else context.lineTo(pointX, pointY);
|
||||
});
|
||||
context.lineTo(x(rows.length - 1), padding.top + chartHeight);
|
||||
context.lineTo(x(0), padding.top + chartHeight);
|
||||
context.closePath();
|
||||
context.fillStyle = palette.area;
|
||||
context.fill();
|
||||
|
||||
context.beginPath();
|
||||
rows.forEach((row, index) => {
|
||||
const pointX = x(index);
|
||||
const pointY = y(row.score);
|
||||
if (index === 0) context.moveTo(pointX, pointY);
|
||||
else context.lineTo(pointX, pointY);
|
||||
});
|
||||
context.strokeStyle = palette.line;
|
||||
context.lineWidth = 2.5;
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
context.stroke();
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
context.beginPath();
|
||||
context.arc(x(index), y(row.score), index === rows.length - 1 ? 4.5 : 3, 0, Math.PI * 2);
|
||||
context.fillStyle = ["退潮", "冰点"].includes(row.phase) ? palette.up : row.phase === "修复" ? palette.repair : palette.line;
|
||||
context.fill();
|
||||
context.strokeStyle = palette.background;
|
||||
context.lineWidth = 1.5;
|
||||
context.stroke();
|
||||
});
|
||||
context.restore();
|
||||
|
||||
const labelStep = Math.max(1, Math.ceil(rows.length / 6));
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = palette.axis;
|
||||
rows.forEach((row, index) => {
|
||||
if (index % labelStep !== 0 && index !== rows.length - 1) return;
|
||||
const dateText = displayCompactDate(row.trade_date).slice(5);
|
||||
context.fillText(dateText, x(index), height - padding.bottom + 10);
|
||||
});
|
||||
}
|
||||
|
||||
function bindSentimentChartTooltip(rows) {
|
||||
const canvas = document.querySelector("#sentimentTrendChart");
|
||||
const tooltip = document.querySelector("#sentimentChartTooltip");
|
||||
if (!canvas || !tooltip || !rows.length) return;
|
||||
canvas.onmousemove = (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const padding = { left: 42, right: 18 };
|
||||
const chartWidth = Math.max(1, rect.width - padding.left - padding.right);
|
||||
const relativeX = clamp(event.clientX - rect.left - padding.left, 0, chartWidth);
|
||||
const index = rows.length === 1 ? 0 : Math.round(relativeX / chartWidth * (rows.length - 1));
|
||||
const row = rows[index];
|
||||
tooltip.innerHTML = `${escapeHtml(displayCompactDate(row.trade_date))} · 温度 <b>${number(row.score)}</b> · ${escapeHtml(row.phase)}`;
|
||||
tooltip.hidden = false;
|
||||
const targetLeft = padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
|
||||
tooltip.style.left = `${clamp(targetLeft + 10, 8, rect.width - tooltip.offsetWidth - 8)}px`;
|
||||
tooltip.style.top = `${clamp(event.clientY - rect.top - 34, 8, rect.height - 34)}px`;
|
||||
};
|
||||
canvas.onmouseleave = () => { tooltip.hidden = true; };
|
||||
}
|
||||
|
||||
function sentimentScoreClass(score) {
|
||||
const value = number(score);
|
||||
return value >= 60 ? "score-strong" : value < 40 ? "score-weak" : "score-neutral";
|
||||
}
|
||||
|
||||
function sentimentPhaseClass(phase) {
|
||||
return {
|
||||
"冰点": "phase-ice",
|
||||
"修复": "phase-repair",
|
||||
"发酵": "phase-fermentation",
|
||||
"高潮": "phase-climax",
|
||||
"分化": "phase-divergence",
|
||||
"退潮": "phase-retreat",
|
||||
}[phase] || "phase-divergence";
|
||||
}
|
||||
|
||||
function sentimentPhaseConfidence(row) {
|
||||
const explicit = number(row?.confidence || row?.phase_confidence);
|
||||
if (explicit > 0) return Math.round(clamp(explicit, 0, 100));
|
||||
const historyEvidence = Math.min(12, number(row?.history_days) * 0.6);
|
||||
const movementEvidence = Math.min(18, Math.abs(number(row?.day_change)) * 0.8);
|
||||
return Math.round(clamp(62 + historyEvidence + movementEvidence, 60, 92));
|
||||
}
|
||||
|
||||
function sentimentPhaseAdvice(phase) {
|
||||
return {
|
||||
"冰点": "情绪处于极弱区,先观察风险释放,允许没有候选结果。",
|
||||
"修复": "风险开始收敛,关注率先转强的核心,小仓验证修复强度。",
|
||||
"发酵": "主线与梯队正在形成,优先跟随核心,避免偏离主线。",
|
||||
"高潮": "情绪与一致性已处高位,聚焦核心并主动降低后排暴露。",
|
||||
"分化": "强弱开始分层,关注承接与回流,淘汰失去辨识度的方向。",
|
||||
"退潮": "情绪指标继续走弱。",
|
||||
}[phase] || "市场结构尚未形成清晰阶段,保持观察并等待确认。";
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:1207-1516 */
|
||||
@@ -0,0 +1,108 @@
|
||||
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
|
||||
enter: ["loadThemes"],
|
||||
});
|
||||
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:2482-2583 */
|
||||
async function loadThemeLibrary(force = false) {
|
||||
if (state.themeLoading) return;
|
||||
state.themeLoading = true;
|
||||
const button = document.querySelector("#themeRefreshButton");
|
||||
button.disabled = true;
|
||||
setText("themeDateLabel", "正在整理题材库");
|
||||
try {
|
||||
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
|
||||
if (force) query.set("force", "1");
|
||||
state.themeLibrary = await apiRequest(`/api/themes?${query}`);
|
||||
renderThemeLibrary();
|
||||
const available = (state.themeLibrary.items || []).some((item) => item.code === state.selectedThemeCode);
|
||||
if (!available) state.selectedThemeCode = "";
|
||||
const initialCode = state.selectedThemeCode || state.themeLibrary.items?.[0]?.code || "";
|
||||
if (initialCode) await selectTheme(initialCode, true);
|
||||
} catch (error) {
|
||||
setText("themeDateLabel", error.message || "题材数据暂不可用");
|
||||
renderEmptyState("themeDirectory", error.message || "题材数据加载失败");
|
||||
showToast(error.message || "题材数据加载失败");
|
||||
} finally {
|
||||
state.themeLoading = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderThemeLibrary() {
|
||||
const payload = state.themeLibrary;
|
||||
if (!payload) return;
|
||||
const summary = payload.summary || {};
|
||||
setText("themeDateLabel", `${payload.meta?.carried_forward ? "最近有效行情" : "行情日期"} ${payload.meta?.trade_date || "--"}`);
|
||||
document.querySelector("#themeSummary").innerHTML = [
|
||||
["收录题材", number(summary.theme_count), "个", ""],
|
||||
["当日上涨", number(summary.up_count), "个", "up"],
|
||||
["当日下跌", number(summary.down_count), "个", "down"],
|
||||
["人气题材", number(summary.hot_count), "个", "warning"],
|
||||
].map(([label, value, unit, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}<small>${unit}</small></strong></div>`).join("");
|
||||
renderThemeDirectory();
|
||||
}
|
||||
|
||||
function renderThemeDirectory() {
|
||||
let items = [...(state.themeLibrary?.items || [])];
|
||||
if (state.themeQuery) {
|
||||
items = items.filter((item) => `${item.code} ${item.name}`.toLocaleLowerCase("zh-CN").includes(state.themeQuery));
|
||||
}
|
||||
setText("themeResultCount", `${items.length} 个`);
|
||||
document.querySelector("#themeDirectory").innerHTML = items.map((item, index) => {
|
||||
const active = item.code === state.selectedThemeCode;
|
||||
return `
|
||||
<button type="button" class="theme-directory-item-v2 ${active ? "active" : ""}" data-theme-code="${escapeHtml(item.code)}" aria-pressed="${active}">
|
||||
<span class="theme-rank-v2">${index + 1}</span>
|
||||
<span class="theme-directory-copy-v2"><strong class="market-preview-trigger" data-market-preview-type="theme" data-market-preview-id="${escapeHtml(item.code)}" title="悬停预览题材行情">${escapeHtml(item.name)}</strong><small>${number(item.member_count)} 只成分${item.hot_rank ? ` · 人气第 ${number(item.hot_rank)}` : ""}</small></span>
|
||||
<b class="${changeClass(item.change)}">${item.has_quote ? `${signed(item.change)}%` : "--"}</b>
|
||||
</button>`;
|
||||
}).join("") || emptyStateHtml("没有匹配的题材");
|
||||
}
|
||||
|
||||
async function selectTheme(code, keepSelection = false) {
|
||||
if (!code) return;
|
||||
state.selectedThemeCode = code;
|
||||
if (!keepSelection) renderThemeDirectory();
|
||||
document.querySelector("#themeDetailEmpty").hidden = false;
|
||||
document.querySelector("#themeDetailContent").hidden = true;
|
||||
setText("themeDetailEmpty", "正在读取题材详情");
|
||||
try {
|
||||
const query = new URLSearchParams({ code, trade_date: elements.tradeDate.value });
|
||||
state.themeDetail = await apiRequest(`/api/themes/detail?${query}`);
|
||||
renderThemeDetail();
|
||||
} catch (error) {
|
||||
setText("themeDetailEmpty", error.message || "题材详情加载失败");
|
||||
showToast(error.message || "题材详情加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function renderThemeDetail() {
|
||||
const payload = state.themeDetail;
|
||||
if (!payload) return;
|
||||
const theme = payload.theme || {};
|
||||
const summary = payload.summary || {};
|
||||
document.querySelector("#themeDetailEmpty").hidden = true;
|
||||
document.querySelector("#themeDetailContent").hidden = false;
|
||||
setText("themeDetailName", theme.name || "--");
|
||||
setText("themeDetailCode", `${theme.code || "--"} · ${payload.meta?.trade_date || "--"}`);
|
||||
setText("themeDetailChange", `${signed(theme.change)}%`);
|
||||
document.querySelector("#themeDetailChange").className = changeClass(theme.change);
|
||||
document.querySelector("#themeDetailMetrics").innerHTML = [
|
||||
["成分股", `${number(summary.member_count)} 只`, ""],
|
||||
["有行情", `${number(summary.quoted_count)} 只`, ""],
|
||||
["上涨", `${number(summary.up_count)} 只`, "up"],
|
||||
["下跌", `${number(summary.down_count)} 只`, "down"],
|
||||
["换手率", `${formatNumber(theme.turnover_rate, 2)}%`, ""],
|
||||
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
|
||||
setText("themeMemberCount", `有行情 ${number(summary.quoted_count)} / ${number(summary.member_count)}`);
|
||||
const body = document.querySelector("#themeMemberTableBody");
|
||||
body.innerHTML = (payload.members || []).map((row, index) => `
|
||||
<tr data-code="${escapeHtml(row.code)}"><td class="row-number num muted">${index + 1}</td>
|
||||
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
|
||||
<td class="number num ${changeClass(row.change)}">${row.has_quote ? signed(row.change) : ""}</td>
|
||||
<td class="number num">${row.has_quote ? formatNumber(row.price, 2) : ""}</td><td class="number num">${row.has_quote ? formatNumber(row.amount_billion, 2) : ""}</td></tr>`).join("");
|
||||
bindStockRows(body);
|
||||
renderThemeDirectory();
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:2482-2583 */
|
||||
@@ -0,0 +1,143 @@
|
||||
/* PRESERVATION-SOURCE-BEGIN app.js:8905-9051 */
|
||||
function exportStocks() {
|
||||
exportRows("涨停池", getVisibleStocks(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["连板", "streak"], ["涨幅%", "change"],
|
||||
["价格", "price"], ["所属板块", "sector"], ["涨停原因", "reason"], ["首封", "first_time"],
|
||||
["最后封板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"],
|
||||
["成交额亿", "amount_billion"], ["封单额万", "seal_amount_million"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportBroken() {
|
||||
exportRows("炸板池", getVisibleBrokenRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["现价涨幅%", "change"], ["距涨停%", "limitGap"],
|
||||
["价格", "price"], ["所属板块", "sector"], ["首次触板", "first_time"], ["开板次数", "open_times"],
|
||||
["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportDown() {
|
||||
exportRows("跌停板", getVisibleDownRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["跌幅%", "change"], ["价格", "price"],
|
||||
["所属板块", "sector"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportYesterday() {
|
||||
exportRows("昨日涨停", getVisibleYesterdayRows(), [
|
||||
["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"],
|
||||
["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"],
|
||||
["所属板块", "sector"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportLadder() {
|
||||
const rows = (state.dashboard?.ladders || []).flatMap((group) => (group.stocks || []).map((stock) => ({
|
||||
level: group.label || group.level,
|
||||
...stock,
|
||||
})));
|
||||
exportRows("市场天梯", rows, [
|
||||
["梯队", "level"], ["股票代码", "code"], ["股票名称", "name"], ["所属板块", "sector"],
|
||||
["封板时间", "first_time"], ["开板次数", "open_times"], ["封单额万", "seal_amount_million"], ["成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportRotation() {
|
||||
const sectorMap = new Map((state.dashboard?.sectors || []).map((sector) => [sector.name, sector]));
|
||||
const rows = (state.dashboard?.sector_rotation || []).map((row) => ({
|
||||
...row,
|
||||
average_change: sectorMap.get(row.name)?.change ?? 0,
|
||||
}));
|
||||
exportRows("板块轮动", rows, [
|
||||
["排名", "rank"], ["板块", "name"], ["趋势", "trend"], ["今日涨停", "count"],
|
||||
["昨日涨停", "previous_count"], ["变化", "delta"], ["强度", "strength"],
|
||||
["最高板", "max_streak"], ["平均涨幅%", "average_change"],
|
||||
["领涨股", "leader"], ["涨停股成交额亿", "amount_billion"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportSentimentHistory() {
|
||||
const rows = state.sentimentHistory?.rows || [];
|
||||
if (!rows.length) {
|
||||
showToast("暂无可导出的情绪周期数据");
|
||||
return;
|
||||
}
|
||||
const exportRowsData = rows.map((row) => ({
|
||||
...row,
|
||||
breadth_score: row.components?.breadth?.score,
|
||||
limit_ecology_score: row.components?.limit_ecology?.score,
|
||||
profit_effect_score: row.components?.profit_effect?.score,
|
||||
ladder_structure_score: row.components?.ladder_structure?.score,
|
||||
liquidity_score: row.components?.liquidity?.score,
|
||||
}));
|
||||
exportRows("情绪周期", exportRowsData, [
|
||||
["交易日", "trade_date"], ["情绪温度", "score"], ["周期阶段", "phase"], ["方向", "direction"],
|
||||
["涨停", "limit_up_count"], ["首板", "first_board_count"], ["二板", "second_board_count"],
|
||||
["三板以上", "three_plus_count"], ["连板高度", "max_height"], ["炸板", "broken_count"],
|
||||
["跌停", "limit_down_count"], ["昨日涨停", "previous_limit_count"],
|
||||
["昨日涨停红盘", "previous_positive_count"], ["昨日涨停红盘率%", "previous_positive_rate"],
|
||||
["市场宽度", "breadth_score"], ["涨停生态", "limit_ecology_score"],
|
||||
["赚钱效应", "profit_effect_score"], ["连板结构", "ladder_structure_score"],
|
||||
["成交活跃度", "liquidity_score"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportDragonTiger() {
|
||||
const rows = (state.dragonTiger?.traders || []).flatMap((trader) => (
|
||||
(trader.operations || []).map((operation) => ({
|
||||
trader_name: trader.name,
|
||||
identity_type: dragonIdentityLabel(trader.identity_type),
|
||||
...operation,
|
||||
}))
|
||||
));
|
||||
exportRows("游资龙虎榜", rows, [
|
||||
["游资或席位", "trader_name"], ["身份", "identity_type"], ["股票代码", "code"],
|
||||
["股票名称", "name"], ["方向", "direction"], ["涨幅%", "change"],
|
||||
["买入百万元", "buy_million"], ["卖出百万元", "sell_million"], ["净额百万元", "net_buy_million"],
|
||||
["关联席位", "seat_name"], ["上榜原因", "reason"],
|
||||
]);
|
||||
}
|
||||
|
||||
function exportHotMoneyProfiles() {
|
||||
const rows = state.hotMoneyProfiles?.profiles || [];
|
||||
if (!rows.length) {
|
||||
showToast("暂无可导出的游资档案");
|
||||
return;
|
||||
}
|
||||
downloadCsv(
|
||||
`游资档案-${todayString()}.csv`,
|
||||
["游资名称", "简介", "关联营业部", "席位数量"],
|
||||
rows.map((profile) => [
|
||||
profile.name,
|
||||
profile.description,
|
||||
(profile.organizations || []).join(";"),
|
||||
number(profile.organization_count),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function exportRows(label, rows, columns) {
|
||||
const headers = columns.map(([header]) => header);
|
||||
const data = rows.map((row) => columns.map(([, key]) => row[key] ?? ""));
|
||||
downloadCsv(`${label}-${state.dashboard.meta.trade_date}.csv`, headers, data);
|
||||
}
|
||||
|
||||
function downloadCsv(filename, headers, rows) {
|
||||
const lines = [headers, ...rows].map((row) => row.map(csvCell).join(","));
|
||||
const blob = new Blob(["\ufeff", lines.join("\r\n")], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`已导出 ${rows.length} 条数据`);
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
let text = String(value ?? "");
|
||||
if (/^[=+\-@]/.test(text)) text = `'${text}`;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
/* PRESERVATION-SOURCE-END app.js:8905-9051 */
|
||||
+4
-115
@@ -1,118 +1,7 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility alias for the canonical heaven agent implementation."""
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
import sys
|
||||
|
||||
from backend.features.heaven import agent as _implementation
|
||||
|
||||
class HeavenAgentError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def interpret_heaven(
|
||||
mode: str,
|
||||
context: dict[str, Any],
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
timeout: int = 90,
|
||||
) -> dict[str, Any]:
|
||||
if mode not in {"trend", "fortune", "heart"}:
|
||||
raise HeavenAgentError("不支持的问天解读模式。")
|
||||
if not api_key or not model:
|
||||
raise HeavenAgentError("LLM API Key 或模型尚未配置。")
|
||||
system_prompt = _system_prompt(mode)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(context, ensure_ascii=False, separators=(",", ":")),
|
||||
},
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}/chat/completions",
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": "XiaobaiReviewWeb/0.7",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
answer = str(result["choices"][0]["message"]["content"]).strip()
|
||||
if not answer:
|
||||
raise KeyError("empty response")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise HeavenAgentError(_http_error_message(exc)) from exc
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
||||
raise HeavenAgentError(f"问天模型调用失败:{exc}") from exc
|
||||
return {
|
||||
"answer": answer,
|
||||
"model": model,
|
||||
"latency_ms": round((time.perf_counter() - started) * 1000),
|
||||
}
|
||||
|
||||
|
||||
def _system_prompt(mode: str) -> str:
|
||||
common = """
|
||||
你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情。
|
||||
问天属于传统文化与娱乐化观察,不是预测模型,不承诺应验,不输出无条件买卖指令,不用神秘话术制造确定性。
|
||||
使用中文,先给核心判断,再解释结构。引用市场数字时标明数据日期。输出纯文本,可使用简短标题。
|
||||
""".strip()
|
||||
if mode == "trend":
|
||||
return common + """
|
||||
|
||||
当前任务是“观势·解势”。六爻从初爻到上爻依次是个股内核、个股外显、板块内核、板块外显、指数内核、指数外显;初二为地、三四为人、五上为天。
|
||||
行情数据只负责生成六爻,本次解势必须以卦象本身为主,不得根据指数涨跌、板块强弱、涨停家数、成交量或个股表现直接推演方向。context中不会提供这些数字,也不会提供爻位对应的市场角色。
|
||||
先解释本卦卦名的核心义、上下卦组合及大象;再只解释实际动爻所代表的转折,并说明本卦如何走向之卦;最后可把这一组卦势翻译成克制的市场语言。
|
||||
重点是“本卦为当下之势,动爻为变化关节,之卦为所趋之势”。不要说明某一动爻对应指数、板块或个股,也不要输出“一看指数、二看涨停家数”一类行情观察条件。
|
||||
全文控制在300至450个中文字符,最多四小段。卦理约占九成,市场翻译最多一句,只能落到节制、等待、守信、辨伪等行为态度,不得据此预测市场下一阶段、涨跌方向或动能变化。不直接荐股,不使用Markdown表格。
|
||||
不要使用“必然、确定、必涨、必跌、后续将、进入某阶段”等断语;天机只点出势的性质与变化关系,不替用户宣布结果。
|
||||
""".strip()
|
||||
if mode == "fortune":
|
||||
return common + """
|
||||
|
||||
当前任务是“观气·解运”。严格区分五运、六气、节气、月令和日干,不把丙午简单解释为火年。
|
||||
严格服从five_phase_field.framework提供的确定性结构,不自行重新计算五行:年纲由中运与司天在泉构成;岁半以前司天为主、在泉为辅,岁半以后在泉为主、司天为辅;当前六气层以客气加临主气为核心;日辰只负责触发。节气只用于定位当前六气阶段,不得再次叠加为独立力量。
|
||||
重点解释framework.relations中的客主同气、客生主、主生客、客克主或主克客,以及客胜为从、主胜为逆、司天在泉同位、天符岁会等已经判定的关系。不得把司天、在泉、主气、客气视为彼此独立的证据重复计权,也不得自行增删传统格局。
|
||||
首要解释当日气场容易放大参与者的哪些情绪、判断偏差和操作冲动,例如急躁、恐惧、迟疑、追涨、过早止损或路径依赖;再给出一至两个调节动作。
|
||||
如有personal_profile,结合其日主、十神、五行平衡倾向说明当日对该用户主观状态的影响,但不得把简化平衡倾向说成唯一喜用神,也不得复述或猜测出生日期。
|
||||
不得引用市场上涨下跌家数、涨跌停数量、成交额、板块强度或个股表现来证明气场。industry_affinity只是五行行业取象示例,不是行情旁证;行业契合度最多在末尾用一句话说明,不得写“当日共振”或暗示相关行业必然涨跌。
|
||||
全文控制在420至600个中文字符,按“三层气机、人的状态、操作偏向、个人影响(如有)、制衡动作”组织,标题必须写“三层气机”。明确这些是传统历法框架下的观察语言,不宣称气候或五行直接导致股价。
|
||||
""".strip()
|
||||
return common + """
|
||||
|
||||
当前任务是“观心·解卦”。用户的问题始终只在心中,没有输入给你,因此你不能猜测问题内容,也不能替用户作具体决定。
|
||||
全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句。
|
||||
不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味。
|
||||
""".strip()
|
||||
|
||||
|
||||
def _http_error_message(exc: urllib.error.HTTPError) -> str:
|
||||
detail = ""
|
||||
try:
|
||||
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
detail = str(error.get("message") or error.get("code") or "")
|
||||
elif error:
|
||||
detail = str(error)
|
||||
elif payload.get("message"):
|
||||
detail = str(payload["message"])
|
||||
except (json.JSONDecodeError, OSError):
|
||||
detail = ""
|
||||
suffix = f":{detail[:300]}" if detail else ""
|
||||
return f"问天模型调用失败(HTTP {exc.code}){suffix}"
|
||||
sys.modules[__name__] = _implementation
|
||||
|
||||
+3
-1178
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,11 @@ module.exports = defineConfig({
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: "python -m http.server 8876 --bind 127.0.0.1 --directory static",
|
||||
command: "python -m http.server 8876 --bind 127.0.0.1 --directory frontend",
|
||||
url: "http://127.0.0.1:8876/index.html",
|
||||
reuseExistingServer: true,
|
||||
timeout: 15_000,
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
});
|
||||
|
||||
-9283
File diff suppressed because it is too large
Load Diff
@@ -1,672 +0,0 @@
|
||||
(function exposeHeavenLoading(global) {
|
||||
"use strict";
|
||||
|
||||
const PAPER = "#fdfcf8";
|
||||
const PAPER_CENTER = "#f1e8d9";
|
||||
const NODE_TEXT = "#fffaf0";
|
||||
const INK = "#68493d";
|
||||
const INK_BRIGHT = "#963f37";
|
||||
const GOLD = "#80533e";
|
||||
const GOLD_BRIGHT = "#b64e43";
|
||||
const CINNABAR = "#b94038";
|
||||
const DIM = "rgba(68,57,49,0.62)";
|
||||
const PARTICLE_COLORS = ["#a94b42", "#456b62", "#506b85"];
|
||||
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
|
||||
const ELEMENT_COLORS = {
|
||||
木: "#4f7a4a",
|
||||
火: "#b3483d",
|
||||
土: "#96702c",
|
||||
金: "#70685b",
|
||||
水: "#496d92",
|
||||
};
|
||||
const QI6 = [
|
||||
{ name: "厥阴风木", element: "木" },
|
||||
{ name: "少阴君火", element: "火" },
|
||||
{ name: "少阳相火", element: "火" },
|
||||
{ name: "太阴湿土", element: "土" },
|
||||
{ name: "阳明燥金", element: "金" },
|
||||
{ name: "太阳寒水", element: "水" },
|
||||
];
|
||||
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
|
||||
const TRIGRAMS = [
|
||||
{ name: "乾", bits: [1, 1, 1], angle: -90 },
|
||||
{ name: "兑", bits: [1, 1, 0], angle: -135 },
|
||||
{ name: "离", bits: [1, 0, 1], angle: 180 },
|
||||
{ name: "震", bits: [1, 0, 0], angle: 135 },
|
||||
{ name: "巽", bits: [0, 1, 1], angle: -45 },
|
||||
{ name: "坎", bits: [0, 1, 0], angle: 0 },
|
||||
{ name: "艮", bits: [0, 0, 1], angle: 45 },
|
||||
{ name: "坤", bits: [0, 0, 0], angle: 90 },
|
||||
];
|
||||
const SIXIANG = [
|
||||
{ name: "太阳", bits: [1, 1], dx: 0, dy: -1 },
|
||||
{ name: "少阴", bits: [1, 0], dx: 1, dy: 0 },
|
||||
{ name: "太阴", bits: [0, 0], dx: 0, dy: 1 },
|
||||
{ name: "少阳", bits: [0, 1], dx: -1, dy: 0 },
|
||||
];
|
||||
const HEXAGRAM_NAMES = [
|
||||
"坤", "剥", "比", "观", "豫", "晋", "萃", "否", "谦", "艮", "蹇", "渐", "小过", "旅", "咸", "遁",
|
||||
"师", "蒙", "坎", "涣", "解", "未济", "困", "讼", "升", "蛊", "井", "巽", "恒", "鼎", "大过", "姤",
|
||||
"复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临",
|
||||
"损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾",
|
||||
];
|
||||
const HEX_TOTAL = 12500;
|
||||
const FORTUNE_TOTAL = 12800;
|
||||
const HEX_STAGES = [
|
||||
[0, 1800, "太 极", "无极而太极,动而生阳"],
|
||||
[1800, 3300, "两 仪", "一阴一阳之谓道"],
|
||||
[3300, 4700, "四 象", "阴阳消长,太少相生"],
|
||||
[4700, 6800, "八 卦", "天地定位,山泽通气"],
|
||||
[6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"],
|
||||
[10800, HEX_TOTAL, "归 一", "万物负阴而抱阳,冲气以为和"],
|
||||
];
|
||||
const clamp01 = (value) => Math.max(0, Math.min(1, value));
|
||||
const smooth = (start, end, value) => {
|
||||
const progress = clamp01((value - start) / Math.max(1, end - start));
|
||||
return progress * progress * (3 - 2 * progress);
|
||||
};
|
||||
const easeOut = (value) => 1 - Math.pow(1 - clamp01(value), 3);
|
||||
const hexBits = (index) => Array.from({ length: 6 }, (_, bit) => (index >> (5 - bit)) & 1);
|
||||
const point = (cx, cy, radius, degrees) => {
|
||||
const radians = degrees * Math.PI / 180;
|
||||
return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius];
|
||||
};
|
||||
|
||||
class HeavenLoadingCanvas {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.context = canvas.getContext("2d");
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
this.dpr = 1;
|
||||
this.scene = "hexagram";
|
||||
this.data = {};
|
||||
this.startedAt = 0;
|
||||
this.frameId = 0;
|
||||
this.running = false;
|
||||
this.completingAt = 0;
|
||||
this.completionResolve = null;
|
||||
this.completionTimer = 0;
|
||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||
this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
this.stars = this.createStars(this.reducedMotion ? 48 : 150);
|
||||
}
|
||||
|
||||
createStars(count) {
|
||||
let seed = 24681357;
|
||||
const random = () => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 4294967296;
|
||||
};
|
||||
return Array.from({ length: count }, () => ({
|
||||
x: random(),
|
||||
y: random(),
|
||||
radius: 0.3 + random() * 1.3,
|
||||
phase: random() * Math.PI * 2,
|
||||
speed: 0.00015 + random() * 0.0004,
|
||||
colorIndex: Math.floor(random() * PARTICLE_COLORS.length),
|
||||
}));
|
||||
}
|
||||
|
||||
start(scene, data = {}) {
|
||||
const nextScene = scene === "fortune" ? "fortune" : "hexagram";
|
||||
if (this.running && this.scene === nextScene) {
|
||||
this.data = data;
|
||||
return;
|
||||
}
|
||||
this.stop();
|
||||
this.scene = nextScene;
|
||||
this.data = data;
|
||||
this.startedAt = performance.now();
|
||||
this.running = true;
|
||||
this.canvas.dataset.scene = this.scene;
|
||||
this.canvas.dataset.running = "true";
|
||||
this.canvas.dataset.looping = "true";
|
||||
this.resizeObserver.observe(this.canvas);
|
||||
this.resize();
|
||||
if (this.reducedMotion) {
|
||||
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
|
||||
} else {
|
||||
this.frameId = requestAnimationFrame((now) => this.frame(now));
|
||||
}
|
||||
}
|
||||
|
||||
complete() {
|
||||
if (!this.running || this.reducedMotion) {
|
||||
this.stop();
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this.completionResolve) return this.completionPromise;
|
||||
this.completingAt = performance.now();
|
||||
this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; });
|
||||
this.completionTimer = global.setTimeout(() => this.stop(), 2200);
|
||||
return this.completionPromise;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.frameId) cancelAnimationFrame(this.frameId);
|
||||
this.frameId = 0;
|
||||
this.running = false;
|
||||
this.completingAt = 0;
|
||||
if (this.completionTimer) global.clearTimeout(this.completionTimer);
|
||||
this.completionTimer = 0;
|
||||
this.resizeObserver.disconnect();
|
||||
this.canvas.dataset.running = "false";
|
||||
this.canvas.dataset.looping = "false";
|
||||
if (this.completionResolve) this.completionResolve();
|
||||
this.completionResolve = null;
|
||||
this.completionPromise = null;
|
||||
}
|
||||
|
||||
resize() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.round(rect.width));
|
||||
const height = Math.max(1, Math.round(rect.height));
|
||||
if (width === this.width && height === this.height) return;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.dpr = Math.min(global.devicePixelRatio || 1, 2);
|
||||
this.canvas.width = Math.round(width * this.dpr);
|
||||
this.canvas.height = Math.round(height * this.dpr);
|
||||
this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||
if (this.running && this.reducedMotion) {
|
||||
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
|
||||
}
|
||||
}
|
||||
|
||||
frame(now) {
|
||||
if (!this.running) return;
|
||||
if (this.completingAt) {
|
||||
const duration = this.scene === "fortune" ? 1800 : 1700;
|
||||
const progress = clamp01((now - this.completingAt) / duration);
|
||||
this.drawCompletion(progress, now);
|
||||
if (progress >= 1) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const total = this.scene === "fortune" ? FORTUNE_TOTAL : HEX_TOTAL;
|
||||
const elapsed = Math.max(0, now - this.startedAt);
|
||||
const timeline = elapsed % total;
|
||||
this.canvas.dataset.cycle = String(Math.floor(elapsed / total));
|
||||
this.draw(timeline, now);
|
||||
}
|
||||
this.frameId = requestAnimationFrame((time) => this.frame(time));
|
||||
}
|
||||
|
||||
draw(time, now) {
|
||||
if (this.width <= 1 || this.height <= 1) return;
|
||||
this.drawBackground(now);
|
||||
if (this.scene === "fortune") this.drawFortune(time, now);
|
||||
else this.drawHexagram(time, now);
|
||||
}
|
||||
|
||||
drawBackground(now) {
|
||||
const { context: ctx, width, height } = this;
|
||||
const cx = width / 2;
|
||||
const cy = height * 0.44;
|
||||
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
|
||||
gradient.addColorStop(0, PAPER_CENTER);
|
||||
gradient.addColorStop(0.52, "#faf7ef");
|
||||
gradient.addColorStop(1, PAPER);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
for (const star of this.stars) {
|
||||
const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012));
|
||||
const alpha = twinkle * 0.5;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = PARTICLE_COLORS[star.colorIndex];
|
||||
const y = ((star.y + now * star.speed) % 1) * height;
|
||||
ctx.fillRect(star.x * width, y, star.radius, star.radius);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
label(text, x, y, size, color = INK, alpha = 1, weight = "", maxWidth) {
|
||||
if (!text || alpha <= 0) return;
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = `${weight ? `${weight} ` : ""}${size}px ${SERIF}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
if (maxWidth) ctx.fillText(text, x, y, maxWidth);
|
||||
else ctx.fillText(text, x, y);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
node(x, y, radius, color, alpha = 1, glow = 0) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = color;
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
line(x1, y1, x2, y2, color, alpha = 1, width = 1) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) {
|
||||
if (alpha <= 0) return;
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.quadraticCurveTo(mx, my, x2, y2);
|
||||
ctx.stroke();
|
||||
const angle = Math.atan2(y2 - my, x2 - mx);
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x2, y2);
|
||||
ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42));
|
||||
ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = INK;
|
||||
ctx.shadowColor = GOLD;
|
||||
ctx.shadowBlur = glow;
|
||||
if (yang) {
|
||||
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, width, lineWidth);
|
||||
} else {
|
||||
const gap = width * 0.18;
|
||||
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
|
||||
ctx.fillRect(cx + gap / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawGua(cx, cy, width, lineWidth, bits, alpha, glow = 0) {
|
||||
const gap = lineWidth * 1.7;
|
||||
const top = cy - (bits.length - 1) * gap / 2;
|
||||
bits.forEach((bit, index) => {
|
||||
this.drawYao(cx, top + (bits.length - 1 - index) * gap, width, lineWidth, bit === 1, alpha, glow);
|
||||
});
|
||||
}
|
||||
|
||||
stageAlpha(time, start, end, fade = 300, hold = false) {
|
||||
const enter = smooth(start, start + fade, time);
|
||||
return hold ? enter : enter * (1 - smooth(end - fade, end, time));
|
||||
}
|
||||
|
||||
fortuneStages() {
|
||||
const sixQi = this.data.sixQi || {};
|
||||
const pillar = this.data.yearPillar || "岁运";
|
||||
const movement = this.data.movement || "中运合参";
|
||||
const sitian = sixQi.sitian || "司天气候";
|
||||
return [
|
||||
[0, 2100, "五 运", "木火土金水,五运相袭,周而复始"],
|
||||
[2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"],
|
||||
[3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明 · 辰戌太阳 · 巳亥厥阴"],
|
||||
[5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"],
|
||||
[7900, 11000, "岁 运 合 参", `${pillar}年 · 中运${movement} · ${sitian}司天`],
|
||||
[11000, FORTUNE_TOTAL, "归 一", "谨守病机,无失气宜"],
|
||||
];
|
||||
}
|
||||
|
||||
drawFooter(time, now, total, stages, scene) {
|
||||
const { context: ctx, width, height } = this;
|
||||
const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0];
|
||||
const labelAlpha = smooth(stage[0], stage[0] + 300, time)
|
||||
* (1 - smooth(stage[1] - 250, stage[1], time));
|
||||
this.label(stage[2], width / 2, height - 108, 19, GOLD, 0.55 + 0.45 * labelAlpha, "600");
|
||||
this.label(stage[3], width / 2, height - 84, 12.5, DIM, (0.4 + 0.4 * labelAlpha) * (scene === "fortune" ? 0.85 : 0.8), "", width - 32);
|
||||
|
||||
const baseSlotWidth = 34;
|
||||
const baseSlotHeight = 5;
|
||||
const baseSlotGap = 12;
|
||||
const baseTotalWidth = baseSlotWidth * 6 + baseSlotGap * 5;
|
||||
const fit = Math.min(1, (width - 28) / baseTotalWidth);
|
||||
const slotWidth = baseSlotWidth * fit;
|
||||
const slotHeight = baseSlotHeight * fit;
|
||||
const slotGap = baseSlotGap * fit;
|
||||
const totalWidth = slotWidth * 6 + slotGap * 5;
|
||||
const filled = Math.min(6, Math.floor(time / (total / 6)));
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap);
|
||||
const y = height - 56;
|
||||
const color = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.16;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, slotWidth, slotHeight);
|
||||
ctx.restore();
|
||||
if (index < filled) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.fillStyle = color;
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.fillRect(x, y, slotWidth, slotHeight);
|
||||
ctx.restore();
|
||||
} else if (index === filled) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35 + 0.3 * Math.sin(now / 200);
|
||||
ctx.fillStyle = color;
|
||||
const progress = (time % (total / 6)) / (total / 6);
|
||||
ctx.fillRect(x, y, slotWidth * progress, slotHeight);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
const dots = ".".repeat(1 + Math.floor(now / 450) % 3);
|
||||
const loadingText = scene === "fortune" ? "推 演 运 气 · 加 载 中" : "推 演 天 机 · 加 载 中";
|
||||
this.label(`${loadingText}${dots}`, width / 2, height - 32, 13, GOLD, 0.75);
|
||||
}
|
||||
|
||||
drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha * 0.13;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
const breath = 1 + 0.006 * Math.sin(now / 620);
|
||||
TRIGRAMS.forEach((trigram, index) => {
|
||||
const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 1;
|
||||
if (progress <= 0) return;
|
||||
const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle);
|
||||
this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 8);
|
||||
const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha;
|
||||
this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index)));
|
||||
});
|
||||
}
|
||||
|
||||
drawHexagram(time, now) {
|
||||
const { width, height } = this;
|
||||
const cx = width / 2;
|
||||
const cy = height * 0.44;
|
||||
const scale = Math.min(width, height);
|
||||
if (time < 1800) {
|
||||
const alpha = this.stageAlpha(time, 0, 1800);
|
||||
this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34);
|
||||
for (let ring = 0; ring < 3; ring += 1) {
|
||||
const progress = ((now / 1500) + ring / 3) % 1;
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = (1 - progress) * 0.22 * alpha;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
if (time >= 1800 && time < 3300) {
|
||||
const alpha = this.stageAlpha(time, 1800, 3300);
|
||||
const progress = easeOut((time - 1850) / 850);
|
||||
const yaoWidth = scale * 0.19 * progress;
|
||||
const yaoLine = Math.max(scale * 0.013, 5);
|
||||
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
|
||||
this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14);
|
||||
this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9);
|
||||
}
|
||||
if (time >= 3300 && time < 4700) {
|
||||
const alpha = this.stageAlpha(time, 3300, 4700);
|
||||
const distance = scale * 0.085;
|
||||
const yaoWidth = Math.max(scale * 0.055, 28);
|
||||
const yaoLine = Math.max(scale * 0.009, 3.5);
|
||||
SIXIANG.forEach((symbol, index) => {
|
||||
const progress = easeOut((time - 3330 - index * 160) / 520);
|
||||
if (progress <= 0) return;
|
||||
const x = cx + symbol.dx * distance;
|
||||
const y = cy + symbol.dy * distance;
|
||||
this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 10);
|
||||
this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
|
||||
});
|
||||
}
|
||||
const trigramRadius = scale * 0.215;
|
||||
const trigramWidth = Math.max(scale * 0.052, 26);
|
||||
const trigramLine = Math.max(scale * 0.0075, 3);
|
||||
if (time >= 4700 && time < 6800) {
|
||||
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
|
||||
}
|
||||
if (time >= 6800 && time < 10800) {
|
||||
const alpha = this.stageAlpha(time, 6800, 10800, 350);
|
||||
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time);
|
||||
const ringRadius = scale * 0.365;
|
||||
const hexWidth = Math.max(scale * 0.026, 13);
|
||||
const hexLine = Math.max(scale * 0.0042, 1.6);
|
||||
const count = Math.floor(clamp01((time - 7000) / 3600) * 64);
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64);
|
||||
this.node(x, y, 1.4, GOLD, alpha * 0.14);
|
||||
if (index < count) {
|
||||
const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
|
||||
if (freshness > 0) {
|
||||
const ctx = this.context;
|
||||
const gradient = ctx.createLinearGradient(cx, cy, x, y);
|
||||
gradient.addColorStop(0, "rgba(128,83,62,0)");
|
||||
gradient.addColorStop(1, GOLD);
|
||||
this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35);
|
||||
}
|
||||
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9);
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
const current = count - 1;
|
||||
const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
|
||||
const pop = 1 + 0.22 * (1 - popTime);
|
||||
this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16);
|
||||
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600");
|
||||
this.label(`第 ${current + 1} 卦`, cx, cy + scale * 0.105, 13, GOLD, alpha * 0.55);
|
||||
}
|
||||
}
|
||||
if (time >= 10800) {
|
||||
const alpha = this.stageAlpha(time, 10800, HEX_TOTAL, 420);
|
||||
const progress = easeOut((time - 10850) / 1150);
|
||||
const radius = scale * 0.365 * (1 - progress);
|
||||
for (let index = 0; index < 64 && radius >= 8; index += 1) {
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64);
|
||||
this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - progress) * 0.7 * alpha);
|
||||
}
|
||||
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
|
||||
}
|
||||
this.drawFooter(time, now, HEX_TOTAL, HEX_STAGES, "hexagram");
|
||||
}
|
||||
|
||||
drawFortune(time, now) {
|
||||
const { width, height } = this;
|
||||
const cx = width / 2;
|
||||
const cy = height * 0.44;
|
||||
const scale = Math.min(width, height);
|
||||
if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale);
|
||||
if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale);
|
||||
if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale);
|
||||
if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale);
|
||||
if (time >= 7900 && time < 11000) this.drawAnnualQi(time, now, cx, cy, scale);
|
||||
if (time >= 11000) {
|
||||
const alpha = this.stageAlpha(time, 11000, FORTUNE_TOTAL, 420);
|
||||
const progress = easeOut((time - 11050) / 1200);
|
||||
const radius = scale * 0.30 * (1 - progress);
|
||||
QI6.forEach((qi, index) => {
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 60);
|
||||
if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - progress) * 0.8 * alpha, 8);
|
||||
});
|
||||
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
|
||||
}
|
||||
this.drawFooter(time, now, FORTUNE_TOTAL, this.fortuneStages(), "fortune");
|
||||
}
|
||||
|
||||
drawFiveMovements(time, now, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 0, 2100);
|
||||
const radius = scale * 0.17;
|
||||
const nodeRadius = Math.max(scale * 0.018, 9);
|
||||
const elements = [
|
||||
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
|
||||
];
|
||||
const positions = {};
|
||||
this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30);
|
||||
elements.forEach(([element, degrees], index) => {
|
||||
const progress = easeOut((time - 500 - index * 170) / 500);
|
||||
if (progress <= 0) return;
|
||||
const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress;
|
||||
const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress;
|
||||
positions[element] = [x, y];
|
||||
this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 16);
|
||||
this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600");
|
||||
const direction = element === "土" ? "中央土" : { 木: "东方木", 火: "南方火", 金: "西方金", 水: "北方水" }[element];
|
||||
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
|
||||
});
|
||||
const order = ["木", "火", "土", "金", "水"];
|
||||
order.forEach((element, index) => {
|
||||
const from = positions[element];
|
||||
const to = positions[order[(index + 1) % order.length]];
|
||||
if (!from || !to) return;
|
||||
const progress = smooth(1450 + index * 130, 1700 + index * 130, time);
|
||||
const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25;
|
||||
const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25;
|
||||
this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4);
|
||||
});
|
||||
}
|
||||
|
||||
drawStems(time, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 2100, 3900);
|
||||
const stems = "甲乙丙丁戊己庚辛壬癸";
|
||||
const movements = ["土", "金", "水", "木", "火"];
|
||||
const radius = scale * 0.30;
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
|
||||
if (progress <= 0) continue;
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 36);
|
||||
const element = movements[index % 5];
|
||||
this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
|
||||
this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
|
||||
}
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
|
||||
const angle = -90 + index * 36;
|
||||
const [x1, y1] = point(cx, cy, radius, angle);
|
||||
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
|
||||
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
|
||||
const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90);
|
||||
this.label(movements[index], labelX, labelY, 16, ELEMENT_COLORS[movements[index]], alpha * progress, "600");
|
||||
}
|
||||
}
|
||||
|
||||
drawBranches(time, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 3900, 5800);
|
||||
const branches = "子丑寅卯辰巳午未申酉戌亥";
|
||||
const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"];
|
||||
const radius = scale * 0.31;
|
||||
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
|
||||
const [x, y] = point(cx, cy, radius, branchAngle(index));
|
||||
this.node(x, y, 2.5, GOLD, alpha * progress, 6);
|
||||
this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
|
||||
}
|
||||
qiNames.forEach((name, index) => {
|
||||
const progress = smooth(4900 + index * 130, 5200 + index * 130, time);
|
||||
const [x1, y1] = point(cx, cy, radius, branchAngle(index));
|
||||
const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6));
|
||||
const element = QI6.find((item) => item.name === name)?.element || "土";
|
||||
this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.4);
|
||||
const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
|
||||
this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
|
||||
});
|
||||
}
|
||||
|
||||
drawSixQi(time, now, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 5800, 7900);
|
||||
const radius = scale * 0.27;
|
||||
const drift = now * 0.004;
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha * 0.13;
|
||||
ctx.strokeStyle = GOLD;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
QI6.forEach((qi, index) => {
|
||||
const progress = easeOut((time - 5850 - index * 180) / 550);
|
||||
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
|
||||
const nodeRadius = Math.max(scale * 0.015, 8) * progress;
|
||||
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
|
||||
this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
|
||||
this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9);
|
||||
});
|
||||
this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24);
|
||||
}
|
||||
|
||||
drawAnnualQi(time, now, cx, cy, scale) {
|
||||
const alpha = this.stageAlpha(time, 7900, 11000, 350);
|
||||
const sixQi = this.data.sixQi || {};
|
||||
const pillar = this.data.yearPillar || "岁运";
|
||||
const movement = this.data.movement || "中运合参";
|
||||
const sitian = sixQi.sitian || "司天气候";
|
||||
const zaiquan = sixQi.zaiquan || "在泉气化";
|
||||
const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1));
|
||||
const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
|
||||
const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
|
||||
this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
|
||||
this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
|
||||
this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
|
||||
this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
|
||||
this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600");
|
||||
this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62);
|
||||
const radius = scale * 0.30;
|
||||
QI6.forEach((qi, index) => {
|
||||
const progress = smooth(9200 + index * 260, 9480 + index * 260, time);
|
||||
const [x, y] = point(cx, cy, radius, -90 + index * 60);
|
||||
const current = index + 1 === currentStep;
|
||||
const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0;
|
||||
this.node(x, y, Math.max(scale * 0.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14);
|
||||
if (current) {
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
|
||||
ctx.strokeStyle = CINNABAR;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600");
|
||||
}
|
||||
const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`;
|
||||
this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : "");
|
||||
if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress);
|
||||
});
|
||||
}
|
||||
|
||||
drawCompletion(progress, now) {
|
||||
this.drawBackground(now);
|
||||
if (this.scene === "fortune") {
|
||||
this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now);
|
||||
} else {
|
||||
this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.HeavenLoadingCanvas = HeavenLoadingCanvas;
|
||||
})(window);
|
||||
@@ -1,4 +0,0 @@
|
||||
window.XiaobaiPageModules.register("auction", ["auctionView"], {
|
||||
enter: ["loadAuction"],
|
||||
leave: ["clearAuction"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
|
||||
enter: ["loadDragonTiger"],
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
window.XiaobaiPageModules.register("heaven", ["heavenView"], {
|
||||
enter: ["loadHeaven"],
|
||||
leave: ["stopHeaven"],
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
|
||||
enter: ["loadMentor"],
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
window.XiaobaiPageModules.register("pools", [
|
||||
"limitPool",
|
||||
"brokenView",
|
||||
"downView",
|
||||
"yesterdayView",
|
||||
"performanceView",
|
||||
]);
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
|
||||
enter: ["loadPopularity"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
|
||||
enter: ["loadReview"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
|
||||
enter: ["loadRotation"],
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
window.XiaobaiPageModules.register("screener", ["screenerView"], {
|
||||
enter: ["loadScreener"],
|
||||
});
|
||||
|
||||
window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]);
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
|
||||
enter: ["loadSentiment"],
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
|
||||
enter: ["loadThemes"],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
"""Test support package for the preserved application."""
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
ORIGINAL_ROOT = APP_ROOT.parent
|
||||
FRONTEND_ROOT = APP_ROOT / "frontend"
|
||||
ORIGINAL_STATIC = ORIGINAL_ROOT / "static"
|
||||
|
||||
SOURCE_RANGE = re.compile(
|
||||
r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n"
|
||||
r"(.*?)"
|
||||
r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# These exact original app.js line ranges were retired in slice 11 after the
|
||||
# definition-only symbols passed static, runtime, and compatibility review.
|
||||
RETIRED_FRONTEND_SOURCE_RANGES = (
|
||||
(3537, 3540),
|
||||
(4139, 4145),
|
||||
(4973, 4989),
|
||||
(9022, 9027),
|
||||
(9052, 9055),
|
||||
)
|
||||
AUDITED_FRONTEND_SOURCE_LINE_COUNT = 9283
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def reassembled_frontend_runtime() -> str:
|
||||
chunks: dict[tuple[int, int], str] = {}
|
||||
for path in FRONTEND_ROOT.rglob("*.js"):
|
||||
for match in SOURCE_RANGE.finditer(path.read_text(encoding="utf-8")):
|
||||
source_range = (int(match.group(1)), int(match.group(2)))
|
||||
if source_range in chunks:
|
||||
raise AssertionError(f"duplicate app.js source range: {source_range}")
|
||||
chunks[source_range] = match.group(3)
|
||||
|
||||
assembled: list[str] = []
|
||||
next_line = 1
|
||||
for (start, end), content in sorted(chunks.items()):
|
||||
if start != next_line:
|
||||
raise AssertionError(
|
||||
f"app.js source coverage gap: expected line {next_line}, got {start}"
|
||||
)
|
||||
assembled.append(content)
|
||||
next_line = end + 1
|
||||
|
||||
if next_line != AUDITED_FRONTEND_SOURCE_LINE_COUNT + 1:
|
||||
raise AssertionError(
|
||||
"app.js source coverage ended at "
|
||||
f"{next_line - 1}, expected {AUDITED_FRONTEND_SOURCE_LINE_COUNT}"
|
||||
)
|
||||
return "".join(assembled)
|
||||
|
||||
|
||||
def original_runtime_after_audited_retirements() -> str:
|
||||
lines = (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8").splitlines(
|
||||
keepends=True
|
||||
)
|
||||
retired = {
|
||||
line_number
|
||||
for start, end in RETIRED_FRONTEND_SOURCE_RANGES
|
||||
for line_number in range(start, end + 1)
|
||||
}
|
||||
return "".join(
|
||||
line for line_number, line in enumerate(lines, start=1) if line_number not in retired
|
||||
)
|
||||
|
||||
|
||||
def assert_frontend_runtime_matches_audited_baseline(testcase) -> None:
|
||||
testcase.assertEqual(
|
||||
reassembled_frontend_runtime(),
|
||||
original_runtime_after_audited_retirements(),
|
||||
)
|
||||
|
||||
|
||||
def assert_moved_asset_matches(
|
||||
testcase,
|
||||
original_relative: str,
|
||||
frontend_relative: str | None = None,
|
||||
) -> None:
|
||||
target_relative = frontend_relative or original_relative
|
||||
testcase.assertEqual(
|
||||
sha256(FRONTEND_ROOT / target_relative),
|
||||
sha256(ORIGINAL_STATIC / original_relative),
|
||||
original_relative,
|
||||
)
|
||||
|
||||
|
||||
def assert_page_prefix_matches(testcase, page_relative: str) -> None:
|
||||
original = (ORIGINAL_STATIC / page_relative).read_text(encoding="utf-8")
|
||||
migrated = (FRONTEND_ROOT / page_relative).read_text(encoding="utf-8")
|
||||
testcase.assertTrue(
|
||||
migrated.startswith(original.rstrip("\n") + "\n\n"),
|
||||
page_relative,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from database import ReviewDatabase
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
FRONTEND_ROOT = APP_ROOT / "frontend"
|
||||
|
||||
|
||||
class CleanupContractTests(unittest.TestCase):
|
||||
def test_retired_files_stay_absent(self) -> None:
|
||||
self.assertFalse((APP_ROOT / "demo_data.py").exists())
|
||||
self.assertFalse((FRONTEND_ROOT / "heaven-loading.js").exists())
|
||||
|
||||
def test_only_active_heaven_loading_asset_is_loaded(self) -> None:
|
||||
html = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8")
|
||||
self.assertIn('src="/pages/heaven/loading-v2.js', html)
|
||||
self.assertNotIn('src="/heaven-loading.js', html)
|
||||
|
||||
def test_audited_definition_only_functions_stay_absent(self) -> None:
|
||||
runtime = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in FRONTEND_ROOT.rglob("*.js")
|
||||
if "vendor" not in path.parts
|
||||
)
|
||||
for symbol in (
|
||||
"commonReviewColumns",
|
||||
"outcomeClass",
|
||||
"screenerResultMatchesSelection",
|
||||
"selectRegime",
|
||||
"showHeartRitualCurtain",
|
||||
):
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertNotIn(symbol, runtime)
|
||||
|
||||
def test_wencai_history_compatibility_is_retained(self) -> None:
|
||||
for method in (
|
||||
"list_wencai_saved_queries",
|
||||
"save_wencai_query",
|
||||
"delete_wencai_saved_query",
|
||||
):
|
||||
with self.subTest(method=method):
|
||||
self.assertTrue(hasattr(ReviewDatabase, method))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,14 +6,14 @@ from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / "static"
|
||||
STATIC = ROOT / "frontend"
|
||||
TOKENS = STATIC / "shared" / "tokens.css"
|
||||
LEGACY_STYLESHEETS = (
|
||||
"styles.css",
|
||||
"renovation.css",
|
||||
"redesign-v2.css",
|
||||
"design-system.css",
|
||||
"theme.css",
|
||||
"styles/styles.css",
|
||||
"styles/renovation.css",
|
||||
"styles/redesign-v2.css",
|
||||
"styles/design-system.css",
|
||||
"styles/theme.css",
|
||||
)
|
||||
|
||||
|
||||
@@ -26,12 +26,12 @@ class CssGovernanceTests(unittest.TestCase):
|
||||
def test_token_layer_loads_before_application_styles(self) -> None:
|
||||
expected_order = (
|
||||
"/shared/tokens.css",
|
||||
"/styles.css",
|
||||
"/renovation.css",
|
||||
"/redesign-v2.css",
|
||||
"/design-system.css",
|
||||
"/theme.css",
|
||||
"/wentian-v2.css",
|
||||
"/styles/styles.css",
|
||||
"/styles/renovation.css",
|
||||
"/styles/redesign-v2.css",
|
||||
"/styles/design-system.css",
|
||||
"/styles/theme.css",
|
||||
"/pages/heaven/page.css",
|
||||
)
|
||||
positions = [self.html.index(path) for path in expected_order]
|
||||
self.assertEqual(positions, sorted(positions))
|
||||
@@ -63,7 +63,7 @@ class CssGovernanceTests(unittest.TestCase):
|
||||
|
||||
def test_wentian_tokens_remain_isolated(self) -> None:
|
||||
self.assertNotRegex(self.tokens, r"--wt-[a-z0-9-]+\s*:")
|
||||
wentian = (STATIC / "wentian-v2.css").read_text(encoding="utf-8")
|
||||
wentian = (STATIC / "pages" / "heaven" / "page.css").read_text(encoding="utf-8")
|
||||
self.assertRegex(wentian, r"--wt-[a-z0-9-]+\s*:")
|
||||
|
||||
def test_compatibility_aliases_cover_historical_layers(self) -> None:
|
||||
|
||||
@@ -4,10 +4,13 @@ import json
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.preservation_helpers import reassembled_frontend_runtime
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / "static"
|
||||
STATIC = ROOT / "frontend"
|
||||
|
||||
|
||||
class FrontendBoundaryTests(unittest.TestCase):
|
||||
@@ -22,7 +25,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
|
||||
def test_shared_dependencies_load_before_application(self) -> None:
|
||||
html = (STATIC / "index.html").read_text(encoding="utf-8")
|
||||
ui_position = html.index('/ui-core.js')
|
||||
ui_position = html.index('/shared/ui-core.js')
|
||||
components_position = html.index('/shared/components.js')
|
||||
pages_position = html.index('/pages.config.js')
|
||||
runtime_position = html.index('/pages/runtime.js')
|
||||
@@ -40,10 +43,18 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
self.assertLess(shell_position, app_position)
|
||||
|
||||
def test_application_state_is_created_through_shared_boundary(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
self.assertIn("const state = window.XiaobaiState.create({", app)
|
||||
self.assertNotIn("const state = {", app)
|
||||
|
||||
def test_candidate_runtime_reassembly_does_not_require_original_static(self) -> None:
|
||||
with patch(
|
||||
"tests.preservation_helpers.ORIGINAL_STATIC",
|
||||
ROOT / "missing-original-static",
|
||||
):
|
||||
app = reassembled_frontend_runtime()
|
||||
self.assertIn("function openView(", app)
|
||||
|
||||
def test_runtime_page_registry_matches_governance_registry(self) -> None:
|
||||
expected = json.loads(
|
||||
(ROOT / "config" / "pages.config.json").read_text(encoding="utf-8")
|
||||
@@ -70,7 +81,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_shell_owns_navigation_and_page_mounting(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
shell = (STATIC / "shared" / "shell.js").read_text(encoding="utf-8")
|
||||
self.assertNotIn("function syncNavigationState", app)
|
||||
self.assertNotIn("function initializeApplicationShell", app)
|
||||
@@ -109,7 +120,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_page_lifecycle_is_owned_outside_application_monolith(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
runtime = (STATIC / "pages" / "runtime.js").read_text(encoding="utf-8")
|
||||
start = app.index("function openView(")
|
||||
end = app.index("\nfunction initializeAutoTableSorting", start)
|
||||
@@ -122,7 +133,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
||||
|
||||
def test_shared_empty_state_component_is_used_by_multiple_features(self) -> None:
|
||||
components = (STATIC / "shared" / "components.js").read_text(encoding="utf-8")
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
app = reassembled_frontend_runtime()
|
||||
self.assertIn("function emptyStateHtml(message, options = {})", components)
|
||||
self.assertIn("function renderEmptyState(target, message, options = {})", components)
|
||||
self.assertGreaterEqual(app.count("renderEmptyState("), 8)
|
||||
|
||||
@@ -5,8 +5,10 @@ import unittest
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from tests.preservation_helpers import reassembled_frontend_runtime
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parents[1] / "static"
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parents[1] / "frontend"
|
||||
|
||||
|
||||
class IdCollector(HTMLParser):
|
||||
@@ -22,11 +24,11 @@ class FrontendContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
cls.script = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
|
||||
cls.script = reassembled_frontend_runtime()
|
||||
cls.shell = (STATIC_DIR / "shared" / "shell.js").read_text(encoding="utf-8")
|
||||
cls.ui_core = (STATIC_DIR / "ui-core.js").read_text(encoding="utf-8")
|
||||
cls.design_system = (STATIC_DIR / "design-system.css").read_text(encoding="utf-8")
|
||||
cls.theme = (STATIC_DIR / "theme.css").read_text(encoding="utf-8")
|
||||
cls.ui_core = (STATIC_DIR / "shared" / "ui-core.js").read_text(encoding="utf-8")
|
||||
cls.design_system = (STATIC_DIR / "styles" / "design-system.css").read_text(encoding="utf-8")
|
||||
cls.theme = (STATIC_DIR / "styles" / "theme.css").read_text(encoding="utf-8")
|
||||
cls.tokens = (STATIC_DIR / "shared" / "tokens.css").read_text(encoding="utf-8")
|
||||
collector = IdCollector()
|
||||
collector.feed(cls.html)
|
||||
@@ -158,7 +160,7 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn("必需数据已完整,本日没有股票同时满足", self.script)
|
||||
|
||||
def test_dialogs_and_dark_table_hover_have_shared_safety_constraints(self):
|
||||
redesign = (STATIC_DIR / "redesign-v2.css").read_text(encoding="utf-8")
|
||||
redesign = (STATIC_DIR / "styles" / "redesign-v2.css").read_text(encoding="utf-8")
|
||||
self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }", redesign)
|
||||
self.assertIn("max-height: min(760px, calc(100dvh - 28px));", redesign)
|
||||
self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.theme)
|
||||
@@ -166,8 +168,8 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.theme)
|
||||
|
||||
def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self):
|
||||
styles = (STATIC_DIR / "styles.css").read_text(encoding="utf-8")
|
||||
wentian = (STATIC_DIR / "wentian-v2.css").read_text(encoding="utf-8")
|
||||
styles = (STATIC_DIR / "styles" / "styles.css").read_text(encoding="utf-8")
|
||||
wentian = (STATIC_DIR / "pages" / "heaven" / "page.css").read_text(encoding="utf-8")
|
||||
self.assertIn("#toast.toast {", styles)
|
||||
self.assertIn("top: auto;", styles)
|
||||
self.assertIn("left: auto;", styles)
|
||||
@@ -183,7 +185,7 @@ class FrontendContractTests(unittest.TestCase):
|
||||
|
||||
def test_shared_ui_core_loads_before_application(self):
|
||||
self.assertLess(
|
||||
self.html.index('<script src="/ui-core.js"'),
|
||||
self.html.index('<script src="/shared/ui-core.js"'),
|
||||
self.html.index('<script src="/app.js'),
|
||||
)
|
||||
for function_name in (
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from server import DashboardService
|
||||
from tests.preservation_helpers import reassembled_frontend_runtime
|
||||
|
||||
|
||||
class SearchDatabaseStub:
|
||||
@@ -77,9 +78,9 @@ class GlobalSearchTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_frontend_reuses_full_stock_detail_and_renders_market_daily_k(self):
|
||||
static_dir = Path(__file__).resolve().parents[1] / "static"
|
||||
static_dir = Path(__file__).resolve().parents[1] / "frontend"
|
||||
html = (static_dir / "index.html").read_text(encoding="utf-8")
|
||||
script = (static_dir / "app.js").read_text(encoding="utf-8")
|
||||
script = reassembled_frontend_runtime()
|
||||
|
||||
self.assertIn('id="globalSearchButton"', html)
|
||||
self.assertIn('id="globalSearchDialog"', html)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from backend.application import (
|
||||
AUTHENTICATED_POST_HANDLERS,
|
||||
PUBLIC_POST_HANDLERS,
|
||||
RequestHandler,
|
||||
)
|
||||
|
||||
|
||||
class HttpDispatchContractTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def handler(path: str, calls: list[str]) -> RequestHandler:
|
||||
handler = RequestHandler.__new__(RequestHandler)
|
||||
handler.path = path
|
||||
handler.require_auth = lambda: calls.append("auth") or True
|
||||
handler.require_csrf = lambda: calls.append("csrf") or True
|
||||
handler.require_access = lambda method, route: (
|
||||
calls.append(f"access:{method}:{route}") or True
|
||||
)
|
||||
return handler
|
||||
|
||||
def test_named_handlers_are_real_registered_post_routes(self) -> None:
|
||||
all_handlers = {**PUBLIC_POST_HANDLERS, **AUTHENTICATED_POST_HANDLERS}
|
||||
self.assertEqual(
|
||||
set(PUBLIC_POST_HANDLERS) & set(AUTHENTICATED_POST_HANDLERS), set()
|
||||
)
|
||||
for path, handler_name in all_handlers.items():
|
||||
with self.subTest(path=path):
|
||||
route = RequestHandler.route_registry.resolve("POST", path)
|
||||
self.assertIsNotNone(route)
|
||||
self.assertTrue(callable(getattr(RequestHandler, handler_name)))
|
||||
expected_access = "public" if path in PUBLIC_POST_HANDLERS else None
|
||||
if expected_access:
|
||||
self.assertEqual(route.access, expected_access)
|
||||
else:
|
||||
self.assertNotEqual(route.access, "public")
|
||||
|
||||
def test_public_post_dispatches_without_authentication(self) -> None:
|
||||
for path, handler_name in PUBLIC_POST_HANDLERS.items():
|
||||
with self.subTest(path=path):
|
||||
calls: list[str] = []
|
||||
handler = self.handler(path, calls)
|
||||
handler.require_auth = lambda: (_ for _ in ()).throw(
|
||||
AssertionError("public route required authentication")
|
||||
)
|
||||
setattr(handler, handler_name, lambda: calls.append(handler_name))
|
||||
|
||||
RequestHandler.do_POST(handler)
|
||||
|
||||
self.assertEqual(calls, [handler_name])
|
||||
|
||||
def test_authenticated_post_preserves_guard_order(self) -> None:
|
||||
for path, handler_name in AUTHENTICATED_POST_HANDLERS.items():
|
||||
with self.subTest(path=path):
|
||||
calls: list[str] = []
|
||||
handler = self.handler(path, calls)
|
||||
setattr(handler, handler_name, lambda: calls.append(handler_name))
|
||||
|
||||
RequestHandler.do_POST(handler)
|
||||
|
||||
self.assertEqual(
|
||||
calls,
|
||||
["auth", "csrf", f"access:POST:{path}", handler_name],
|
||||
)
|
||||
|
||||
def test_failed_access_never_dispatches_protected_handler(self) -> None:
|
||||
path, handler_name = next(iter(AUTHENTICATED_POST_HANDLERS.items()))
|
||||
calls: list[str] = []
|
||||
handler = self.handler(path, calls)
|
||||
handler.require_access = lambda method, route: (
|
||||
calls.append(f"access:{method}:{route}") or False
|
||||
)
|
||||
setattr(handler, handler_name, lambda: calls.append(handler_name))
|
||||
|
||||
RequestHandler.do_POST(handler)
|
||||
|
||||
self.assertEqual(calls, ["auth", "csrf", f"access:POST:{path}"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from assistant_agent import ReviewAssistantError, stream_review_assistant
|
||||
from backend.llm import transport
|
||||
from heaven_agent import HeavenAgentError, interpret_heaven
|
||||
from llm_strategy import LLMCompilerError, test_llm_connection
|
||||
from mentor_agent import MentorAgentError, MentorSkill, stream_with_mentor
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, *, payload: bytes = b"", lines: list[bytes] | None = None) -> None:
|
||||
self.payload = payload
|
||||
self.lines = lines or []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
return False
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self.payload
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.lines)
|
||||
|
||||
|
||||
class OpenAITransportTests(unittest.TestCase):
|
||||
def test_chat_completion_builds_one_openai_compatible_request(self) -> None:
|
||||
captured = {}
|
||||
response = FakeResponse(
|
||||
payload=json.dumps(
|
||||
{"choices": [{"message": {"content": "OK"}}]}
|
||||
).encode("utf-8")
|
||||
)
|
||||
|
||||
def open_request(request, timeout):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = request.headers
|
||||
captured["payload"] = json.loads(request.data.decode("utf-8"))
|
||||
captured["timeout"] = timeout
|
||||
return response
|
||||
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", side_effect=open_request):
|
||||
result = transport.chat_completion(
|
||||
api_key="secret",
|
||||
base_url="https://example.test/v1/",
|
||||
model="model",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
timeout=17,
|
||||
user_agent="XiaobaiReviewWeb/test",
|
||||
)
|
||||
|
||||
self.assertEqual(result.content, "OK")
|
||||
self.assertGreaterEqual(result.latency_ms, 0)
|
||||
self.assertEqual(captured["url"], "https://example.test/v1/chat/completions")
|
||||
self.assertEqual(captured["payload"]["stream"], False)
|
||||
self.assertEqual(captured["headers"]["Authorization"], "Bearer secret")
|
||||
self.assertEqual(captured["timeout"], 17)
|
||||
|
||||
def test_stream_completion_parses_deltas_and_ignores_final_snapshot(self) -> None:
|
||||
response = FakeResponse(
|
||||
lines=[
|
||||
b'data: {"choices":[{"delta":{"content":"first"}}]}\n',
|
||||
b'data: {"choices":[{"delta":{"content":" second"}}]}\n',
|
||||
b'data: {"choices":[{"message":{"content":"first second"}}]}\n',
|
||||
b"data: [DONE]\n",
|
||||
]
|
||||
)
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
|
||||
chunks = list(
|
||||
transport.stream_chat_completion(
|
||||
api_key="secret",
|
||||
base_url="https://example.test/v1",
|
||||
model="model",
|
||||
messages=[],
|
||||
timeout=17,
|
||||
user_agent="XiaobaiReviewWeb/test",
|
||||
)
|
||||
)
|
||||
self.assertEqual(chunks, ["first", " second"])
|
||||
|
||||
def test_empty_stream_has_a_stable_transport_error(self) -> None:
|
||||
response = FakeResponse(lines=[b"data: [DONE]\n"])
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", return_value=response):
|
||||
with self.assertRaises(transport.OpenAIEmptyResponseError):
|
||||
list(
|
||||
transport.stream_chat_completion(
|
||||
api_key="secret",
|
||||
base_url="https://example.test/v1",
|
||||
model="model",
|
||||
messages=[],
|
||||
timeout=17,
|
||||
user_agent="XiaobaiReviewWeb/test",
|
||||
)
|
||||
)
|
||||
|
||||
def test_http_error_keeps_code_and_sanitized_provider_detail(self) -> None:
|
||||
error = urllib.error.HTTPError(
|
||||
"https://example.test/v1/chat/completions",
|
||||
429,
|
||||
"rate limited",
|
||||
{},
|
||||
io.BytesIO(b'{"error":{"message":"capacity"}}'),
|
||||
)
|
||||
self.addCleanup(error.close)
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", side_effect=error):
|
||||
with self.assertRaises(transport.OpenAIHTTPError) as caught:
|
||||
transport.chat_completion(
|
||||
api_key="secret",
|
||||
base_url="https://example.test/v1",
|
||||
model="model",
|
||||
messages=[],
|
||||
timeout=17,
|
||||
user_agent="XiaobaiReviewWeb/test",
|
||||
)
|
||||
self.assertEqual(caught.exception.code, 429)
|
||||
self.assertEqual(
|
||||
caught.exception.describe("模型调用失败"),
|
||||
"模型调用失败(HTTP 429):capacity",
|
||||
)
|
||||
|
||||
def test_feature_agents_have_no_direct_provider_transport(self) -> None:
|
||||
paths = (
|
||||
"backend/features/mentor/agent.py",
|
||||
"backend/features/heaven/agent.py",
|
||||
"backend/features/review/agent.py",
|
||||
"backend/features/screener/compiler.py",
|
||||
)
|
||||
for relative in paths:
|
||||
source = (ROOT / relative).read_text(encoding="utf-8")
|
||||
with self.subTest(path=relative):
|
||||
self.assertNotIn("urllib.request", source)
|
||||
self.assertNotIn("/chat/completions", source)
|
||||
self.assertIn("llm_transport.", source)
|
||||
|
||||
|
||||
class FeatureErrorMappingTests(unittest.TestCase):
|
||||
def test_feature_specific_http_messages_are_preserved(self) -> None:
|
||||
error = transport.OpenAIHTTPError(429, "capacity")
|
||||
skill = MentorSkill(
|
||||
skill_id="test",
|
||||
name="测试老师",
|
||||
description="",
|
||||
tagline="",
|
||||
focus=(),
|
||||
content="",
|
||||
path=Path("SKILL.md"),
|
||||
)
|
||||
with patch(
|
||||
"mentor_agent.llm_transport.stream_chat_completion", side_effect=error
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
MentorAgentError, "问师模型调用失败(HTTP 429):capacity"
|
||||
):
|
||||
list(stream_with_mentor(skill, {}, "问题", [], "key", "https://x", "m"))
|
||||
with patch("heaven_agent.llm_transport.chat_completion", side_effect=error):
|
||||
with self.assertRaisesRegex(
|
||||
HeavenAgentError, "问天模型调用失败(HTTP 429):capacity"
|
||||
):
|
||||
interpret_heaven("heart", {}, "key", "https://x", "m")
|
||||
with patch(
|
||||
"assistant_agent.llm_transport.stream_chat_completion", side_effect=error
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
ReviewAssistantError, "智能解读服务暂不可用(429)"
|
||||
):
|
||||
list(stream_review_assistant({}, "问题", [], "key", "https://x", "m"))
|
||||
with patch("llm_strategy.llm_transport.chat_completion", side_effect=error):
|
||||
with self.assertRaisesRegex(
|
||||
LLMCompilerError, "模型连接测试失败(HTTP 429):capacity"
|
||||
):
|
||||
test_llm_connection("key", "https://x", "m")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tools.build_api_registry import build as build_api_registry
|
||||
from tools.build_architecture_inventory import (
|
||||
build as build_architecture_inventory,
|
||||
source_metrics,
|
||||
)
|
||||
from tools.verify_baseline import python_test_command
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class MaintenanceToolTests(unittest.TestCase):
|
||||
def test_generated_candidate_registries_are_current(self) -> None:
|
||||
expected_api = json.loads(
|
||||
(ROOT / "config" / "api.config.json").read_text(encoding="utf-8")
|
||||
)
|
||||
expected_architecture = json.loads(
|
||||
(ROOT / "config" / "architecture-inventory.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
self.assertEqual(expected_api, build_api_registry())
|
||||
self.assertEqual(expected_architecture, build_architecture_inventory())
|
||||
|
||||
def test_baseline_verifier_uses_the_candidate_frontend(self) -> None:
|
||||
source = (ROOT / "tools" / "verify_baseline.py").read_text(encoding="utf-8")
|
||||
self.assertIn('FRONTEND_ROOT = ROOT / "frontend"', source)
|
||||
self.assertIn('FRONTEND_ROOT.rglob("*.js")', source)
|
||||
self.assertIn("start_e2e_server()", source)
|
||||
self.assertIn("stop_e2e_server(server)", source)
|
||||
self.assertNotIn('"static/app.js"', source)
|
||||
|
||||
def test_standalone_verifier_excludes_only_migration_comparison_modules(self) -> None:
|
||||
repository_command = python_test_command(preservation_baseline=True)
|
||||
standalone_command = python_test_command(preservation_baseline=False)
|
||||
self.assertIn("discover", repository_command)
|
||||
self.assertTrue(any(item == "tests.test_frontend_contract" for item in standalone_command))
|
||||
self.assertFalse(any("test_preservation_" in item for item in standalone_command))
|
||||
|
||||
def test_architecture_metrics_are_independent_of_checkout_line_endings(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
lf = root / "lf.js"
|
||||
crlf = root / "crlf.js"
|
||||
lf.write_bytes(b"const a = 1;\nconst b = 2;\n")
|
||||
crlf.write_bytes(b"const a = 1;\r\nconst b = 2;\r\n")
|
||||
self.assertEqual(source_metrics(lf), source_metrics(crlf))
|
||||
|
||||
def test_every_tool_has_a_non_mutating_help_path(self) -> None:
|
||||
for path in sorted((ROOT / "tools").glob("*.py")):
|
||||
if path.name.startswith("_"):
|
||||
continue
|
||||
with self.subTest(tool=path.name):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(path), "--help"],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_migration_only_tools_are_explicitly_classified(self) -> None:
|
||||
readme = (ROOT / "tools" / "README.md").read_text(encoding="utf-8")
|
||||
for name in (
|
||||
"build_preservation_manifest.py",
|
||||
"move_class_methods.py",
|
||||
"split_frontend_runtime.py",
|
||||
):
|
||||
self.assertIn(name, readme)
|
||||
manifest = (ROOT / "tools" / "build_preservation_manifest.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertNotIn('TARGET = ROOT / "app"', manifest)
|
||||
self.assertIn('required=True', manifest)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -9,11 +9,11 @@ from tushare_client import _sector_coverage_issue
|
||||
|
||||
|
||||
def load_method(name: str):
|
||||
source = Path("backend/application.py").read_text(encoding="utf-8")
|
||||
source = Path("backend/features/heaven/service.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
dashboard_service = next(
|
||||
node for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "DashboardService"
|
||||
if isinstance(node, ast.ClassDef) and node.name == "HeavenServiceMixin"
|
||||
)
|
||||
method = next(
|
||||
node for node in dashboard_service.body
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from backend.bootstrap.config import tushare_code
|
||||
from backend.features.market import charts
|
||||
|
||||
|
||||
class MarketSymbolNormalizationTests(unittest.TestCase):
|
||||
def test_chart_and_market_services_share_one_suffix_converter(self) -> None:
|
||||
self.assertIs(charts._stock_market_code, tushare_code)
|
||||
|
||||
def test_existing_exchange_mapping_is_preserved(self) -> None:
|
||||
cases = {
|
||||
"000001": "000001.SZ",
|
||||
"600000": "600000.SH",
|
||||
"430047": "430047.BJ",
|
||||
"830799": "830799.BJ",
|
||||
}
|
||||
for code, expected in cases.items():
|
||||
with self.subTest(code=code):
|
||||
self.assertEqual(charts._stock_market_code(code), expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -44,7 +44,7 @@ class MentorStreamTests(unittest.TestCase):
|
||||
captured["accept"] = request.headers.get("Accept")
|
||||
return FakeStreamResponse(self.lines)
|
||||
|
||||
with patch("mentor_agent.urllib.request.urlopen", side_effect=open_request):
|
||||
with patch("backend.llm.transport.urllib.request.urlopen", side_effect=open_request):
|
||||
chunks = list(
|
||||
stream_with_mentor(
|
||||
self.skill, {"data_trade_date": "20260723"}, "怎么看?", [],
|
||||
@@ -58,7 +58,7 @@ class MentorStreamTests(unittest.TestCase):
|
||||
|
||||
def test_non_streaming_compatibility_wrapper_collects_chunks(self):
|
||||
with patch(
|
||||
"mentor_agent.urllib.request.urlopen",
|
||||
"backend.llm.transport.urllib.request.urlopen",
|
||||
return_value=FakeStreamResponse(self.lines),
|
||||
):
|
||||
result = chat_with_mentor(
|
||||
@@ -74,7 +74,7 @@ class MentorStreamTests(unittest.TestCase):
|
||||
b"data: [DONE]\n",
|
||||
]
|
||||
with patch(
|
||||
"mentor_agent.urllib.request.urlopen",
|
||||
"backend.llm.transport.urllib.request.urlopen",
|
||||
return_value=FakeStreamResponse(lines),
|
||||
):
|
||||
chunks = list(
|
||||
@@ -92,7 +92,7 @@ class MentorStreamTests(unittest.TestCase):
|
||||
b"data: [DONE]\n",
|
||||
]
|
||||
with patch(
|
||||
"mentor_agent.urllib.request.urlopen",
|
||||
"backend.llm.transport.urllib.request.urlopen",
|
||||
return_value=FakeStreamResponse(lines),
|
||||
):
|
||||
chunks = list(
|
||||
|
||||
@@ -23,7 +23,7 @@ class AccountSliceStructureTests(unittest.TestCase):
|
||||
|
||||
def test_runtime_paths_still_point_at_app_root(self) -> None:
|
||||
self.assertEqual(APP_DIR, Path(__file__).resolve().parents[1])
|
||||
self.assertEqual(STATIC_DIR, APP_DIR / "static")
|
||||
self.assertEqual(STATIC_DIR, APP_DIR / "frontend")
|
||||
|
||||
def test_account_persistence_and_http_transport_have_single_owners(self) -> None:
|
||||
for method in (
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from backend.bootstrap.config import APP_DIR, STATIC_DIR
|
||||
from tests.preservation_helpers import (
|
||||
FRONTEND_ROOT,
|
||||
ORIGINAL_STATIC,
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
)
|
||||
|
||||
|
||||
class FrontendPreservationSliceTests(unittest.TestCase):
|
||||
def test_split_runtime_matches_original_except_audited_retirements(self) -> None:
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
|
||||
def test_index_diff_is_limited_to_asset_relocation_and_split_loading(self) -> None:
|
||||
migrated = (FRONTEND_ROOT / "index.html").read_text(encoding="utf-8")
|
||||
restored = migrated
|
||||
for current, original in (
|
||||
("/styles/styles.css", "/styles.css"),
|
||||
("/styles/renovation.css", "/renovation.css"),
|
||||
("/styles/redesign-v2.css", "/redesign-v2.css"),
|
||||
("/styles/design-system.css", "/design-system.css"),
|
||||
("/styles/theme.css", "/theme.css"),
|
||||
("/pages/heaven/page.css", "/wentian-v2.css"),
|
||||
("/shared/ui-core.js", "/ui-core.js"),
|
||||
("/pages/heaven/loading-v2.js", "/heaven-loading-v2.js"),
|
||||
):
|
||||
restored = restored.replace(current, original)
|
||||
restored = restored.replace(
|
||||
' <script src="/pages/market/runtime.js?v=20260731-1" defer></script>\n',
|
||||
"",
|
||||
)
|
||||
restored = restored.replace(
|
||||
' <script src="/shared/export.js?v=20260731-1" defer></script>\n',
|
||||
"",
|
||||
)
|
||||
self.assertEqual(
|
||||
restored,
|
||||
(ORIGINAL_STATIC / "index.html").read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_complete_stylesheet_stack_is_byte_identical_after_relocation(self) -> None:
|
||||
for original, migrated in (
|
||||
("shared/tokens.css", "shared/tokens.css"),
|
||||
("styles.css", "styles/styles.css"),
|
||||
("renovation.css", "styles/renovation.css"),
|
||||
("redesign-v2.css", "styles/redesign-v2.css"),
|
||||
("design-system.css", "styles/design-system.css"),
|
||||
("theme.css", "styles/theme.css"),
|
||||
("wentian-v2.css", "pages/heaven/page.css"),
|
||||
):
|
||||
with self.subTest(asset=original):
|
||||
assert_moved_asset_matches(self, original, migrated)
|
||||
|
||||
def test_shared_vendor_and_animation_assets_are_byte_identical(self) -> None:
|
||||
for original, migrated in (
|
||||
("ui-core.js", "shared/ui-core.js"),
|
||||
("heaven-loading-v2.js", "pages/heaven/loading-v2.js"),
|
||||
("vendor/lucide.min.js", "vendor/lucide.min.js"),
|
||||
("pages.config.js", "pages.config.js"),
|
||||
("pages/runtime.js", "pages/runtime.js"),
|
||||
("shared/api.js", "shared/api.js"),
|
||||
("shared/components.js", "shared/components.js"),
|
||||
("shared/shell.js", "shared/shell.js"),
|
||||
("shared/state.js", "shared/state.js"),
|
||||
):
|
||||
with self.subTest(asset=original):
|
||||
assert_moved_asset_matches(self, original, migrated)
|
||||
|
||||
def test_original_page_registration_prefixes_are_preserved(self) -> None:
|
||||
for path in sorted((ORIGINAL_STATIC / "pages").glob("*/page.js")):
|
||||
relative = path.relative_to(ORIGINAL_STATIC).as_posix()
|
||||
with self.subTest(page=relative):
|
||||
assert_page_prefix_matches(self, relative)
|
||||
|
||||
def test_frontend_is_the_only_served_static_root(self) -> None:
|
||||
self.assertEqual(STATIC_DIR, APP_DIR / "frontend")
|
||||
self.assertFalse((APP_DIR / "static").exists())
|
||||
|
||||
def test_shared_api_remains_the_only_fetch_exit(self) -> None:
|
||||
consumers = []
|
||||
for path in FRONTEND_ROOT.rglob("*.js"):
|
||||
if "vendor" in path.parts:
|
||||
continue
|
||||
if re.search(r"\bfetch\s*\(", path.read_text(encoding="utf-8")):
|
||||
consumers.append(path.relative_to(FRONTEND_ROOT).as_posix())
|
||||
self.assertEqual(consumers, ["shared/api.js"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import heaven_agent
|
||||
import heaven_engine
|
||||
from backend.features.heaven import agent as canonical_agent
|
||||
from backend.features.heaven import engine as canonical_engine
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
ORIGINAL_ROOT = APP_ROOT.parent
|
||||
|
||||
HEAVEN_SERVICE_METHODS = {
|
||||
"_heaven_manual_schema",
|
||||
"_validate_heaven_manual_data",
|
||||
"_apply_heaven_manual_data",
|
||||
"_heaven_line_checks",
|
||||
"_resolve_heaven_stock_code",
|
||||
"heaven_setup",
|
||||
"_heaven_stock_context",
|
||||
"_heaven_market_mode",
|
||||
"_heaven_trend_sources",
|
||||
"_heaven_trend_quality_issues",
|
||||
"heaven_personal",
|
||||
"heaven_hexagram",
|
||||
"heaven_readings",
|
||||
"_heaven_reading_identity",
|
||||
"heaven_interpret",
|
||||
"_legacy_truncated_heaven_reading",
|
||||
"_call_heaven_agent",
|
||||
"_heaven_index_context",
|
||||
"_aggregate_index_context",
|
||||
"_heaven_sector_context",
|
||||
}
|
||||
|
||||
HEAVEN_REPOSITORY_METHODS = {
|
||||
"_heaven_reading_dict",
|
||||
"save_heaven_reading",
|
||||
"list_heaven_readings",
|
||||
"latest_heaven_reading",
|
||||
"delete_heaven_reading",
|
||||
}
|
||||
|
||||
HEAVEN_HTTP_METHODS = {"heaven_hexagram", "heaven_personal", "heaven_interpret"}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def class_methods(path: Path, class_name: str) -> dict[str, str]:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
owner = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name
|
||||
)
|
||||
return {
|
||||
node.name: ast.dump(node, include_attributes=False)
|
||||
for node in owner.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
|
||||
|
||||
def top_level_definitions(path: Path) -> dict[str, str]:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
return {
|
||||
node.name: ast.dump(node, include_attributes=False)
|
||||
for node in tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
||||
}
|
||||
|
||||
|
||||
class HeavenSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
def assert_methods_equal(
|
||||
self,
|
||||
original_path: Path,
|
||||
original_class: str,
|
||||
migrated_path: Path,
|
||||
migrated_class: str,
|
||||
expected: set[str],
|
||||
adapted: set[str] | None = None,
|
||||
) -> None:
|
||||
original = class_methods(original_path, original_class)
|
||||
migrated = class_methods(migrated_path, migrated_class)
|
||||
self.assertEqual(set(migrated), expected)
|
||||
for name in sorted(expected - (adapted or set())):
|
||||
self.assertEqual(migrated[name], original[name], name)
|
||||
|
||||
def test_heaven_agent_uses_shared_transport(self) -> None:
|
||||
source = (
|
||||
APP_ROOT / "backend" / "features" / "heaven" / "agent.py"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("llm_transport.chat_completion", source)
|
||||
self.assertNotIn("urllib.request", source)
|
||||
|
||||
def test_heaven_engine_definitions_are_exact_original_ast(self) -> None:
|
||||
self.assertEqual(
|
||||
top_level_definitions(ORIGINAL_ROOT / "heaven_engine.py"),
|
||||
top_level_definitions(
|
||||
APP_ROOT / "backend" / "features" / "heaven" / "engine.py"
|
||||
),
|
||||
)
|
||||
|
||||
def test_compatibility_modules_are_canonical_module_objects(self) -> None:
|
||||
self.assertIs(heaven_agent, canonical_agent)
|
||||
self.assertIs(heaven_engine, canonical_engine)
|
||||
|
||||
def test_heaven_service_methods_are_exact_original_ast(self) -> None:
|
||||
self.assert_methods_equal(
|
||||
ORIGINAL_ROOT / "server.py",
|
||||
"DashboardService",
|
||||
APP_ROOT / "backend" / "features" / "heaven" / "service.py",
|
||||
"HeavenServiceMixin",
|
||||
HEAVEN_SERVICE_METHODS,
|
||||
{"_heaven_reading_identity"},
|
||||
)
|
||||
source = (
|
||||
APP_ROOT / "backend" / "features" / "heaven" / "service.py"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
"MarketServiceMixin._display_compact_date(context_date)", source
|
||||
)
|
||||
|
||||
def test_heaven_repository_methods_are_exact_original_ast(self) -> None:
|
||||
self.assert_methods_equal(
|
||||
ORIGINAL_ROOT / "database.py",
|
||||
"ReviewDatabase",
|
||||
APP_ROOT / "backend" / "features" / "heaven" / "repository.py",
|
||||
"HeavenRepositoryMixin",
|
||||
HEAVEN_REPOSITORY_METHODS,
|
||||
)
|
||||
|
||||
def test_original_classes_no_longer_duplicate_moved_methods(self) -> None:
|
||||
remaining_service = class_methods(
|
||||
APP_ROOT / "backend" / "application.py", "DashboardService"
|
||||
)
|
||||
remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
|
||||
remaining_http = class_methods(
|
||||
APP_ROOT / "backend" / "application.py", "RequestHandler"
|
||||
)
|
||||
self.assertTrue(HEAVEN_SERVICE_METHODS.isdisjoint(remaining_service))
|
||||
self.assertTrue(HEAVEN_REPOSITORY_METHODS.isdisjoint(remaining_database))
|
||||
self.assertTrue(HEAVEN_HTTP_METHODS.isdisjoint(remaining_http))
|
||||
|
||||
def test_http_mixin_preserves_all_heaven_endpoints(self) -> None:
|
||||
methods = class_methods(
|
||||
APP_ROOT / "backend" / "features" / "heaven" / "http.py",
|
||||
"HeavenHttpMixin",
|
||||
)
|
||||
self.assertEqual(set(methods), HEAVEN_HTTP_METHODS)
|
||||
source = (
|
||||
APP_ROOT / "backend" / "features" / "heaven" / "http.py"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertNotIn("SERVICE.", source)
|
||||
self.assertEqual(source.count("self.application_service.heaven_"), 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,6 +5,12 @@ import hashlib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tests.preservation_helpers import (
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
ORIGINAL_ROOT = APP_ROOT.parent
|
||||
@@ -73,19 +79,14 @@ class LadderRotationSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"config/api.config.json",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/ladder/page.js",
|
||||
"static/pages/rotation/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "config/api.config.json"),
|
||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||
)
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
for page in ("pages/ladder/page.js", "pages/rotation/page.js"):
|
||||
assert_page_prefix_matches(self, page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -9,10 +9,15 @@ import chart_data_provider
|
||||
import ifind_client
|
||||
import realtime_aggregator
|
||||
import tushare_client
|
||||
from backend.bootstrap import config as bootstrap_config
|
||||
from backend.data import realtime
|
||||
from backend.data.providers import ifind_client as canonical_ifind
|
||||
from backend.data.providers import tushare_client as canonical_tushare
|
||||
from backend.features.market import charts
|
||||
from tests.preservation_helpers import (
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -97,6 +102,18 @@ def top_level_definitions(path: Path) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def function_contract(path: Path, name: str) -> tuple[str, str]:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
function = next(
|
||||
node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name
|
||||
)
|
||||
body = ast.Module(body=function.body, type_ignores=[])
|
||||
return (
|
||||
ast.dump(function.args, include_attributes=False),
|
||||
ast.dump(body, include_attributes=False),
|
||||
)
|
||||
|
||||
|
||||
class MarketSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
def test_market_service_methods_are_exact_original_ast(self) -> None:
|
||||
original = class_methods(ORIGINAL_ROOT / "server.py", "DashboardService")
|
||||
@@ -141,26 +158,32 @@ class MarketSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
top_level_definitions(ORIGINAL_ROOT / "tushare_client.py"),
|
||||
top_level_definitions(APP_ROOT / "backend/data/providers/tushare_client.py"),
|
||||
)
|
||||
original_charts = top_level_definitions(ORIGINAL_ROOT / "chart_data_provider.py")
|
||||
original_charts.pop("_stock_market_code")
|
||||
self.assertEqual(
|
||||
top_level_definitions(ORIGINAL_ROOT / "chart_data_provider.py"),
|
||||
original_charts,
|
||||
top_level_definitions(APP_ROOT / "backend/features/market/charts.py"),
|
||||
)
|
||||
self.assertEqual(
|
||||
function_contract(
|
||||
ORIGINAL_ROOT / "chart_data_provider.py", "_stock_market_code"
|
||||
),
|
||||
function_contract(
|
||||
APP_ROOT / "backend/bootstrap/config.py", "tushare_code"
|
||||
),
|
||||
)
|
||||
self.assertIs(charts._stock_market_code, bootstrap_config.tushare_code)
|
||||
|
||||
def test_unchanged_frontend_assets_match_the_original(self) -> None:
|
||||
for relative in (
|
||||
"index.html",
|
||||
"app.js",
|
||||
"styles.css",
|
||||
"renovation.css",
|
||||
"redesign-v2.css",
|
||||
"theme.css",
|
||||
"wentian-v2.css",
|
||||
def test_relocated_frontend_preserves_original_market_runtime_and_styles(self) -> None:
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
for original, migrated in (
|
||||
("styles.css", "styles/styles.css"),
|
||||
("renovation.css", "styles/renovation.css"),
|
||||
("redesign-v2.css", "styles/redesign-v2.css"),
|
||||
("theme.css", "styles/theme.css"),
|
||||
("wentian-v2.css", "pages/heaven/page.css"),
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "static" / relative),
|
||||
sha256(ORIGINAL_ROOT / "static" / relative),
|
||||
relative,
|
||||
)
|
||||
assert_moved_asset_matches(self, original, migrated)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,6 +7,11 @@ from pathlib import Path
|
||||
|
||||
import market_insights
|
||||
from backend.features.market import insights as canonical_insights
|
||||
from tests.preservation_helpers import (
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -174,21 +179,19 @@ class MarketInsightsSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
self.assertEqual(migrated[name], original[name], name)
|
||||
|
||||
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"config/api.config.json",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/auction/page.js",
|
||||
"static/pages/themes/page.js",
|
||||
"static/pages/popularity/page.js",
|
||||
"static/pages/dragon-tiger/page.js",
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "config/api.config.json"),
|
||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||
)
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
for page in (
|
||||
"pages/auction/page.js",
|
||||
"pages/themes/page.js",
|
||||
"pages/popularity/page.js",
|
||||
"pages/dragon-tiger/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
assert_page_prefix_matches(self, page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -105,11 +105,12 @@ class MentorLLMSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
for name in sorted(expected):
|
||||
self.assertEqual(migrated[name], original[name], name)
|
||||
|
||||
def test_mentor_agent_and_stream_accumulator_are_exact_files(self) -> None:
|
||||
self.assertEqual(
|
||||
sha256(ORIGINAL_ROOT / "mentor_agent.py"),
|
||||
sha256(APP_ROOT / "backend" / "features" / "mentor" / "agent.py"),
|
||||
def test_mentor_agent_uses_shared_transport_and_stream_accumulator_is_exact(self) -> None:
|
||||
mentor_source = (APP_ROOT / "backend" / "features" / "mentor" / "agent.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("llm_transport.stream_chat_completion", mentor_source)
|
||||
self.assertNotIn("urllib.request", mentor_source)
|
||||
self.assertEqual(
|
||||
sha256(ORIGINAL_ROOT / "llm_stream.py"),
|
||||
sha256(APP_ROOT / "backend" / "llm" / "stream.py"),
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import assistant_agent
|
||||
from backend.features.review import agent as canonical_agent
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
ORIGINAL_ROOT = APP_ROOT.parent
|
||||
|
||||
ALERT_SERVICE_METHODS = {
|
||||
"alert_center",
|
||||
"create_alert",
|
||||
"mark_alert_read",
|
||||
"mark_all_alerts_read",
|
||||
"delete_alert",
|
||||
}
|
||||
|
||||
REVIEW_SERVICE_METHODS = {
|
||||
"trade_entries",
|
||||
"review_watchlist",
|
||||
"save_trade_entry",
|
||||
"delete_trade_entry",
|
||||
"assistant_messages",
|
||||
"clear_assistant_messages",
|
||||
"assistant_stream",
|
||||
"_assistant_context",
|
||||
}
|
||||
|
||||
ALERT_REPOSITORY_METHODS = {
|
||||
"save_alert",
|
||||
"list_alerts",
|
||||
"count_unread_alerts",
|
||||
"mark_alert_read",
|
||||
"mark_all_alerts_read",
|
||||
"delete_alert",
|
||||
}
|
||||
|
||||
REVIEW_REPOSITORY_METHODS = {
|
||||
"list_watchlist",
|
||||
"save_watchlist",
|
||||
"watchlist_price_history",
|
||||
"delete_watchlist",
|
||||
"list_notes",
|
||||
"save_note",
|
||||
"delete_note",
|
||||
"save_trade_entry",
|
||||
"list_trade_entries",
|
||||
"delete_trade_entry",
|
||||
"save_assistant_exchange",
|
||||
"list_assistant_messages",
|
||||
"delete_assistant_messages",
|
||||
}
|
||||
|
||||
ALERT_HTTP_METHODS = {"save_alert"}
|
||||
REVIEW_HTTP_METHODS = {
|
||||
"save_trade_entry",
|
||||
"stream_assistant_chat",
|
||||
"save_watchlist",
|
||||
"save_note",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def class_methods(path: Path, class_name: str) -> dict[str, str]:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
owner = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name
|
||||
)
|
||||
return {
|
||||
node.name: ast.dump(node, include_attributes=False)
|
||||
for node in owner.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
|
||||
|
||||
def top_level_definition(path: Path, name: str) -> str:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
node = next(
|
||||
item
|
||||
for item in tree.body
|
||||
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
||||
and item.name == name
|
||||
)
|
||||
return ast.dump(node, include_attributes=False)
|
||||
|
||||
|
||||
class ReviewAlertsSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
def assert_methods_equal(
|
||||
self,
|
||||
original_path: Path,
|
||||
original_class: str,
|
||||
migrated_path: Path,
|
||||
migrated_class: str,
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
original = class_methods(original_path, original_class)
|
||||
migrated = class_methods(migrated_path, migrated_class)
|
||||
self.assertEqual(set(migrated), expected)
|
||||
for name in sorted(expected):
|
||||
self.assertEqual(migrated[name], original[name], name)
|
||||
|
||||
def test_review_assistant_agent_uses_shared_transport(self) -> None:
|
||||
source = (
|
||||
APP_ROOT / "backend" / "features" / "review" / "agent.py"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("llm_transport.stream_chat_completion", source)
|
||||
self.assertNotIn("urllib.request", source)
|
||||
|
||||
def test_review_assistant_compatibility_module_is_canonical(self) -> None:
|
||||
self.assertIs(assistant_agent, canonical_agent)
|
||||
|
||||
def test_alert_and_review_service_methods_are_exact_original_ast(self) -> None:
|
||||
self.assert_methods_equal(
|
||||
ORIGINAL_ROOT / "server.py",
|
||||
"DashboardService",
|
||||
APP_ROOT / "backend" / "features" / "alerts" / "facade.py",
|
||||
"AlertServiceMixin",
|
||||
ALERT_SERVICE_METHODS,
|
||||
)
|
||||
self.assert_methods_equal(
|
||||
ORIGINAL_ROOT / "server.py",
|
||||
"DashboardService",
|
||||
APP_ROOT / "backend" / "features" / "review" / "service.py",
|
||||
"ReviewServiceMixin",
|
||||
REVIEW_SERVICE_METHODS,
|
||||
)
|
||||
|
||||
def test_alert_and_review_repository_methods_are_exact_original_ast(self) -> None:
|
||||
self.assert_methods_equal(
|
||||
ORIGINAL_ROOT / "database.py",
|
||||
"ReviewDatabase",
|
||||
APP_ROOT / "backend" / "features" / "alerts" / "repository.py",
|
||||
"AlertRepositoryMixin",
|
||||
ALERT_REPOSITORY_METHODS,
|
||||
)
|
||||
self.assert_methods_equal(
|
||||
ORIGINAL_ROOT / "database.py",
|
||||
"ReviewDatabase",
|
||||
APP_ROOT / "backend" / "features" / "review" / "repository.py",
|
||||
"ReviewRepositoryMixin",
|
||||
REVIEW_REPOSITORY_METHODS,
|
||||
)
|
||||
|
||||
def test_existing_alert_and_trade_journal_services_preserve_original_classes(self) -> None:
|
||||
self.assertEqual(
|
||||
top_level_definition(
|
||||
ORIGINAL_ROOT / "backend" / "features" / "alerts" / "service.py",
|
||||
"AlertService",
|
||||
),
|
||||
top_level_definition(
|
||||
APP_ROOT / "backend" / "features" / "alerts" / "service.py",
|
||||
"AlertService",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
top_level_definition(
|
||||
ORIGINAL_ROOT / "backend" / "features" / "review" / "trade_journal.py",
|
||||
"TradeJournalService",
|
||||
),
|
||||
top_level_definition(
|
||||
APP_ROOT / "backend" / "features" / "review" / "trade_journal.py",
|
||||
"TradeJournalService",
|
||||
),
|
||||
)
|
||||
|
||||
def test_original_classes_no_longer_duplicate_moved_methods(self) -> None:
|
||||
remaining_service = class_methods(
|
||||
APP_ROOT / "backend" / "application.py", "DashboardService"
|
||||
)
|
||||
remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
|
||||
remaining_http = class_methods(
|
||||
APP_ROOT / "backend" / "application.py", "RequestHandler"
|
||||
)
|
||||
self.assertTrue(ALERT_SERVICE_METHODS.isdisjoint(remaining_service))
|
||||
self.assertTrue(REVIEW_SERVICE_METHODS.isdisjoint(remaining_service))
|
||||
self.assertTrue(ALERT_REPOSITORY_METHODS.isdisjoint(remaining_database))
|
||||
self.assertTrue(REVIEW_REPOSITORY_METHODS.isdisjoint(remaining_database))
|
||||
self.assertTrue(ALERT_HTTP_METHODS.isdisjoint(remaining_http))
|
||||
self.assertTrue(REVIEW_HTTP_METHODS.isdisjoint(remaining_http))
|
||||
|
||||
def test_http_mixins_preserve_all_endpoints_without_global_service(self) -> None:
|
||||
alerts = class_methods(
|
||||
APP_ROOT / "backend" / "features" / "alerts" / "http.py",
|
||||
"AlertHttpMixin",
|
||||
)
|
||||
review = class_methods(
|
||||
APP_ROOT / "backend" / "features" / "review" / "http.py",
|
||||
"ReviewHttpMixin",
|
||||
)
|
||||
self.assertEqual(set(alerts), ALERT_HTTP_METHODS)
|
||||
self.assertEqual(set(review), REVIEW_HTTP_METHODS)
|
||||
for path in (
|
||||
APP_ROOT / "backend" / "features" / "alerts" / "http.py",
|
||||
APP_ROOT / "backend" / "features" / "review" / "http.py",
|
||||
):
|
||||
source = path.read_text(encoding="utf-8")
|
||||
self.assertNotIn("SERVICE.", source)
|
||||
self.assertIn("self.application_service.", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,6 +11,11 @@ import screener
|
||||
import strategy_tracking
|
||||
from backend.features.screener import compiler, engine, strategies, tracking
|
||||
from backend.features.screener import service as screener_service
|
||||
from tests.preservation_helpers import (
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -165,12 +170,16 @@ class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_library_and_compiler_files_are_exact_copies(self) -> None:
|
||||
for original, migrated in (
|
||||
("advanced_strategies.py", "backend/features/screener/strategies.py"),
|
||||
("llm_strategy.py", "backend/features/screener/compiler.py"),
|
||||
):
|
||||
self.assertEqual(sha256(ORIGINAL_ROOT / original), sha256(APP_ROOT / migrated))
|
||||
def test_library_is_exact_and_compiler_uses_shared_transport(self) -> None:
|
||||
self.assertEqual(
|
||||
sha256(ORIGINAL_ROOT / "advanced_strategies.py"),
|
||||
sha256(APP_ROOT / "backend/features/screener/strategies.py"),
|
||||
)
|
||||
compiler_source = (
|
||||
APP_ROOT / "backend/features/screener/compiler.py"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertEqual(compiler_source.count("llm_transport.chat_completion"), 2)
|
||||
self.assertNotIn("urllib.request", compiler_source)
|
||||
|
||||
def test_compatibility_modules_export_the_canonical_objects(self) -> None:
|
||||
self.assertIs(screener, engine)
|
||||
@@ -182,17 +191,9 @@ class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_screener_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/screener/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
assert_page_prefix_matches(self, "pages/screener/page.js")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,6 +7,11 @@ from pathlib import Path
|
||||
|
||||
import sentiment_engine
|
||||
from backend.features.sentiment import engine as canonical_engine
|
||||
from tests.preservation_helpers import (
|
||||
assert_frontend_runtime_matches_audited_baseline,
|
||||
assert_moved_asset_matches,
|
||||
assert_page_prefix_matches,
|
||||
)
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -95,19 +100,14 @@ class SentimentPoolSliceSourceEquivalenceTests(unittest.TestCase):
|
||||
self.assertIs(sentiment_engine, canonical_engine)
|
||||
|
||||
def test_api_and_frontend_assets_are_unchanged(self) -> None:
|
||||
for relative in (
|
||||
"config/api.config.json",
|
||||
"static/index.html",
|
||||
"static/app.js",
|
||||
"static/styles.css",
|
||||
"static/pages/sentiment/page.js",
|
||||
"static/pages/pools/page.js",
|
||||
):
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / relative),
|
||||
sha256(ORIGINAL_ROOT / relative),
|
||||
relative,
|
||||
)
|
||||
self.assertEqual(
|
||||
sha256(APP_ROOT / "config/api.config.json"),
|
||||
sha256(ORIGINAL_ROOT / "config/api.config.json"),
|
||||
)
|
||||
assert_frontend_runtime_matches_audited_baseline(self)
|
||||
assert_moved_asset_matches(self, "styles.css", "styles/styles.css")
|
||||
for page in ("pages/sentiment/page.js", "pages/pools/page.js"):
|
||||
assert_page_prefix_matches(self, page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user