rebuild(stage-12): deliver private review workflows

This commit is contained in:
leefer
2026-07-30 07:45:55 +08:00
parent 0a055867a4
commit b93fb3ec41
32 changed files with 2292 additions and 9 deletions
+3
View File
@@ -0,0 +1,3 @@
from backend.features.review.service import ReviewService
__all__ = ["ReviewService"]
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import json
from typing import Any
PROMPT_VERSION = "review-assistant-v1"
def messages(
context: dict[str, Any], history: list[dict[str, str]], question: str
) -> list[dict[str, str]]:
return [
{
"role": "system",
"content": (
"你是复盘助手,只能基于提供的数据帮助用户复盘,不修改数据、不执行交易。"
"回答依次区分【市场事实】【用户记录】【推断】【条件化计划】;缺失信息明确留空。"
"不得把推断写成事实,不给无条件买卖指令。默认不超过800个中文字符,"
"只有用户明确要求展开时才可更长。"
),
},
{
"role": "user",
"content": "复盘上下文:"
+ json.dumps(context, ensure_ascii=False, separators=(",", ":")),
},
*history,
{"role": "user", "content": question},
]
+306
View File
@@ -0,0 +1,306 @@
from __future__ import annotations
import json
import sqlite3
class ReviewRepository:
def watchlist(self, connection: sqlite3.Connection, user_id: int) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""SELECT identifier, name, sector, remark, created_at FROM watchlist_entries
WHERE user_id = ? ORDER BY created_at, identifier""",
(user_id,),
).fetchall()
)
def save_watch(
self, connection: sqlite3.Connection, user_id: int, item: dict, now: str
) -> None:
connection.execute(
"""INSERT INTO watchlist_entries (user_id, identifier, name, sector, remark, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, identifier) DO UPDATE SET
name=excluded.name, sector=excluded.sector,
remark=CASE
WHEN excluded.remark = '' THEN watchlist_entries.remark
ELSE excluded.remark
END""",
(
user_id,
item["identifier"],
item["name"],
item.get("sector"),
item.get("remark") or "",
now,
),
)
def delete_watch(self, connection: sqlite3.Connection, user_id: int, identifier: str) -> bool:
cursor = connection.execute(
"DELETE FROM watchlist_entries WHERE user_id = ? AND identifier = ?",
(user_id, identifier),
)
return cursor.rowcount > 0
def save_watch_remark(
self, connection: sqlite3.Connection, user_id: int, identifier: str, remark: str
) -> bool:
cursor = connection.execute(
"UPDATE watchlist_entries SET remark=? WHERE user_id=? AND identifier=?",
(remark, user_id, identifier),
)
return cursor.rowcount > 0
def latest_factors(self, connection: sqlite3.Connection, through: str) -> dict[str, dict]:
snapshot = connection.execute(
"""SELECT id, trade_date FROM screener_factor_snapshots
WHERE trade_date <= ? ORDER BY trade_date DESC, id DESC LIMIT 1""",
(through,),
).fetchone()
if snapshot is None:
return {}
return {
str(row["identifier"]): json.loads(str(row["payload_json"]))
for row in connection.execute(
"SELECT identifier, payload_json FROM screener_factor_values WHERE snapshot_id = ?",
(int(snapshot["id"]),),
).fetchall()
}
def latest_auction_scores(
self, connection: sqlite3.Connection, through: str
) -> dict[str, float]:
row = connection.execute(
"""SELECT payload_json FROM market_insight_snapshots
WHERE kind = 'auction' AND trade_date <= ? AND entity_key = ''
ORDER BY trade_date DESC LIMIT 1""",
(through,),
).fetchone()
if row is None:
return {}
payload = json.loads(str(row["payload_json"]))
scores = {}
for key in ("_market_rows", "rows", "one_price_rows"):
for item in payload.get(key) or []:
identifier = str(item.get("identifier") or item.get("ts_code") or "")
score = item.get("attention_score")
if identifier and isinstance(score, (int, float)):
scores[identifier] = float(score)
return scores
def note(
self, connection: sqlite3.Connection, user_id: int, code: str, trade_date: str
) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM review_notes WHERE user_id = ? AND code = ? AND trade_date = ?",
(user_id, code, trade_date),
).fetchone()
def notes(
self, connection: sqlite3.Connection, user_id: int, code: str = "", limit: int = 60
) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""SELECT * FROM review_notes WHERE user_id = ? AND code = ?
ORDER BY trade_date DESC, id DESC LIMIT ?""",
(user_id, code, limit),
).fetchall()
)
def save_note(self, connection: sqlite3.Connection, user_id: int, data: dict, now: str) -> int:
connection.execute(
"""INSERT INTO review_notes (
user_id, code, stock_name, trade_date, summary, content, plan,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, code, trade_date) DO UPDATE SET
stock_name=excluded.stock_name, summary=excluded.summary,
content=excluded.content, plan=excluded.plan, updated_at=excluded.updated_at""",
(
user_id,
data["code"],
data["stock_name"],
data["trade_date"],
data["summary"],
data["content"],
data["plan"],
now,
now,
),
)
row = self.note(connection, user_id, data["code"], data["trade_date"])
if row is None:
raise RuntimeError("复盘记录写入失败")
return int(row["id"])
def delete_note(self, connection: sqlite3.Connection, user_id: int, note_id: int) -> bool:
cursor = connection.execute(
"DELETE FROM review_notes WHERE user_id = ? AND id = ?", (user_id, note_id)
)
return cursor.rowcount > 0
def trades(
self, connection: sqlite3.Connection, user_id: int, limit: int = 300
) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""SELECT * FROM trade_entries WHERE user_id = ?
ORDER BY trade_date DESC, id DESC LIMIT ?""",
(user_id, limit),
).fetchall()
)
def trade(
self, connection: sqlite3.Connection, user_id: int, trade_id: int
) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM trade_entries WHERE user_id = ? AND id = ?", (user_id, trade_id)
).fetchone()
def save_trade(self, connection: sqlite3.Connection, user_id: int, data: dict, now: str) -> int:
values = (
data["trade_date"],
data["code"],
data["name"],
data["action"],
data["price"],
data["quantity"],
data["position_pct"],
data["pnl_amount"],
data["pnl_pct"],
data["emotion"],
json.dumps(data["tags"], ensure_ascii=False),
data["thesis"],
data["execution"],
now,
)
if data.get("id"):
cursor = connection.execute(
"""UPDATE trade_entries SET trade_date=?, code=?, name=?, action=?, price=?,
quantity=?, position_pct=?, pnl_amount=?, pnl_pct=?, emotion=?, tags_json=?,
thesis=?, execution=?, updated_at=? WHERE user_id=? AND id=?""",
(*values, user_id, int(data["id"])),
)
if cursor.rowcount == 0:
raise ValueError("交易记录不存在或无权修改。")
return int(data["id"])
cursor = connection.execute(
"""INSERT INTO trade_entries (
trade_date, code, name, action, price, quantity, position_pct, pnl_amount,
pnl_pct, emotion, tags_json, thesis, execution, created_at, updated_at, user_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(*values, now, user_id),
)
return int(cursor.lastrowid)
def delete_trade(self, connection: sqlite3.Connection, user_id: int, trade_id: int) -> bool:
cursor = connection.execute(
"DELETE FROM trade_entries WHERE user_id = ? AND id = ?", (user_id, trade_id)
)
return cursor.rowcount > 0
def save_alert(self, connection: sqlite3.Connection, user_id: int, data: dict, now: str) -> int:
connection.execute(
"""INSERT INTO alerts (
user_id, kind, title, content, available_date, code, dedupe_key,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, dedupe_key) DO NOTHING""",
(
user_id,
data["kind"],
data["title"],
data["content"],
data["available_date"],
data["code"],
data["dedupe_key"],
now,
now,
),
)
row = connection.execute(
"SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?",
(user_id, data["dedupe_key"]),
).fetchone()
return int(row["id"])
def alerts(
self, connection: sqlite3.Connection, user_id: int, unread: bool
) -> tuple[sqlite3.Row, ...]:
clause = "AND is_read = 0" if unread else ""
return tuple(
connection.execute(
f"""SELECT * FROM alerts WHERE user_id = ? {clause}
ORDER BY available_date > date('now'), is_read,
available_date DESC, id DESC LIMIT 100""",
(user_id,),
).fetchall()
)
def unread_count(self, connection: sqlite3.Connection, user_id: int, today: str) -> int:
row = connection.execute(
"""SELECT COUNT(*) total FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0""",
(user_id, today),
).fetchone()
return int(row["total"])
def mark_alert(
self, connection: sqlite3.Connection, user_id: int, alert_id: int, now: str
) -> bool:
cursor = connection.execute(
"UPDATE alerts SET is_read=1, read_at=?, updated_at=? WHERE user_id=? AND id=?",
(now, now, user_id, alert_id),
)
return cursor.rowcount > 0
def mark_all_alerts(
self, connection: sqlite3.Connection, user_id: int, today: str, now: str
) -> int:
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, user_id, today),
)
return int(cursor.rowcount)
def delete_alert(self, connection: sqlite3.Connection, user_id: int, alert_id: int) -> bool:
cursor = connection.execute(
"DELETE FROM alerts WHERE user_id=? AND id=?", (user_id, alert_id)
)
return cursor.rowcount > 0
def messages(
self, connection: sqlite3.Connection, user_id: int, limit: int = 100
) -> tuple[sqlite3.Row, ...]:
rows = connection.execute(
"""SELECT * FROM review_assistant_messages WHERE user_id=?
ORDER BY id DESC LIMIT ?""",
(user_id, limit),
).fetchall()
return tuple(reversed(rows))
def add_message(
self, connection: sqlite3.Connection, user_id: int, data: dict, now: str
) -> None:
connection.execute(
"""INSERT INTO review_assistant_messages
(user_id, role, content, context_date, request_id, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
user_id,
data["role"],
data["content"],
data["context_date"],
data.get("request_id"),
data["status"],
now,
),
)
def clear_messages(self, connection: sqlite3.Connection, user_id: int) -> int:
cursor = connection.execute(
"DELETE FROM review_assistant_messages WHERE user_id=?", (user_id,)
)
return int(cursor.rowcount)
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
import json
from collections.abc import Iterator
from typing import Annotated
from fastapi import APIRouter, Path, Query, Request
from fastapi.responses import StreamingResponse
from backend.features.accounts.auth import (
AuthenticatedPrincipal,
CsrfPrincipal,
SmartAccessPrincipal,
SmartWritePrincipal,
)
from backend.features.review.schemas import (
AlertInput,
AssistantInput,
NoteInput,
TradeInput,
WatchInput,
WatchRemarkInput,
)
from backend.features.review.service import ReviewError
from backend.http.errors import AppError
from backend.llm.gateway import LLMGatewayError
router = APIRouter(prefix="/review", tags=["review"])
@router.get("", response_model=dict)
def workspace(
request: Request,
principal: AuthenticatedPrincipal,
trade_date: Annotated[str, Query(alias="date")],
) -> dict:
return _call(request, "workspace", principal, trade_date)
@router.post("/watchlist", response_model=dict)
def add_watch(payload: WatchInput, request: Request, principal: CsrfPrincipal) -> dict:
return _call(request, "add_watch", principal, payload.identifier)
@router.patch("/watchlist/{identifier}/remark", response_model=dict)
def save_watch_remark(
payload: WatchRemarkInput,
request: Request,
principal: CsrfPrincipal,
identifier: Annotated[str, Path(min_length=1, max_length=40)],
) -> dict:
_call(request, "save_watch_remark", principal, identifier, payload.remark)
return {"message": "跟踪备注已保存。"}
@router.delete("/watchlist/{identifier}", response_model=dict)
def delete_watch(
request: Request,
principal: CsrfPrincipal,
identifier: Annotated[str, Path(min_length=1, max_length=40)],
) -> dict:
_call(request, "delete_watch", principal, identifier)
return {"message": "已移出自选。"}
@router.put("/notes", response_model=dict)
def save_note(payload: NoteInput, request: Request, principal: CsrfPrincipal) -> dict:
identifier = _call(request, "save_note", principal, payload.model_dump())
return {"id": identifier}
@router.get("/stock-notes/{code}", response_model=list[dict])
def stock_notes(
request: Request,
principal: AuthenticatedPrincipal,
code: Annotated[str, Path(min_length=1, max_length=12)],
) -> list[dict]:
return _call(request, "stock_notes", principal, code)
@router.delete("/notes/{note_id}", response_model=dict)
def delete_note(
request: Request,
principal: CsrfPrincipal,
note_id: Annotated[int, Path(gt=0)],
) -> dict:
_call(request, "delete_note", principal, note_id)
return {"message": "复盘记录已删除。"}
@router.put("/trades", response_model=dict)
def save_trade(payload: TradeInput, request: Request, principal: CsrfPrincipal) -> dict:
identifier = _call(request, "save_trade", principal, payload.model_dump())
return {"id": identifier}
@router.delete("/trades/{trade_id}", response_model=dict)
def delete_trade(
request: Request,
principal: CsrfPrincipal,
trade_id: Annotated[int, Path(gt=0)],
) -> dict:
_call(request, "delete_trade", principal, trade_id)
return {"message": "交易记录已删除。"}
@router.get("/alerts", response_model=dict)
def alerts(
request: Request,
principal: AuthenticatedPrincipal,
unread: Annotated[bool, Query()] = False,
) -> dict:
return _call(request, "alert_center", principal, unread)
@router.post("/alerts", response_model=dict)
def create_alert(payload: AlertInput, request: Request, principal: CsrfPrincipal) -> dict:
identifier = _call(request, "create_alert", principal, payload.model_dump())
return {"id": identifier}
@router.patch("/alerts/read-all", response_model=dict)
def mark_all_alerts(request: Request, principal: CsrfPrincipal) -> dict:
return {"updated": _call(request, "mark_all_alerts", principal)}
@router.patch("/alerts/{alert_id}/read", response_model=dict)
def mark_alert(
request: Request,
principal: CsrfPrincipal,
alert_id: Annotated[int, Path(gt=0)],
) -> dict:
_call(request, "mark_alert", principal, alert_id)
return {"message": "提醒已读。"}
@router.delete("/alerts/{alert_id}", response_model=dict)
def delete_alert(
request: Request,
principal: CsrfPrincipal,
alert_id: Annotated[int, Path(gt=0)],
) -> dict:
_call(request, "delete_alert", principal, alert_id)
return {"message": "提醒已删除。"}
@router.get("/assistant/messages", response_model=list[dict])
def assistant_messages(request: Request, principal: SmartAccessPrincipal) -> list[dict]:
return _call(request, "messages", principal)
@router.delete("/assistant/messages", response_model=dict)
def clear_assistant_messages(request: Request, principal: SmartWritePrincipal) -> dict:
return {"deleted": _call(request, "clear_messages", principal)}
@router.post("/assistant/chat")
def assistant_chat(
payload: AssistantInput,
request: Request,
principal: SmartWritePrincipal,
) -> StreamingResponse:
prepared = _call(request, "prepare_assistant", principal, payload.trade_date, payload.question)
def body() -> Iterator[bytes]:
for event in request.app.state.container.review.stream_assistant(prepared):
yield (json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n").encode()
return StreamingResponse(
body(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"},
)
def _call(request: Request, method: str, *args):
try:
return getattr(request.app.state.container.review, method)(*args)
except ReviewError as exc:
raise AppError("review_unavailable", str(exc), 409) from exc
except LLMGatewayError as exc:
status = 403 if exc.code in {"membership_required", "quota_exhausted"} else 503
raise AppError(exc.code, str(exc), status) from exc
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
class WatchInput(BaseModel):
identifier: str = Field(min_length=1, max_length=40)
class WatchRemarkInput(BaseModel):
remark: str = Field(max_length=500)
class NoteInput(BaseModel):
trade_date: str
code: str = Field(default="", max_length=12)
stock_name: str = Field(default="", max_length=40)
summary: str = Field(default="", max_length=500)
content: str = Field(default="", max_length=5000)
plan: str = Field(default="", max_length=2000)
class TradeInput(BaseModel):
id: int | None = Field(default=None, gt=0)
trade_date: str
code: str = Field(min_length=6, max_length=12)
name: str = Field(min_length=1, max_length=40)
action: Literal["buy", "sell", "add", "trim", "watch"]
price: float = Field(ge=0, le=1_000_000)
quantity: int = Field(default=0, ge=0, le=100_000_000)
position_pct: float | None = Field(default=None, ge=0, le=100)
pnl_amount: float | None = Field(default=None, ge=-1e12, le=1e12)
pnl_pct: float | None = Field(default=None, ge=-1000, le=10000)
emotion: Literal["calm", "confident", "hesitant", "anxious", "impulsive"]
tags: list[str] = Field(default_factory=list, max_length=8)
thesis: str = Field(default="", max_length=2000)
execution: str = Field(default="", max_length=2000)
class AlertInput(BaseModel):
title: str = Field(min_length=1, max_length=80)
remind_date: str
code: str = Field(default="", max_length=12)
content: str = Field(default="", max_length=500)
class AssistantInput(BaseModel):
trade_date: str
question: str = Field(min_length=1, max_length=2000)
+368
View File
@@ -0,0 +1,368 @@
from __future__ import annotations
import secrets
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.gateway import DataGateway
from backend.database.connection import Database
from backend.features.accounts.models import Principal
from backend.features.review.prompt import PROMPT_VERSION, messages
from backend.features.review.repository import ReviewRepository
from backend.features.review.views import alert as alert_view
from backend.features.review.views import note as note_view
from backend.features.review.views import trade_summary, trades
from backend.features.screener.service import ScreenerService
from backend.llm.gateway import LLMCall, LLMGateway, LLMGatewayError
SHANGHAI = ZoneInfo("Asia/Shanghai")
class ReviewError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class PreparedAssistant:
call: LLMCall
prompt: list[dict[str, str]]
context_date: str
class ReviewService:
def __init__(
self,
database: Database,
repository: ReviewRepository,
gateway: DataGateway,
screener: ScreenerService,
llm: LLMGateway,
) -> None:
self._database = database
self._repository = repository
self._gateway = gateway
self._screener = screener
self._llm = llm
def workspace(self, principal: Principal, requested_date: str) -> dict[str, Any]:
trade_date = self._trade_date(requested_date)
with self._database.read() as connection:
watches = self._watch_rows(connection, principal.user.id, trade_date)
daily = self._repository.note(connection, principal.user.id, "", trade_date)
history = self._repository.notes(connection, principal.user.id, "", 60)
trade_rows = trades(self._repository.trades(connection, principal.user.id))
return {
"trade_date": trade_date,
"watchlist": watches,
"daily": note_view(daily),
"history": [note_view(row) for row in history],
"trades": trade_rows,
"trade_summary": trade_summary(trade_rows),
}
def add_watch(self, principal: Principal, identifier: str) -> dict[str, Any]:
normalized = identifier.strip().upper()
candidates = self._gateway.search(normalized)
item = next(
(
candidate
for candidate in candidates
if candidate.entity_type == "stock" and candidate.identifier == normalized
),
None,
)
if item is None:
raise ReviewError("未找到可加入自选的股票。")
with self._database.transaction() as connection:
self._repository.save_watch(
connection,
principal.user.id,
{"identifier": item.identifier, "name": item.name, "sector": item.sector},
_now(),
)
return {"identifier": item.identifier, "name": item.name}
def save_watch_remark(self, principal: Principal, identifier: str, remark: str) -> None:
normalized = _text(remark, 500)
with self._database.transaction() as connection:
if not self._repository.save_watch_remark(
connection, principal.user.id, identifier, normalized
):
raise ReviewError("自选记录不存在。")
def delete_watch(self, principal: Principal, identifier: str) -> None:
with self._database.transaction() as connection:
if not self._repository.delete_watch(connection, principal.user.id, identifier):
raise ReviewError("自选记录不存在。")
def save_note(self, principal: Principal, payload: dict[str, Any]) -> int:
data = {
"trade_date": _date(payload["trade_date"]),
"code": payload.get("code", "").strip().upper(),
"stock_name": _text(payload.get("stock_name", ""), 40),
"summary": _text(payload.get("summary", ""), 500),
"content": _text(payload.get("content", ""), 5000),
"plan": _text(payload.get("plan", ""), 2000),
}
if data["code"] and not data["stock_name"]:
raise ReviewError("个股名称不能为空。")
with self._database.transaction() as connection:
return self._repository.save_note(connection, principal.user.id, data, _now())
def stock_notes(self, principal: Principal, code: str) -> list[dict[str, Any]]:
with self._database.read() as connection:
return [
note_view(row)
for row in self._repository.notes(
connection, principal.user.id, code.strip().upper()
)
]
def delete_note(self, principal: Principal, note_id: int) -> None:
with self._database.transaction() as connection:
if not self._repository.delete_note(connection, principal.user.id, note_id):
raise ReviewError("复盘记录不存在。")
def save_trade(self, principal: Principal, payload: dict[str, Any]) -> int:
data = dict(payload)
data["trade_date"] = _date(str(data["trade_date"]))
data["code"] = str(data["code"]).strip().upper()
data["name"] = _text(data["name"], 40, True)
data["thesis"] = _text(data.get("thesis", ""), 2000)
data["execution"] = _text(data.get("execution", ""), 2000)
data["tags"] = [_text(item, 20, True) for item in data.get("tags", [])][:8]
with self._database.transaction() as connection:
return self._repository.save_trade(connection, principal.user.id, data, _now())
def delete_trade(self, principal: Principal, trade_id: int) -> None:
with self._database.transaction() as connection:
if not self._repository.delete_trade(connection, principal.user.id, trade_id):
raise ReviewError("交易记录不存在。")
def alert_center(self, principal: Principal, unread: bool = False) -> dict[str, Any]:
self._sync_tracking_alerts(principal.user.id)
today = date.today().isoformat()
with self._database.read() as connection:
rows = self._repository.alerts(connection, principal.user.id, unread)
items = [alert_view(row, today) for row in rows]
if unread:
items = [item for item in items if item["due"]]
count = self._repository.unread_count(connection, principal.user.id, today)
return {"items": items, "unread_count": count, "as_of": today}
def create_alert(self, principal: Principal, payload: dict[str, Any]) -> int:
data = {
"kind": "manual",
"title": _text(payload["title"], 80, True),
"content": _text(payload.get("content", ""), 500),
"available_date": _date(payload["remind_date"]),
"code": _text(payload.get("code", ""), 12),
"dedupe_key": f"manual:{secrets.token_hex(12)}",
}
with self._database.transaction() as connection:
return self._repository.save_alert(connection, principal.user.id, data, _now())
def mark_alert(self, principal: Principal, alert_id: int) -> None:
with self._database.transaction() as connection:
if not self._repository.mark_alert(connection, principal.user.id, alert_id, _now()):
raise ReviewError("提醒不存在。")
def mark_all_alerts(self, principal: Principal) -> int:
with self._database.transaction() as connection:
return self._repository.mark_all_alerts(
connection, principal.user.id, date.today().isoformat(), _now()
)
def delete_alert(self, principal: Principal, alert_id: int) -> None:
with self._database.transaction() as connection:
if not self._repository.delete_alert(connection, principal.user.id, alert_id):
raise ReviewError("提醒不存在。")
def messages(self, principal: Principal) -> list[dict[str, Any]]:
with self._database.read() as connection:
return [dict(row) for row in self._repository.messages(connection, principal.user.id)]
def clear_messages(self, principal: Principal) -> int:
with self._database.transaction() as connection:
return self._repository.clear_messages(connection, principal.user.id)
def prepare_assistant(
self, principal: Principal, requested_date: str, question: str
) -> PreparedAssistant:
context_date = self._trade_date(requested_date)
normalized = _text(question, 2000, True)
context = self._assistant_context(principal, context_date)
history = self._history(principal.user.id)
prompt = messages(context, history, normalized)
call = self._llm.prepare(
principal,
feature="review_assistant",
prompt_version=PROMPT_VERSION,
business_id=context_date,
input_chars=sum(len(item["content"]) for item in prompt),
)
with self._database.transaction() as connection:
self._repository.add_message(
connection,
principal.user.id,
{
"role": "user",
"content": normalized,
"context_date": context_date,
"request_id": call.request_id,
"status": "complete",
},
_now(),
)
return PreparedAssistant(call, prompt, context_date)
def stream_assistant(self, prepared: PreparedAssistant) -> Iterator[dict[str, Any]]:
answer = ""
stream = self._llm.stream(prepared.call, prepared.prompt)
try:
for event in stream:
if event.type == "delta":
answer += event.content
yield {
"type": "delta",
"content": event.content,
"request_id": event.request_id,
}
elif event.type == "done":
self._save_assistant(prepared, answer, "complete")
yield {"type": "done", "request_id": event.request_id}
except GeneratorExit:
stream.close()
if answer:
self._save_assistant(prepared, answer, "stopped")
raise
except LLMGatewayError as exc:
if answer:
self._save_assistant(prepared, answer, "error")
yield {"type": "error", "code": exc.code, "message": str(exc), "partial": exc.partial}
def _watch_rows(self, connection, user_id: int, trade_date: str) -> list[dict[str, Any]]:
factors = self._repository.latest_factors(connection, trade_date)
auction_scores = self._repository.latest_auction_scores(connection, trade_date)
rows = []
for watch in self._repository.watchlist(connection, user_id):
factor = factors.get(str(watch["identifier"]), {})
rows.append(
{
**dict(watch),
"code": str(watch["identifier"]).split(".")[0],
"pct_chg": factor.get("pct_chg"),
"return_5d": factor.get("return_5d"),
"attention_score": auction_scores.get(str(watch["identifier"])),
"remark": str(watch["remark"]),
}
)
return rows
def _sync_tracking_alerts(self, user_id: int) -> None:
tracks = self._screener.tracks(user_id)
batches: dict[int, list[dict]] = {}
for item in tracks:
batches.setdefault(int(item["run_id"]), []).append(item)
with self._database.transaction() as connection:
for run_id, items in batches.items():
name = str(items[0]["strategy_name"])
if any(item.get("t1_return") is not None for item in items):
self._repository.save_alert(
connection,
user_id,
{
"kind": "strategy_t1",
"title": f"{name} 已有 T+1 反馈",
"content": (
f"{sum(item.get('t1_return') is not None for item in items)}"
f"/{len(items)} 只标的已有首日表现。"
),
"available_date": date.today().isoformat(),
"code": "",
"dedupe_key": f"strategy:{run_id}:t1",
},
_now(),
)
if items and all(item.get("t5_return") is not None for item in items):
self._repository.save_alert(
connection,
user_id,
{
"kind": "strategy_t5",
"title": f"{name} 五日跟踪完成",
"content": f"本批 {len(items)} 只标的已完成 T+5 跟踪。",
"available_date": date.today().isoformat(),
"code": "",
"dedupe_key": f"strategy:{run_id}:t5",
},
_now(),
)
def _assistant_context(self, principal: Principal, trade_date: str) -> dict[str, Any]:
summary = self._gateway.summary(trade_date)
workspace = self.workspace(principal, trade_date)
alerts = self.alert_center(principal)["items"][:20]
return {
"data_date": trade_date,
"market_facts": summary["values"],
"user_records": {
"watchlist": workspace["watchlist"][:30],
"daily_reviews": workspace["history"][:10],
"strategy_tracking": self._screener.tracks(principal.user.id)[:30],
"alerts": alerts,
"trade_summary": workspace["trade_summary"],
"trade_entries": workspace["trades"][:30],
},
}
def _history(self, user_id: int) -> list[dict[str, str]]:
with self._database.read() as connection:
rows = self._repository.messages(connection, user_id, 20)
return [
{"role": str(row["role"]), "content": str(row["content"])[:4000]} for row in rows[-12:]
]
def _save_assistant(self, prepared: PreparedAssistant, answer: str, status: str) -> None:
if not answer:
return
with self._database.transaction() as connection:
self._repository.add_message(
connection,
prepared.call.user_id,
{
"role": "assistant",
"content": answer,
"context_date": prepared.context_date,
"request_id": prepared.call.request_id,
"status": status,
},
_now(),
)
def _trade_date(self, value: str) -> str:
requested = _date(value)
return self._gateway.trade_context(requested).actual_date or requested
def _date(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise ReviewError("日期格式无效。") from exc
def _text(value: Any, limit: int, required: bool = False) -> str:
normalized = " ".join(str(value or "").split())
if required and not normalized:
raise ReviewError("必填内容不能为空。")
if len(normalized) > limit:
raise ReviewError(f"内容不能超过{limit}个字符。")
return normalized
def _now() -> str:
return datetime.now(SHANGHAI).isoformat(timespec="seconds")
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import json
from typing import Any
ACTIONS = {"buy": "买入", "sell": "卖出", "add": "加仓", "trim": "减仓", "watch": "观察"}
EMOTIONS = {
"calm": "平静",
"confident": "笃定",
"hesitant": "犹豫",
"anxious": "焦虑",
"impulsive": "冲动",
}
def note(row) -> dict[str, Any] | None:
return dict(row) if row is not None else None
def trades(rows) -> list[dict[str, Any]]:
result = []
for row in rows:
item = dict(row)
item["tags"] = json.loads(str(item.pop("tags_json")))
item["action_label"] = ACTIONS[str(item["action"])]
item["emotion_label"] = EMOTIONS[str(item["emotion"])]
result.append(item)
return result
def trade_summary(items: list[dict[str, Any]]) -> dict[str, Any]:
realized = [
item for item in items if item["pnl_amount"] is not None or item["pnl_pct"] is not None
]
wins = sum(
float(item["pnl_pct"] if item["pnl_pct"] is not None else item["pnl_amount"]) > 0
for item in realized
)
amounts = [float(item["pnl_amount"]) for item in items if item["pnl_amount"] is not None]
positions = [float(item["position_pct"]) for item in items if item["position_pct"] is not None]
return {
"total": len(items),
"realized": len(realized),
"win_rate": round(wins / len(realized) * 100, 1) if realized else None,
"pnl_amount": round(sum(amounts), 2) if amounts else None,
"average_position": round(sum(positions) / len(positions), 1) if positions else None,
}
def alert(row, today: str) -> dict[str, Any]:
item = dict(row)
item["is_read"] = bool(item["is_read"])
item["due"] = str(item["available_date"]) <= today
return item