Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
9111309
Add Windows batch scripts for starting/stopping services
Aug 15, 2026
bce53ff
feat(watchlist-groups): 新增自选股分组功能
Aug 15, 2026
8f9a1ad
docs: 添加自选板块 UI 优化计划
Aug 15, 2026
d584ea9
feat: 侧边栏板块平均涨幅始终显示
Aug 15, 2026
fdc5bf6
feat: 将添加股票搜索框移到页面右上角
Aug 15, 2026
21aa1c1
feat: 添加设置按钮整合重命名和删除功能
Aug 15, 2026
b6c96ce
优化自选板块UI:侧边栏设置菜单、列表移除和置顶按钮
Aug 15, 2026
cb249d7
feat: 添加板块置顶功能
Aug 15, 2026
298afd8
feat: add watchlist group sorting functionality
Aug 15, 2026
716706f
fix: 修复新增股票后板块平均涨跌幅未重新计算的问题
Aug 15, 2026
cfe046f
feat: 为自选板块列表添加列排序功能
Aug 15, 2026
cc919bd
feat: 为自选板块股票列表添加板块标识
Aug 15, 2026
928c0bc
fix: 调整自选板块股票列表代码和名称颜色,与自选股保持一致
Aug 15, 2026
6d703b1
优化自选板块卡片页:隐藏股票代码、减少间距、添加个股排序、移除新建按钮
Aug 15, 2026
9dfddf0
feat: 为自选板块卡片视图添加侧边栏
Aug 15, 2026
ae68631
优化自选板块功能:添加列配置后端持久化和批量enriched数据接口
Aug 15, 2026
1d9883c
fix: 在菜单设置中添加自选板块配置
Aug 15, 2026
60e4de0
P3-1: 优化宽泛异常捕获
Aug 15, 2026
a99e9ee
优化: 将自选板块的'侧边栏'视图名称改为'列表'
Aug 15, 2026
c2a656d
修复自选板块自定义列问题:支持扩展列、蜡烛图和分时图
Aug 16, 2026
dc2e6cc
feat: 添加卡片页列表视图,支持切换视图和调整个股显示样式
Aug 16, 2026
f394561
优化:自选板块详情改为右侧下半部分显示而非弹出Modal
Aug 16, 2026
53d1e23
优化:调整自选板块视图切换和按钮顺序
Aug 16, 2026
3ac4f1d
优化自选板块UI:
Aug 16, 2026
15307ee
重命名:自选板块 → 自选分组
Aug 16, 2026
bd625fc
misc
Aug 16, 2026
3c28d6c
fix:修复自选板块添加个股后不立刻显示的问题
Aug 17, 2026
6a9e0e4
添加涨跌颜色自定义功能
Aug 17, 2026
d64ab54
修复:自选和自选分组卡片视图涨跌幅显示错误
Aug 17, 2026
96918de
Merge remote-tracking branch 'origin/hmf' into hmf
Aug 17, 2026
ef1a1f3
实现情绪周期功能\n\n- 新增 sentiment_builder.py 实现情绪计算逻辑\n- 新增 sentiment.py API…
Aug 20, 2026
fa484c3
misc
Aug 20, 2026
748f006
调整自选分组排序按钮顺序为自定义→递减→递增
Aug 21, 2026
608bfa8
添加自选分组排序状态持久化功能
Aug 21, 2026
13c5554
fix: add missing semicolon between IIFEs in index.html
Aug 21, 2026
b4fc810
feat: 添加自选分组导入导出功能
Aug 21, 2026
7a05a3a
feat: 情绪雷达添加鼠标悬停浮窗显示所有维度得分
Aug 21, 2026
3bc0a31
修复情绪周期计算逻辑,使其与看板完全一致
Aug 22, 2026
fe21690
docs: 添加离线部署方案文档
Aug 22, 2026
28d47c2
fix: 调整情绪周期雷达图维度顺序为逆时针,指数在最上方
Aug 23, 2026
2ce3c0e
feat: 情绪周期页面添加趋势图悬停同步功能
Aug 23, 2026
8c71054
perf: 优化情绪周期页面雷达图动画效果
Aug 23, 2026
3dd7f1f
fix: 修复趋势图浮窗不显示的问题
Aug 23, 2026
a25630d
fix: 恢复趋势图悬停时顶部数据同步更新功能
Aug 23, 2026
9e86bb6
feat: 在情绪周期页面日期显示中添加周几
Aug 23, 2026
f45b07a
feat: 情绪趋势图悬停时高亮对应日历格子
Aug 23, 2026
7cf1812
feat: 新增实时情绪分时功能
Aug 23, 2026
eee4ead
feat: 添加实时指数页面,修复分钟K线时间显示问题
Aug 23, 2026
8126d65
fix: 修复分钟K线时间显示从0点开始的问题
Aug 23, 2026
2bc3ddb
fix: 完全重写分钟K线时间计算逻辑
Aug 23, 2026
46e4f75
misc
Aug 23, 2026
046a291
feat: 添加实时情绪页面日期选择功能
Aug 25, 2026
f6aee5f
feat: 扩展实时情绪记录时段,支持集合竞价和收盘后时段
Aug 25, 2026
2a521f2
修复5/15/30/60分K计算逻辑
Aug 25, 2026
438925e
feat: 新增实时环境页面
Aug 25, 2026
dcc7df8
feat: 优化实时情绪页面布局
Aug 25, 2026
5f7518c
fix: 修复TypeScript编译错误 - 移除未使用的导入和代码
Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,10 @@ RUN if [ "$USE_CN_MIRROR" = "1" ]; then \
fi

# Backend deps
COPY README.md /README.md
COPY README.md ./README.md
COPY backend/pyproject.toml backend/uv.lock* ./
# Fix readme path in pyproject.toml for Docker build context
RUN sed -i 's|readme = "../README.md"|readme = "README.md"|g' pyproject.toml
# uv 原生支持同时挂多个 index(主源 + 备用源),会自动在两源中查找,
# 比逐个重试更稳健 —— 任一源缺包时另一源补位。
RUN if [ "$USE_CN_MIRROR" = "1" ]; then \
Expand Down
84 changes: 84 additions & 0 deletions backend/app/api/regime_intraday.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""实时环境 API - 分钟级环境综合分及四维度数据。"""
from __future__ import annotations

import logging
from datetime import date, datetime
from pathlib import Path

from fastapi import APIRouter, Query, Request

from app.services.intraday_regime import (
INTRADAY_REGIME_DIR,
get_intraday_regime_service,
is_trading_time,
load_intraday_regime,
)

router = APIRouter(prefix="/api/regime/intraday", tags=["regime-intraday"])
logger = logging.getLogger(__name__)


@router.get("/latest")
def intraday_regime_latest(request: Request, target_date: date | None = Query(None)):
"""获取最新的实时环境数据。"""
service = get_intraday_regime_service()
service.set_repo(request.app.state.repo)
latest = service.get_latest(target_date)
return {"data": latest}


@router.get("/history")
def intraday_regime_history(request: Request, target_date: date | None = Query(None)):
"""获取当日的历史分钟级环境数据。"""
service = get_intraday_regime_service()
service.set_repo(request.app.state.repo)
history = service.get_history(target_date)
return {"data": history, "count": len(history)}


@router.get("/status")
def intraday_regime_status(request: Request):
"""获取实时环境服务状态。"""
now = datetime.now()
trading = is_trading_time(now)
return {
"trading_time": trading,
"current_time": now.isoformat(),
}


@router.post("/compute")
def intraday_regime_compute(request: Request, force: bool = Query(False)):
"""手动触发一次实时环境计算。"""
service = get_intraday_regime_service()
service.set_repo(request.app.state.repo)
result = service.compute_now(force=force)
return {"success": result is not None, "data": result}


@router.get("/dates")
def intraday_regime_dates(request: Request):
"""获取可用的日期列表。"""
repo = request.app.state.repo
data_dir = Path(repo.store.data_dir) / INTRADAY_REGIME_DIR

if not data_dir.exists():
return {"dates": []}

dates = []

# 扫描 .parquet 文件
for file in data_dir.glob("*.parquet"):
date_str = file.stem
try:
# 验证日期格式
parsed_date = date.fromisoformat(date_str)
dates.append(parsed_date)
except ValueError:
pass

# 去重并按日期降序排序
unique_dates = sorted(list(set(dates)), reverse=True)

return {"dates": [d.isoformat() for d in unique_dates]}

159 changes: 159 additions & 0 deletions backend/app/api/sentiment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""情绪周期(sentiment) API — 时序查询 + 手动重算。

装配逻辑在 app.services.sentiment_builder (纯函数), API 层薄壳 + TTL 缓存。
完全复用 regime API 的设计模式。
"""
from __future__ import annotations

import logging
import threading
import time
from datetime import date
from typing import Any

from fastapi import APIRouter, Query, Request

from app.services import sentiment_builder

router = APIRouter(prefix="/api/sentiment", tags=["sentiment"])

_CACHE_TTL = 5.0
_cache: dict[str, Any] | None = None
_cache_ts: float = 0.0
_cache_lock = threading.Lock()

logger = logging.getLogger(__name__)


def invalidate_sentiment_cache() -> None:
"""清空 sentiment 查询缓存。批算/重算后调用。"""
global _cache, _cache_ts
with _cache_lock:
_cache = None
_cache_ts = 0.0


def _data_dir(request: Request) -> Any:
return request.app.state.repo.store.data_dir


def _df_to_records(df) -> list[dict]:
"""polars DataFrame → JSON 安全的 list[dict](date 转 ISO 字符串)。"""
if df is None or df.is_empty():
return []
records = []
for r in df.to_dicts():
if "date" in r and r["date"] is not None:
r["date"] = str(r["date"])
records.append(r)
return records


@router.get("/history")
def sentiment_history(
request: Request,
start: date | None = Query(None),
end: date | None = Query(None),
limit: int = Query(120, ge=1, le=1000),
):
"""历史情绪周期时序(含 6 维度分 + 总情绪分 + 标签)。默认最近 N 天。"""
global _cache, _cache_ts
cache_key = f"hist|{start}|{end}|{limit}"
with _cache_lock:
if _cache is not None and _cache.get("key") == cache_key and (time.time() - _cache_ts) < _CACHE_TTL:
return _cache["data"]

df = sentiment_builder.load_sentiment_history(_data_dir(request))
if df.is_empty():
result: dict = {"rows": [], "total": 0}
else:
if start:
df = df.filter(pl_col_date(df, ">=", start))
if end:
df = df.filter(pl_col_date(df, "<=", end))
# limit 仅在"最近 N 天"模式(未传 start/end)生效;
# 日期范围模式(传了 start/end, 如"全部")应返回完整范围, 不截断。
if start is None and end is None:
df = df.sort("date", descending=True).head(limit)
df = df.sort("date")
rows = _df_to_records(df)
result = {"rows": rows, "total": len(rows)}

with _cache_lock:
_cache = {"key": cache_key, "data": result}
_cache_ts = time.time()
return result


def pl_col_date(df, op: str, value: date):
"""polars 日期过滤辅助(避免重复 import)。"""
import polars as pl

col = pl.col("date")
return col >= value if op == ">=" else col <= value


@router.get("/latest")
def sentiment_latest(request: Request):
"""最新日情绪周期(轻量)。"""
df = sentiment_builder.load_sentiment_history(_data_dir(request))
if df.is_empty():
return {"row": None}
latest = df.sort("date", descending=True).head(1)
rows = _df_to_records(latest)
return {"row": rows[0] if rows else None}


@router.get("/coverage")
def sentiment_coverage(request: Request):
"""sentiment 数据覆盖元信息(供数据画像/API)。"""
return sentiment_builder.get_sentiment_coverage(_data_dir(request))


@router.post("/recompute")
def sentiment_recompute(request: Request, start: date | None = None, end: date | None = None):
"""手动触发重算(全量或指定区间)。管理员操作。

- 不传 start: 强制全量重算(enriched 最早日 ~ 今天), 覆盖所有已有行。
与 daily_pipeline 的增量补差(compute_sentiment_incremental)不同 —— 该接口面向
人工「我要重新算一遍」的预期, 必须真正重算而非增量补缺口。
- 传 start: 仅重算 [start, end] 区间。
"""
repo = request.app.state.repo
data_dir = _data_dir(request)
end = end or date.today()

depth_service = getattr(request.app.state, "depth_service", None)

logger.info("sentiment_recompute called with start=%s, end=%s", start, end)

if start is None:
# 全量: 从 enriched 最早日强制重算到今天
earliest = sentiment_builder.earliest_enriched_date(repo)
logger.info("sentiment_recompute: earliest_enriched_date = %s", earliest)
if earliest is None:
invalidate_sentiment_cache()
return {"ok": True, "computed": 0}
start = earliest

logger.info("sentiment_recompute: computing from %s to %s", start, end)
new_rows = sentiment_builder.run_sentiment_batch(repo, start=start, end=end, depth_service=depth_service)
logger.info("sentiment_recompute: new_rows.height = %s", new_rows.height if not new_rows.is_empty() else 0)

if not new_rows.is_empty():
sentiment_builder.upsert_sentiment_history(data_dir, new_rows)
invalidate_sentiment_cache()
return {"ok": True, "computed": new_rows.height if not new_rows.is_empty() else 0}


@router.post("/refresh")
def sentiment_refresh(request: Request):
"""增量刷新:只计算当天、缺失的、以及stale的数据(快)。"""
repo = request.app.state.repo
data_dir = _data_dir(request)
depth_service = getattr(request.app.state, "depth_service", None)
logger.info("sentiment_refresh called")
new_rows = sentiment_builder.compute_sentiment_incremental(repo, data_dir, depth_service=depth_service)
logger.info("sentiment_refresh: new_rows.height = %s", new_rows.height if not new_rows.is_empty() else 0)
invalidate_sentiment_cache()
return {"ok": True, "computed": new_rows.height if not new_rows.is_empty() else 0}
97 changes: 97 additions & 0 deletions backend/app/api/sentiment_intraday.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""实时情绪 API - 分钟级情绪指标。"""
from __future__ import annotations

import logging
from datetime import date, datetime
from pathlib import Path
from typing import Any

from fastapi import APIRouter, Query, Request

from app.services.intraday_sentiment import (
INTRADAY_SENTIMENT_DIR,
get_intraday_sentiment_service,
is_trading_time,
should_record_now,
load_intraday_sentiment,
)

router = APIRouter(prefix="/api/sentiment/intraday", tags=["sentiment-intraday"])
logger = logging.getLogger(__name__)


@router.get("/latest")
def intraday_sentiment_latest(request: Request, target_date: date | None = Query(None)):
"""获取最新的实时情绪数据。"""
service = get_intraday_sentiment_service()
service.set_repo(request.app.state.repo)
service.set_depth_service(getattr(request.app.state, "depth_service", None))
latest = service.get_latest(target_date)
return {"data": latest}


@router.get("/history")
def intraday_sentiment_history(request: Request, target_date: date | None = Query(None)):
"""获取当日的历史分钟级情绪数据。"""
service = get_intraday_sentiment_service()
service.set_repo(request.app.state.repo)
history = service.get_history(target_date)
return {"data": history, "count": len(history)}


@router.get("/status")
def intraday_sentiment_status(request: Request):
"""获取实时情绪服务状态。"""
now = datetime.now()
trading = is_trading_time(now)
return {
"trading_time": trading,
"current_time": now.isoformat(),
}


@router.post("/compute")
def intraday_sentiment_compute(request: Request, force: bool = Query(False)):
"""手动触发一次实时情绪计算。"""
service = get_intraday_sentiment_service()
service.set_repo(request.app.state.repo)
service.set_depth_service(getattr(request.app.state, "depth_service", None))
result = service.compute_now(force=force)
return {"success": result is not None, "data": result}


@router.get("/dates")
def intraday_sentiment_dates(request: Request):
"""获取可用的日期列表。"""
repo = request.app.state.repo
data_dir = Path(repo.store.data_dir) / INTRADAY_SENTIMENT_DIR

if not data_dir.exists():
return {"dates": []}

dates = []

# 扫描 .parquet 文件
for file in data_dir.glob("*.parquet"):
date_str = file.stem
try:
# 验证日期格式
parsed_date = date.fromisoformat(date_str)
dates.append(parsed_date)
except ValueError:
pass

# 扫描 date=YYYY-MM-DD 目录
for dir_path in data_dir.glob("date=*"):
date_str = dir_path.name.replace("date=", "")
try:
# 验证日期格式
parsed_date = date.fromisoformat(date_str)
dates.append(parsed_date)
except ValueError:
pass

# 去重并按日期降序排序
unique_dates = sorted(list(set(dates)), reverse=True)

return {"dates": [d.isoformat() for d in unique_dates]}
Loading