diff --git a/next/backend/bootstrap/container.py b/next/backend/bootstrap/container.py index 5e2d1bc..9e769e2 100644 --- a/next/backend/bootstrap/container.py +++ b/next/backend/bootstrap/container.py @@ -25,6 +25,8 @@ from backend.features.mentor.context import MentorContextBuilder from backend.features.mentor.repository import MentorRepository from backend.features.mentor.service import MentorService from backend.features.mentor.skills import MentorSkillRegistry +from backend.features.review import ReviewService +from backend.features.review.repository import ReviewRepository from backend.features.screener.repository import ScreenerRepository from backend.features.screener.service import ScreenerService from backend.llm import LLMGateway @@ -46,6 +48,7 @@ class ApplicationContainer: llm: LLMGateway mentor: MentorService heaven: HeavenService + review: ReviewService def build_container(settings: Settings) -> ApplicationContainer: @@ -100,6 +103,7 @@ def build_container(settings: Settings) -> ApplicationContainer: llm, PROJECT_ROOT / "config" / "heaven" / "iching_zh.json", ) + review = ReviewService(database, ReviewRepository(), gateway, screener, llm) return ApplicationContainer( settings=settings, database=database, @@ -113,4 +117,5 @@ def build_container(settings: Settings) -> ApplicationContainer: llm=llm, mentor=mentor, heaven=heaven, + review=review, ) diff --git a/next/backend/database/migrations/m0010_review.py b/next/backend/database/migrations/m0010_review.py new file mode 100644 index 0000000..cfe0dcb --- /dev/null +++ b/next/backend/database/migrations/m0010_review.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import sqlite3 + +from backend.database.migrations.runner import Migration + + +def upgrade(connection: sqlite3.Connection) -> None: + connection.execute("ALTER TABLE watchlist_entries ADD COLUMN remark TEXT NOT NULL DEFAULT ''") + statements = ( + """ + CREATE TABLE review_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code TEXT NOT NULL DEFAULT '', + stock_name TEXT NOT NULL DEFAULT '', + trade_date TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + plan TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, code, trade_date) + ) + """, + """ + CREATE TABLE trade_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + trade_date TEXT NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('buy','sell','add','trim','watch')), + price REAL NOT NULL CHECK (price >= 0), + quantity INTEGER NOT NULL DEFAULT 0 CHECK (quantity >= 0), + position_pct REAL CHECK (position_pct BETWEEN 0 AND 100), + pnl_amount REAL, + pnl_pct REAL, + emotion TEXT NOT NULL CHECK ( + emotion IN ('calm','confident','hesitant','anxious','impulsive') + ), + tags_json TEXT NOT NULL DEFAULT '[]', + thesis TEXT NOT NULL DEFAULT '', + execution TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """, + """ + CREATE INDEX trade_entries_user_date_idx + ON trade_entries(user_id, trade_date DESC, id DESC) + """, + """ + CREATE TABLE alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('manual','strategy_t1','strategy_t5')), + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + available_date TEXT NOT NULL, + code TEXT NOT NULL DEFAULT '', + dedupe_key TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0 CHECK (is_read IN (0,1)), + read_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, dedupe_key) + ) + """, + """ + CREATE INDEX alerts_user_due_idx + ON alerts(user_id, available_date, is_read, id DESC) + """, + """ + CREATE TABLE review_assistant_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user','assistant')), + content TEXT NOT NULL, + context_date TEXT NOT NULL, + request_id TEXT REFERENCES llm_requests(id) ON DELETE SET NULL, + status TEXT NOT NULL CHECK (status IN ('complete','stopped','error')), + created_at TEXT NOT NULL + ) + """, + """ + CREATE INDEX review_assistant_user_idx + ON review_assistant_messages(user_id, id DESC) + """, + ) + for statement in statements: + connection.execute(statement) + + +def downgrade(connection: sqlite3.Connection) -> None: + for table in ( + "review_assistant_messages", + "alerts", + "trade_entries", + "review_notes", + ): + connection.execute(f"DROP TABLE {table}") + connection.execute("ALTER TABLE watchlist_entries DROP COLUMN remark") + + +MIGRATION = Migration( + version=10, + name="create_private_review_domain", + signature="review:v1:notes-trades-alerts-assistant", + upgrade=upgrade, + downgrade=downgrade, +) diff --git a/next/backend/database/migrations/registry.py b/next/backend/database/migrations/registry.py index 6efee63..23d0c15 100644 --- a/next/backend/database/migrations/registry.py +++ b/next/backend/database/migrations/registry.py @@ -7,6 +7,7 @@ from backend.database.migrations.m0006_watchlists import MIGRATION as WATCHLISTS from backend.database.migrations.m0007_screener import MIGRATION as SCREENER from backend.database.migrations.m0008_mentor_llm import MIGRATION as MENTOR_LLM from backend.database.migrations.m0009_heaven import MIGRATION as HEAVEN +from backend.database.migrations.m0010_review import MIGRATION as REVIEW from backend.database.migrations.runner import Migration MIGRATIONS: tuple[Migration, ...] = ( @@ -19,4 +20,5 @@ MIGRATIONS: tuple[Migration, ...] = ( SCREENER, MENTOR_LLM, HEAVEN, + REVIEW, ) diff --git a/next/backend/features/review/__init__.py b/next/backend/features/review/__init__.py new file mode 100644 index 0000000..6ded6c2 --- /dev/null +++ b/next/backend/features/review/__init__.py @@ -0,0 +1,3 @@ +from backend.features.review.service import ReviewService + +__all__ = ["ReviewService"] diff --git a/next/backend/features/review/prompt.py b/next/backend/features/review/prompt.py new file mode 100644 index 0000000..9dd16f5 --- /dev/null +++ b/next/backend/features/review/prompt.py @@ -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}, + ] diff --git a/next/backend/features/review/repository.py b/next/backend/features/review/repository.py new file mode 100644 index 0000000..8250dbf --- /dev/null +++ b/next/backend/features/review/repository.py @@ -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) diff --git a/next/backend/features/review/routes.py b/next/backend/features/review/routes.py new file mode 100644 index 0000000..334c1ca --- /dev/null +++ b/next/backend/features/review/routes.py @@ -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 diff --git a/next/backend/features/review/schemas.py b/next/backend/features/review/schemas.py new file mode 100644 index 0000000..2149663 --- /dev/null +++ b/next/backend/features/review/schemas.py @@ -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) diff --git a/next/backend/features/review/service.py b/next/backend/features/review/service.py new file mode 100644 index 0000000..af34752 --- /dev/null +++ b/next/backend/features/review/service.py @@ -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") diff --git a/next/backend/features/review/views.py b/next/backend/features/review/views.py new file mode 100644 index 0000000..d9e4e0b --- /dev/null +++ b/next/backend/features/review/views.py @@ -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 diff --git a/next/backend/http/router.py b/next/backend/http/router.py index c44d1cf..73fb435 100644 --- a/next/backend/http/router.py +++ b/next/backend/http/router.py @@ -4,6 +4,7 @@ from backend.features.accounts.routes import router as accounts_router from backend.features.heaven.routes import router as heaven_router from backend.features.market.routes import router as market_router from backend.features.mentor.routes import router as mentor_router +from backend.features.review.routes import router as review_router from backend.features.screener.routes import router as screener_router from backend.http.routes.health import router as health_router @@ -14,3 +15,4 @@ api_router.include_router(market_router) api_router.include_router(screener_router) api_router.include_router(mentor_router) api_router.include_router(heaven_router) +api_router.include_router(review_router) diff --git a/next/docs/evidence/stage-12/acceptance.md b/next/docs/evidence/stage-12/acceptance.md new file mode 100644 index 0000000..2d72cde --- /dev/null +++ b/next/docs/evidence/stage-12/acceptance.md @@ -0,0 +1,46 @@ +# 阶段12验收记录 + +## 完成范围 + +- 我的复盘:账户独立的自选追踪、交易日志、每日复盘、历史记录和个股复盘笔记。 +- 提醒中心:手动提醒、策略T+1/T+5提醒、未读状态、批量已读和持久化去重。 +- 复盘助手:统一LLM网关、结构化复盘上下文、流式回答、历史与清空。 +- 权限:复盘私有数据严格按账号隔离;非会员保留完整助手界面并灰化锁定。 +- 前端:PC与移动端、日间与夜间、顶栏提醒入口和个股详情笔记入口。 + +## 关键行为 + +- 每日复盘按账号和交易日唯一更新,三个输入区分别保存盘面摘要、得失和明日计划。 +- 交易日志只统计已填写盈亏字段的已实现记录;零收益进入胜率分母但不计为盈利。 +- 重复加入已有自选不会清空跟踪备注;自选、笔记、交易、提醒和助手历史均不可跨账号读取或修改。 +- 策略跟踪提醒以运行批次和T+1/T+5阶段生成稳定去重键,重复读取提醒中心不会重复创建。 +- 复盘助手只读取本地市场快照和当前账号记录,不执行交易,不建立第二套模型、配额或流式实现。 + +## 自动验收 + +- Ruff:通过。 +- Pytest:92项通过。 +- Vue TypeScript:通过。 +- Vitest:3个文件、7项通过。 +- Vite生产构建:通过。 +- Playwright:阶段4至12共14项通过,固定单工作进程避免共享验收账号并发互相撤销会话。 +- 数据库:版本10前进、回退、账户隔离和每日复盘唯一更新测试通过。 +- Git差异检查:通过。 +- 密钥扫描:已提供的账号密码和Token值无匹配。 + +## 视觉证据 + +- `review-dark-1920x1080.jpg` +- `review-dark-390x844.jpg` + +## 减法审计 + +- 未导入旧复盘页面、旧自选存储、旧助手脚本或旧弹窗覆盖层。 +- 复盘领域只有一组Repository、Service和Routes;响应转换拆入54行的`views.py`,业务Service保持368行。 +- 复盘助手继续复用阶段10唯一LLM网关,顶栏提醒和助手继续复用全局弹窗Host。 +- 页面按复盘、交易弹窗、提醒、助手和个股笔记拆分;页面样式397行,没有新增令牌外色值或间距字面值。 + +## 残余边界 + +- 策略提醒依赖盘后跟踪指标已写入;缺少T+1/T+5结果时不会伪造提醒。 +- NAS生产容器保持不变,最终切换仍需人工确认。 diff --git a/next/docs/evidence/stage-12/review-dark-1920x1080.jpg b/next/docs/evidence/stage-12/review-dark-1920x1080.jpg new file mode 100644 index 0000000..dc31163 Binary files /dev/null and b/next/docs/evidence/stage-12/review-dark-1920x1080.jpg differ diff --git a/next/docs/evidence/stage-12/review-dark-390x844.jpg b/next/docs/evidence/stage-12/review-dark-390x844.jpg new file mode 100644 index 0000000..09042c2 Binary files /dev/null and b/next/docs/evidence/stage-12/review-dark-390x844.jpg differ diff --git a/next/frontend/src/app/shell/TopBar.vue b/next/frontend/src/app/shell/TopBar.vue index 83fdaae..80a9d28 100644 --- a/next/frontend/src/app/shell/TopBar.vue +++ b/next/frontend/src/app/shell/TopBar.vue @@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { useRoute, useRouter } from "vue-router"; import { marketApi } from "../../shared/api/market"; +import { reviewApi } from "../../shared/api/review"; import { useMarketStore } from "../../shared/stores/market"; import { useSessionStore } from "../../shared/stores/session"; import { useUiStore } from "../../shared/stores/ui"; @@ -70,7 +71,12 @@ async function refreshMarket(): Promise { } } -onMounted(() => document.addEventListener("mousedown", closeOnOutside)); +async function refreshAlerts(): Promise { + try { ui.alertUnread = (await reviewApi.alerts()).unread_count; } + catch { ui.alertUnread = 0; } +} + +onMounted(() => { document.addEventListener("mousedown", closeOnOutside); void refreshAlerts(); }); onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside)); @@ -84,7 +90,8 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside)) - + + diff --git a/next/frontend/src/app/views/EntityDetailView.vue b/next/frontend/src/app/views/EntityDetailView.vue index bf0d5f9..714b5cd 100644 --- a/next/frontend/src/app/views/EntityDetailView.vue +++ b/next/frontend/src/app/views/EntityDetailView.vue @@ -4,6 +4,7 @@ import { useRoute } from "vue-router"; import type { MarketEntity } from "../../shared/api/market"; import MarketPreviewPanel from "../../shared/market/MarketPreviewPanel.vue"; +import StockNotes from "../../pages/review/StockNotes.vue"; const route = useRoute(); const entity = computed(() => ({ @@ -22,5 +23,6 @@ const entity = computed(() => ({ 最新真实行情 + diff --git a/next/frontend/src/app/views/WorkspaceView.vue b/next/frontend/src/app/views/WorkspaceView.vue index 784110f..585f261 100644 --- a/next/frontend/src/app/views/WorkspaceView.vue +++ b/next/frontend/src/app/views/WorkspaceView.vue @@ -7,6 +7,7 @@ import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue"; import ScreenerPage from "../../pages/screener/ScreenerPage.vue"; import MentorPage from "../../pages/mentor/MentorPage.vue"; import HeavenPage from "../../pages/heaven/HeavenPage.vue"; +import ReviewPage from "../../pages/review/ReviewPage.vue"; import { useMarketStore } from "../../shared/stores/market"; import { useSessionStore } from "../../shared/stores/session"; import { useUiStore } from "../../shared/stores/ui"; @@ -35,6 +36,7 @@ const implementedMarket = computed(() => +