feat: 全屏个股详情预览 + Tailwind 改版 + 账号鉴权

This commit is contained in:
2026-08-14 22:37:18 +08:00
parent 0f8b9a7255
commit 4c2ea5521d
47 changed files with 2937 additions and 893 deletions

View File

@@ -1,100 +1,117 @@
"""开发自检脚本:跑一遍 /health 与 /backtest打印结果
"""使用线上 PostgreSQL 跑鉴权与核心 API 自检,临时用户会自动清理
FastAPI TestClient无需起服务器同进程验证全链路
用法: uv run --with httpx --directory backend python smoke_test.py
uv run --directory backend python smoke_test.py
"""
from __future__ import annotations
import asyncio
import json
import secrets
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
import httpx
from sqlalchemy import delete, select
from app.auth import hash_password, token_digest
from app.db import async_session, engine
from app.main import app
from app.models import AuthSession, MarketDaily, User
# 必须用 withlifespan建表只在进入上下文时执行
with TestClient(app) as c:
r = c.get("/api/health")
print("== /api/health ==", r.status_code, r.json())
r = c.post(
"/api/backtest",
json={
"symbol": "DEMO",
"strategy": "macd_cross",
"params": {"fast": 12, "slow": 26, "signal": 9},
"initial_cash": 100000.0,
"fast_mode": False,
},
)
print("== /api/backtest ==", r.status_code)
if r.status_code != 200:
print("ERROR:", r.text)
raise SystemExit(1)
async def main() -> None:
username = f"smoke_{secrets.token_hex(6)}"
password = secrets.token_urlsafe(24)
now = datetime.now(timezone.utc)
d = r.json()
print("candles :", len(d["candles"]))
print("signals :", len(d["signals"]), "(买卖点)")
print("equity pts :", len(d["equity"]))
print("final_cash :", round(d["final_cash"], 2))
print("final_pos :", d["final_position"])
print("metrics :", json.dumps(d["metrics"], ensure_ascii=False, indent=2))
print("first signal :", d["signals"][0] if d["signals"] else None)
async with async_session() as db:
db.add(
User(
username=username,
password_hash=hash_password(password),
password_algo="argon2id",
is_active=True,
failed_login_count=0,
password_changed_at=now,
created_at=now,
updated_at=now,
)
)
await db.commit()
assert len(d["candles"]) > 100
try:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
health = await client.get("/api/health")
protected = await client.post("/api/backtest", json={})
bad_login = await client.post(
"/api/auth/login", json={"username": username, "password": "wrong-password"}
)
login = await client.post(
"/api/auth/login", json={"username": username, "password": password}
)
print("health:", health.status_code)
print("protected without login:", protected.status_code)
print("bad login:", bad_login.status_code)
print("login:", login.status_code)
assert health.status_code == 200
assert protected.status_code == 401
assert bad_login.status_code == 401
assert login.status_code == 200
assert client.cookies.get("stock_session")
# 周期聚合:周线 K 线数应明显少于日线
rw = c.post(
"/api/backtest",
json={"symbol": "DEMO", "timeframe": "1w", "strategy": "macd_cross",
"params": {"fast": 12, "slow": 26, "signal": 9}, "initial_cash": 100000.0},
)
wd = rw.json()
print("== weekly ==", rw.status_code, "candles:", len(wd["candles"]), "vs daily", len(d["candles"]))
assert rw.status_code == 200
assert len(wd["candles"]) < len(d["candles"])
me = await client.get("/api/auth/me")
assert me.status_code == 200 and me.json()["username"] == username
print("\n✅ 后端全链路自检通过(含周期聚合)")
daily = await client.post(
"/api/backtest",
json={
"symbol": "DEMO",
"strategy": "macd_cross",
"params": {"fast": 12, "slow": 26, "signal": 9},
"initial_cash": 100000.0,
"fast_mode": False,
},
)
assert daily.status_code == 200, daily.text
result = daily.json()
assert len(result["candles"]) > 100
print("backtest metrics:", json.dumps(result["metrics"], ensure_ascii=False))
# ---------- 智能选股 ----------
print("\n== 智能选股 ==")
token = client.cookies.get("stock_session")
assert token
async with async_session() as db:
auth_session = (
await db.execute(
select(AuthSession).where(AuthSession.token_hash == token_digest(token))
)
).scalar_one()
auth_session.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await db.commit()
assert (await client.get("/api/auth/me")).status_code == 401
print("expired session: 401")
# 1) JSON 提取容错(不联网):代码围栏 / 多余文本
from app.screener.llm import _extract_json
assert _extract_json('```json\n{"a": 1}\n```') == {"a": 1}
assert _extract_json('好的,结果如下:{"indicator": [], "snapshot": []} 谢谢')["indicator"] == []
print("_extract_json 围栏/噪音容错 ✅")
login = await client.post(
"/api/auth/login", json={"username": username, "password": password}
)
assert login.status_code == 200
logout = await client.post("/api/auth/logout")
assert logout.status_code == 204
assert (await client.get("/api/auth/me")).status_code == 401
print("logout and revoke: ok")
# 2) 未配置 LLM_API_KEY 时 /run 返回 503确定性不联网
from app.config import settings as _s
r = c.post("/api/screener/run", json={"text": "这两天 KDJ 的 J 小于 10"})
if not _s.llm_api_key:
assert r.status_code == 503, f"无 key 应 503实际 {r.status_code}"
print("无 LLM_API_KEY -> 503 ✅")
else:
print("已配置 LLM_API_KEY跳过 503 用例")
async with async_session() as db:
has_market_data = bool(await db.scalar(select(MarketDaily.id).limit(1)))
print("market data present:", has_market_data)
finally:
async with async_session() as db:
user_id = await db.scalar(select(User.id).where(User.username == username))
if user_id is not None:
await db.execute(delete(AuthSession).where(AuthSession.user_id == user_id))
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
await engine.dispose()
print("temporary user cleaned")
# 3) 直传条件选股(不依赖 LLM依赖已同步的全市场数据
from app.models import MarketDaily # noqa: F401
from sqlalchemy import select, func
from app.db import async_session
import asyncio
async def _has_data() -> bool:
async with async_session() as session:
return (await session.scalar(select(func.count()).select_from(MarketDaily))) or 0 > 0
if asyncio.run(_has_data()):
r = c.post("/api/screener/run", json={
"text": "测试直传",
"conditions": {
"indicator": [{"indicator": "kdj_j", "params": {"n": 9, "m1": 3, "m2": 3},
"op": "lt", "value": 0, "lookback": 1, "match": "all"}],
"snapshot": [],
},
})
assert r.status_code == 200, f"直传选股失败 {r.status_code}: {r.text}"
d = r.json()
assert d["total"] >= 0
print(f"KDJ J<0 选股 ✅ 命中 {d['total']} 只,基准日 {(d['trade_date'] or '')[:10]}")
else:
print("(未同步全市场数据,跳过直传选股用例;运行 POST /api/screener/sync 后再试)")
print("\n✅ 智能选股自检通过")
if __name__ == "__main__":
asyncio.run(main())