rebuild(stage-12): deliver private review workflows
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from backend.features.review.service import ReviewService
|
||||
|
||||
__all__ = ["ReviewService"]
|
||||
@@ -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},
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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生产容器保持不变,最终切换仍需人工确认。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("mousedown", closeOnOutside));
|
||||
async function refreshAlerts(): Promise<void> {
|
||||
try { ui.alertUnread = (await reviewApi.alerts()).unread_count; }
|
||||
catch { ui.alertUnread = 0; }
|
||||
}
|
||||
|
||||
onMounted(() => { document.addEventListener("mousedown", closeOnOutside); void refreshAlerts(); });
|
||||
onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside));
|
||||
</script>
|
||||
|
||||
@@ -84,7 +90,8 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside))
|
||||
<button class="icon-button" type="button" aria-label="后一日" title="后一日" @click="market.moveDate(1)">›</button>
|
||||
</div>
|
||||
<button class="icon-button" type="button" aria-label="全局搜索" title="全局搜索(Ctrl+K)" @click="ui.openDialog('search')">⌕</button>
|
||||
<button class="icon-button desktop-only" type="button" aria-label="提醒中心" title="提醒中心" @click="ui.showToast('暂无新提醒')">!</button>
|
||||
<button class="icon-button topbar-alert desktop-only" type="button" aria-label="提醒中心" title="提醒中心" @click="ui.openDialog('alerts')">!<span v-if="ui.alertUnread" class="alert-badge">{{ ui.alertUnread > 99 ? '99+' : ui.alertUnread }}</span></button>
|
||||
<button class="icon-button desktop-only" type="button" aria-label="复盘助手" title="复盘助手" @click="ui.openDialog('assistant')">复</button>
|
||||
<button class="btn btn-small" type="button" :title="ui.theme === 'light' ? '切换夜间模式' : '切换日间模式'" @click="ui.toggleTheme">
|
||||
{{ ui.theme === "light" ? "夜间" : "日间" }}
|
||||
</button>
|
||||
|
||||
@@ -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<MarketEntity>(() => ({
|
||||
@@ -22,5 +23,6 @@ const entity = computed<MarketEntity>(() => ({
|
||||
<span class="tag">最新真实行情</span>
|
||||
</header>
|
||||
<MarketPreviewPanel :entity="entity" class="card entity-chart" />
|
||||
<StockNotes v-if="entity.entity_type === 'stock'" :code="entity.code" :name="entity.name" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -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(() =>
|
||||
<ScreenerPage v-else-if="workspace.key === 'screener'" />
|
||||
<MentorPage v-else-if="workspace.key === 'mentor'" />
|
||||
<HeavenPage v-else-if="workspace.key === 'heaven'" />
|
||||
<ReviewPage v-else-if="workspace.key === 'review'" />
|
||||
<main v-else class="page-frame">
|
||||
<header class="page-header">
|
||||
<h1>{{ workspace.title }}</h1>
|
||||
|
||||
@@ -18,6 +18,7 @@ import "./shared/styles/mentor.css";
|
||||
import "./shared/styles/heaven.css";
|
||||
import "./shared/styles/heaven-fortune.css";
|
||||
import "./shared/styles/heaven-heart.css";
|
||||
import "./shared/styles/review.css";
|
||||
import "./shared/styles/system.css";
|
||||
import "./shared/styles/mobile.css";
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { reviewApi, type AlertItem } from "../../shared/api/review";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
const ui = useUiStore();
|
||||
const items = ref<AlertItem[]>([]);
|
||||
const unreadOnly = ref(false);
|
||||
const loading = ref(true);
|
||||
const form = reactive({ title: "", remind_date: new Date().toISOString().slice(0, 10), code: "", content: "" });
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await reviewApi.alerts(unreadOnly.value);
|
||||
items.value = result.items;
|
||||
ui.alertUnread = result.unread_count;
|
||||
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "提醒读取失败"); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
async function create(): Promise<void> {
|
||||
try {
|
||||
await reviewApi.createAlert(form);
|
||||
Object.assign(form, { title: "", code: "", content: "" });
|
||||
ui.showToast("提醒已保存"); await load();
|
||||
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "提醒保存失败"); }
|
||||
}
|
||||
async function mark(row: AlertItem): Promise<void> { await reviewApi.markAlert(row.id); await load(); }
|
||||
async function markAll(): Promise<void> { await reviewApi.markAllAlerts(); await load(); }
|
||||
async function remove(row: AlertItem): Promise<void> { await reviewApi.deleteAlert(row.id); await load(); }
|
||||
function select(unread: boolean): void { unreadOnly.value = unread; void load(); }
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="alerts-panel">
|
||||
<div class="alerts-toolbar"><div class="segmented"><button type="button" :class="{ active: !unreadOnly }" @click="select(false)">全部</button><button type="button" :class="{ active: unreadOnly }" @click="select(true)">未读</button></div><button class="btn btn-small" type="button" @click="markAll">全部已读</button></div>
|
||||
<form class="alert-create" @submit.prevent="create"><div class="alert-create-grid"><label class="field"><span class="field-label">标题</span><input v-model="form.title" class="input" maxlength="80" required /></label><label class="field"><span class="field-label">提醒日期</span><input v-model="form.remind_date" class="input" type="date" required /></label><label class="field"><span class="field-label">股票代码(可选)</span><input v-model="form.code" class="input" maxlength="12" /></label></div><label class="field"><span class="field-label">提醒内容(可选)</span><textarea v-model="form.content" class="textarea" maxlength="500"></textarea></label><div class="form-actions"><button class="btn btn-primary" type="submit">保存提醒</button></div></form>
|
||||
<div v-if="loading" class="workspace-state">正在读取提醒</div>
|
||||
<div v-else-if="items.length" class="alert-list"><article v-for="item in items" :key="item.id" :class="{ unread: !item.is_read && item.due }"><div><strong>{{ item.title }}</strong><span class="tag">{{ item.due ? (item.is_read ? "已读" : "未读") : "未到期" }}</span></div><p>{{ item.content }}</p><small>{{ item.available_date }}<template v-if="item.code"> · {{ item.code }}</template></small><div class="table-actions"><button v-if="item.due && !item.is_read" class="btn btn-small" type="button" @click="mark(item)">标为已读</button><button class="btn btn-small" type="button" @click="remove(item)">删除</button></div></article></div>
|
||||
<EmptyState v-else title="暂无提醒" description="可以创建站内提醒,策略跟踪反馈也会自动汇总到这里。" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
|
||||
import { reviewApi, type AssistantMessage } from "../../shared/api/review";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
const market = useMarketStore();
|
||||
const session = useSessionStore();
|
||||
const ui = useUiStore();
|
||||
const messages = ref<AssistantMessage[]>([]);
|
||||
const question = ref("");
|
||||
const loading = ref(false);
|
||||
const messageRoot = ref<HTMLElement | null>(null);
|
||||
let controller: AbortController | undefined;
|
||||
const locked = computed(() => !session.account?.smart_access);
|
||||
const prompts: Array<[string, string]> = [
|
||||
["市场位置", "结合近十日情绪和今天的数据,当前市场处于什么位置?"],
|
||||
["市场主线", "总结当前市场主线,并说明证据和可能的失效条件。"],
|
||||
["交易复盘", "结合我的策略跟踪和交易日志,找出最值得修正的一个行为模式。"],
|
||||
["明日清单", "为下一个交易日给出条件化观察清单,不给无条件买卖指令。"],
|
||||
];
|
||||
async function load(): Promise<void> {
|
||||
if (locked.value) return;
|
||||
try { messages.value = await reviewApi.assistantMessages(); }
|
||||
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "对话读取失败"); }
|
||||
}
|
||||
async function send(): Promise<void> {
|
||||
const value = question.value.trim();
|
||||
if (!value || loading.value || locked.value) return;
|
||||
question.value = "";
|
||||
const user = { id: Date.now(), role: "user", content: value, context_date: market.selectedDate, status: "complete", created_at: "" } as AssistantMessage;
|
||||
const answer = { id: Date.now() + 1, role: "assistant", content: "", context_date: market.selectedDate, status: "complete", created_at: "" } as AssistantMessage;
|
||||
messages.value.push(user, answer);
|
||||
loading.value = true;
|
||||
controller = new AbortController();
|
||||
try {
|
||||
await reviewApi.chat(market.selectedDate, value, (event) => {
|
||||
if (event.type === "delta") answer.content += event.content ?? "";
|
||||
if (event.type === "error") ui.showToast(event.message ?? "复盘助手回答中断");
|
||||
void scrollEnd();
|
||||
}, controller.signal);
|
||||
} catch (reason) {
|
||||
if (!(reason instanceof DOMException && reason.name === "AbortError")) ui.showToast(reason instanceof Error ? reason.message : "回答失败");
|
||||
} finally { loading.value = false; controller = undefined; }
|
||||
}
|
||||
async function clear(): Promise<void> {
|
||||
await reviewApi.clearAssistant(); messages.value = [];
|
||||
}
|
||||
function usePrompt(value: string): void { question.value = value; }
|
||||
function stop(): void { controller?.abort(); }
|
||||
async function scrollEnd(): Promise<void> { await nextTick(); if (messageRoot.value) messageRoot.value.scrollTop = messageRoot.value.scrollHeight; }
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="assistant-panel">
|
||||
<div v-if="locked" class="notice notice-warning"><strong>复盘助手仅对会员开放</strong><span>开通会员后可读取市场统计、策略跟踪与个人复盘记录,进行统一复盘分析。</span></div>
|
||||
<div class="assistant-content" :class="{ 'locked-content': locked }" :aria-disabled="locked">
|
||||
<div ref="messageRoot" class="assistant-messages"><article v-for="message in messages" :key="message.id" :class="message.role"><span>{{ message.role === 'user' ? '我' : '复盘助手' }}</span><p>{{ message.content || (loading ? "正在整理复盘材料" : "") }}</p></article><div v-if="!messages.length" class="assistant-empty">可以从市场、策略或自己的交易记录开始复盘</div></div>
|
||||
<div class="assistant-prompts"><button v-for="item in prompts" :key="item[0]" type="button" :disabled="locked" @click="usePrompt(item[1])">{{ item[0] }}</button></div>
|
||||
<form class="assistant-compose" @submit.prevent="send"><textarea v-model="question" class="textarea" maxlength="2000" placeholder="询问市场位置、主线、策略表现或自己的交易模式" :disabled="locked" required></textarea><div><button v-if="loading" class="btn" type="button" @click="stop">停止</button><button v-else class="btn btn-primary" type="submit" :disabled="locked">发送</button><button class="btn btn-ghost" type="button" :disabled="locked" @click="clear">清空对话</button></div></form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { marketApi, type MarketEntity } from "../../shared/api/market";
|
||||
import { reviewApi, type ReviewWorkspace, type TradeEntry, type WatchItem } from "../../shared/api/review";
|
||||
import BaseDialog from "../../shared/components/BaseDialog.vue";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import TradeDialog from "./TradeDialog.vue";
|
||||
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
const data = ref<ReviewWorkspace | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const historyOpen = ref(false);
|
||||
const tradeDialog = ref(false);
|
||||
const editingTrade = ref<TradeEntry | null>(null);
|
||||
const watchDialog = ref(false);
|
||||
const watchQuery = ref("");
|
||||
const watchResults = ref<MarketEntity[]>([]);
|
||||
const daily = ref({ summary: "", content: "", plan: "" });
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const summary = computed(() => data.value?.trade_summary);
|
||||
function display(value: number | null | undefined, suffix = ""): string {
|
||||
return value === null || value === undefined ? "" : `${value.toFixed(2)}${suffix}`;
|
||||
}
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
data.value = await reviewApi.workspace(market.selectedDate);
|
||||
daily.value = {
|
||||
summary: data.value.daily?.summary ?? "",
|
||||
content: data.value.daily?.content ?? "",
|
||||
plan: data.value.daily?.plan ?? "",
|
||||
};
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : "复盘记录读取失败";
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
async function saveDaily(): Promise<void> {
|
||||
try {
|
||||
await reviewApi.saveNote({
|
||||
trade_date: market.selectedDate, code: "", stock_name: "", ...daily.value,
|
||||
});
|
||||
ui.showToast("每日复盘已保存");
|
||||
await load();
|
||||
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
|
||||
}
|
||||
function searchWatch(): void {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(async () => {
|
||||
const query = watchQuery.value.trim();
|
||||
if (!query) { watchResults.value = []; return; }
|
||||
try {
|
||||
const result = await marketApi.search(query);
|
||||
watchResults.value = result.groups.find((group) => group.entity_type === "stock")?.items ?? [];
|
||||
} catch { watchResults.value = []; }
|
||||
}, 160);
|
||||
}
|
||||
async function addWatch(item: MarketEntity): Promise<void> {
|
||||
try {
|
||||
await reviewApi.addWatch(item.identifier);
|
||||
watchDialog.value = false;
|
||||
watchQuery.value = "";
|
||||
watchResults.value = [];
|
||||
ui.showToast(`${item.name} 已加入自选`);
|
||||
await load();
|
||||
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "添加失败"); }
|
||||
}
|
||||
async function saveRemark(item: WatchItem, event: Event): Promise<void> {
|
||||
try {
|
||||
await reviewApi.saveRemark(item.identifier, (event.target as HTMLInputElement).value);
|
||||
ui.showToast("跟踪备注已保存");
|
||||
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
|
||||
}
|
||||
async function removeWatch(item: WatchItem): Promise<void> {
|
||||
if (!await ui.askConfirmation({ title: "移出自选", message: `确定移出 ${item.name}?`, confirmLabel: "移出" })) return;
|
||||
try { await reviewApi.removeWatch(item.identifier); ui.showToast("已移出自选"); await load(); }
|
||||
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "操作失败"); }
|
||||
}
|
||||
function openTrade(row: TradeEntry | null = null): void { editingTrade.value = row; tradeDialog.value = true; }
|
||||
async function saveTrade(value: Omit<TradeEntry, "action_label" | "emotion_label">): Promise<void> {
|
||||
try { await reviewApi.saveTrade(value); tradeDialog.value = false; ui.showToast("交易记录已保存"); await load(); }
|
||||
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
|
||||
}
|
||||
async function deleteTrade(row: TradeEntry): Promise<void> {
|
||||
if (!await ui.askConfirmation({ title: "删除交易记录", message: `确定删除 ${row.name} 的交易记录?`, confirmLabel: "删除" })) return;
|
||||
try { await reviewApi.deleteTrade(row.id); ui.showToast("交易记录已删除"); await load(); }
|
||||
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "删除失败"); }
|
||||
}
|
||||
watch(() => market.selectedDate, load);
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-frame review-page">
|
||||
<header class="page-header review-heading">
|
||||
<div><h1>我的复盘</h1><p class="page-subtitle">数据日期 {{ data?.trade_date ?? market.selectedDate }} · 当前账号私有</p></div>
|
||||
<button class="btn btn-primary" type="button" @click="watchDialog = true">添加自选</button>
|
||||
</header>
|
||||
<div v-if="loading" class="card workspace-state">正在读取个人复盘记录</div>
|
||||
<EmptyState v-else-if="error" class="card" title="复盘记录暂不可用" :description="error" />
|
||||
<template v-else-if="data">
|
||||
<section class="card review-watch-card">
|
||||
<header class="card-header"><h2>自选追踪</h2><span class="tag">{{ data.watchlist.length }} 只</span></header>
|
||||
<div v-if="data.watchlist.length" class="data-table-wrap review-watch-table"><table class="data-table"><thead><tr><th>标记</th><th>代码</th><th>股票</th><th>板块</th><th class="numeric">今日涨幅(%)</th><th class="numeric">5日涨幅(%)</th><th class="numeric">竞价关注分</th><th>跟踪备注</th><th>操作</th></tr></thead><tbody><tr v-for="item in data.watchlist" :key="item.identifier"><td class="watch-star">★</td><td>{{ item.code }}</td><td>{{ item.name }}</td><td>{{ item.sector ?? "" }}</td><td class="numeric" :class="item.pct_chg !== null && item.pct_chg >= 0 ? 'up' : 'down'">{{ display(item.pct_chg) }}</td><td class="numeric" :class="item.return_5d !== null && item.return_5d >= 0 ? 'up' : 'down'">{{ display(item.return_5d) }}</td><td class="numeric">{{ display(item.attention_score) }}</td><td><input :value="item.remark" class="review-remark" maxlength="500" aria-label="跟踪备注" @change="saveRemark(item, $event)" /></td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="removeWatch(item)">移除</button></div></td></tr></tbody></table></div>
|
||||
<EmptyState v-else title="暂无自选" description="添加股票后可集中查看涨幅、竞价关注分和跟踪备注。" />
|
||||
</section>
|
||||
|
||||
<div class="review-main-grid">
|
||||
<section class="card trade-card">
|
||||
<header class="card-header"><h2>交易日志</h2><span class="tag">{{ data.trades.length }} 条</span><button class="btn btn-primary btn-small review-card-action" type="button" @click="openTrade()">交易日志</button></header>
|
||||
<div class="review-summary"><div><span>总记录</span><strong>{{ summary?.total }}</strong></div><div><span>已实现</span><strong>{{ summary?.realized }}</strong></div><div><span>胜率</span><strong>{{ summary?.win_rate === null ? "" : display(summary?.win_rate, "%") }}</strong></div><div><span>累计盈亏</span><strong>{{ display(summary?.pnl_amount) }}</strong></div><div><span>平均仓位</span><strong>{{ summary?.average_position === null ? "" : display(summary?.average_position, "%") }}</strong></div></div>
|
||||
<div v-if="data.trades.length" class="trade-scroll"><table class="data-table"><thead><tr><th>日期</th><th>代码</th><th>股票</th><th>动作</th><th class="numeric">价格(元)</th><th class="numeric">仓位(%)</th><th class="numeric">盈亏(%)</th><th>操作</th></tr></thead><tbody><tr v-for="row in data.trades" :key="row.id"><td>{{ row.trade_date }}</td><td>{{ row.code }}</td><td>{{ row.name }}</td><td>{{ row.action_label }}</td><td class="numeric">{{ display(row.price) }}</td><td class="numeric">{{ display(row.position_pct) }}</td><td class="numeric" :class="row.pnl_pct !== null && row.pnl_pct >= 0 ? 'up' : 'down'">{{ display(row.pnl_pct) }}</td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="openTrade(row)">编辑</button><button class="btn btn-small" type="button" @click="deleteTrade(row)">删除</button></div></td></tr></tbody></table></div>
|
||||
<EmptyState v-else title="暂无交易日志" description="记录交易后,摘要与明细会保留在当前页面。" />
|
||||
</section>
|
||||
|
||||
<section class="card daily-review-card">
|
||||
<header class="card-header"><h2>每日复盘</h2><span class="tag">{{ market.selectedDate }}</span></header>
|
||||
<form class="daily-review-form" @submit.prevent="saveDaily">
|
||||
<label class="field"><span class="field-label">今日盘面一句话</span><textarea v-model="daily.summary" class="textarea daily-short" maxlength="500"></textarea></label>
|
||||
<label class="field"><span class="field-label">今日做对了什么 / 做错了什么</span><textarea v-model="daily.content" class="textarea" maxlength="5000"></textarea></label>
|
||||
<label class="field"><span class="field-label">明日策略</span><textarea v-model="daily.plan" class="textarea" maxlength="2000"></textarea></label>
|
||||
<div class="form-actions"><button class="btn btn-primary" type="submit">保存复盘</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card review-history-card">
|
||||
<button class="review-history-toggle" type="button" :aria-expanded="historyOpen" @click="historyOpen = !historyOpen"><span>最近复盘</span><span>{{ historyOpen ? "收起" : `展开 ${data.history.length} 条` }}</span></button>
|
||||
<div v-if="historyOpen" class="review-history-list"><article v-for="note in data.history" :key="note.id"><time>{{ note.trade_date }}</time><strong>{{ note.summary || "未填写盘面一句话" }}</strong><p>{{ note.content }}</p><small>{{ note.plan }}</small></article><EmptyState v-if="!data.history.length" title="暂无每日复盘" description="保存后会按日期显示在这里。" /></div>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<BaseDialog v-if="watchDialog" title="添加自选" @close="watchDialog = false">
|
||||
<div class="form-grid"><label class="field"><span class="field-label">股票代码或名称</span><input v-model="watchQuery" class="input" autofocus @input="searchWatch" /></label><div class="watch-search-results"><button v-for="item in watchResults" :key="item.identifier" type="button" @click="addWatch(item)"><span><strong>{{ item.name }}</strong><small>{{ item.code }} · {{ item.sector || "行业待补" }}</small></span><span>添加</span></button><p v-if="watchQuery && !watchResults.length" class="muted">暂无匹配股票</p></div></div>
|
||||
</BaseDialog>
|
||||
<TradeDialog v-if="tradeDialog" :value="editingTrade" :date="market.selectedDate" @close="tradeDialog = false" @save="saveTrade" />
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
|
||||
import { reviewApi, type ReviewNote } from "../../shared/api/review";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
const props = defineProps<{ code: string; name: string }>();
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
const rows = ref<ReviewNote[]>([]);
|
||||
const content = ref("");
|
||||
const plan = ref("");
|
||||
async function load(): Promise<void> { rows.value = await reviewApi.stockNotes(props.code); }
|
||||
async function save(): Promise<void> {
|
||||
try {
|
||||
await reviewApi.saveNote({ trade_date: market.selectedDate, code: props.code, stock_name: props.name, summary: "", content: content.value, plan: plan.value });
|
||||
ui.showToast("个股复盘笔记已保存"); await load();
|
||||
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
|
||||
}
|
||||
async function remove(id: number): Promise<void> { await reviewApi.deleteNote(id); await load(); }
|
||||
watch(() => props.code, load);
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card stock-notes-card"><header class="card-header"><h2>个股复盘笔记</h2><span class="tag">当前账号私有</span></header><form class="stock-note-form" @submit.prevent="save"><label class="field"><span class="field-label">复盘内容</span><textarea v-model="content" class="textarea" maxlength="5000"></textarea></label><label class="field"><span class="field-label">明日计划</span><textarea v-model="plan" class="textarea" maxlength="2000"></textarea></label><div class="form-actions"><button class="btn btn-primary" type="submit">保存笔记</button></div></form><div v-if="rows.length" class="stock-note-history"><article v-for="row in rows" :key="row.id"><time>{{ row.trade_date }}</time><p>{{ row.content }}</p><small>{{ row.plan }}</small><button class="btn btn-small" type="button" @click="remove(row.id)">删除</button></article></div></section>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from "vue";
|
||||
|
||||
import type { TradeEntry } from "../../shared/api/review";
|
||||
import BaseDialog from "../../shared/components/BaseDialog.vue";
|
||||
|
||||
const props = defineProps<{ value: TradeEntry | null; date: string }>();
|
||||
const emit = defineEmits<{ close: []; save: [value: Omit<TradeEntry, "action_label" | "emotion_label">] }>();
|
||||
const form = reactive({
|
||||
id: 0, trade_date: "", code: "", name: "", action: "buy" as TradeEntry["action"],
|
||||
price: 0, quantity: 0, position_pct: null as number | null, pnl_amount: null as number | null,
|
||||
pnl_pct: null as number | null, emotion: "calm" as TradeEntry["emotion"], tags: "",
|
||||
thesis: "", execution: "",
|
||||
});
|
||||
watch(() => [props.value, props.date] as const, () => {
|
||||
const row = props.value;
|
||||
Object.assign(form, row ? { ...row, tags: row.tags.join(",") } : {
|
||||
id: 0, trade_date: props.date, code: "", name: "", action: "buy", price: 0,
|
||||
quantity: 0, position_pct: null, pnl_amount: null, pnl_pct: null,
|
||||
emotion: "calm", tags: "", thesis: "", execution: "",
|
||||
});
|
||||
}, { immediate: true });
|
||||
function submit(): void {
|
||||
emit("save", {
|
||||
id: form.id, trade_date: form.trade_date, code: form.code, name: form.name,
|
||||
action: form.action, price: Number(form.price), quantity: Number(form.quantity),
|
||||
position_pct: form.position_pct === null ? null : Number(form.position_pct),
|
||||
pnl_amount: form.pnl_amount === null ? null : Number(form.pnl_amount),
|
||||
pnl_pct: form.pnl_pct === null ? null : Number(form.pnl_pct), emotion: form.emotion,
|
||||
tags: form.tags.split(/[,,]/).map((item) => item.trim()).filter(Boolean).slice(0, 8),
|
||||
thesis: form.thesis, execution: form.execution,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDialog :title="form.id ? '编辑交易日志' : '交易日志'" wide @close="emit('close')">
|
||||
<form class="trade-form" @submit.prevent="submit">
|
||||
<div class="trade-form-grid">
|
||||
<label class="field"><span class="field-label">交易日</span><input v-model="form.trade_date" class="input" type="date" required /></label>
|
||||
<label class="field"><span class="field-label">代码</span><input v-model="form.code" class="input" maxlength="12" required /></label>
|
||||
<label class="field"><span class="field-label">名称</span><input v-model="form.name" class="input" maxlength="40" required /></label>
|
||||
<label class="field"><span class="field-label">动作</span><select v-model="form.action" class="select"><option value="buy">买入</option><option value="sell">卖出</option><option value="add">加仓</option><option value="trim">减仓</option><option value="watch">观察</option></select></label>
|
||||
<label class="field"><span class="field-label">价格(元)</span><input v-model.number="form.price" class="input" type="number" min="0" step="0.01" required /></label>
|
||||
<label class="field"><span class="field-label">数量(股)</span><input v-model.number="form.quantity" class="input" type="number" min="0" step="1" /></label>
|
||||
<label class="field"><span class="field-label">仓位(%)</span><input v-model.number="form.position_pct" class="input" type="number" min="0" max="100" step="0.1" /></label>
|
||||
<label class="field"><span class="field-label">盈亏金额(元)</span><input v-model.number="form.pnl_amount" class="input" type="number" step="0.01" /></label>
|
||||
<label class="field"><span class="field-label">盈亏(%)</span><input v-model.number="form.pnl_pct" class="input" type="number" step="0.01" /></label>
|
||||
<label class="field"><span class="field-label">情绪</span><select v-model="form.emotion" class="select"><option value="calm">平静</option><option value="confident">笃定</option><option value="hesitant">犹豫</option><option value="anxious">焦虑</option><option value="impulsive">冲动</option></select></label>
|
||||
<label class="field trade-wide"><span class="field-label">标签</span><input v-model="form.tags" class="input" maxlength="160" placeholder="回踩确认,计划内" /></label>
|
||||
<label class="field trade-wide"><span class="field-label">交易逻辑</span><textarea v-model="form.thesis" class="textarea" maxlength="2000"></textarea></label>
|
||||
<label class="field trade-wide"><span class="field-label">执行复核</span><textarea v-model="form.execution" class="textarea" maxlength="2000"></textarea></label>
|
||||
</div>
|
||||
<div class="form-actions"><button class="btn" type="button" @click="emit('close')">取消</button><button class="btn btn-primary" type="submit">保存</button></div>
|
||||
</form>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export type WatchItem = {
|
||||
identifier: string; code: string; name: string; sector: string | null; remark: string;
|
||||
pct_chg: number | null; return_5d: number | null; attention_score: number | null;
|
||||
};
|
||||
export type ReviewNote = {
|
||||
id: number; code: string; stock_name: string; trade_date: string;
|
||||
summary: string; content: string; plan: string; updated_at: string;
|
||||
};
|
||||
export type TradeEntry = {
|
||||
id: number; trade_date: string; code: string; name: string;
|
||||
action: "buy" | "sell" | "add" | "trim" | "watch"; action_label: string;
|
||||
price: number; quantity: number; position_pct: number | null;
|
||||
pnl_amount: number | null; pnl_pct: number | null;
|
||||
emotion: "calm" | "confident" | "hesitant" | "anxious" | "impulsive";
|
||||
emotion_label: string; tags: string[]; thesis: string; execution: string;
|
||||
};
|
||||
export type TradeSummary = {
|
||||
total: number; realized: number; win_rate: number | null;
|
||||
pnl_amount: number | null; average_position: number | null;
|
||||
};
|
||||
export type ReviewWorkspace = {
|
||||
trade_date: string; watchlist: WatchItem[]; daily: ReviewNote | null;
|
||||
history: ReviewNote[]; trades: TradeEntry[]; trade_summary: TradeSummary;
|
||||
};
|
||||
export type AlertItem = {
|
||||
id: number; kind: string; title: string; content: string; available_date: string;
|
||||
code: string; is_read: boolean; due: boolean; created_at: string;
|
||||
};
|
||||
export type AlertCenter = { items: AlertItem[]; unread_count: number; as_of: string };
|
||||
export type AssistantMessage = {
|
||||
id: number; role: "user" | "assistant"; content: string;
|
||||
context_date: string; status: "complete" | "stopped" | "error"; created_at: string;
|
||||
};
|
||||
export type AssistantEvent = {
|
||||
type: "delta" | "done" | "error"; content?: string; message?: string; partial?: boolean;
|
||||
};
|
||||
|
||||
export const reviewApi = {
|
||||
workspace(date: string): Promise<ReviewWorkspace> {
|
||||
return api.get(`/review?date=${encodeURIComponent(date)}`);
|
||||
},
|
||||
addWatch(identifier: string): Promise<{ identifier: string; name: string }> {
|
||||
return api.post("/review/watchlist", { identifier });
|
||||
},
|
||||
saveRemark(identifier: string, remark: string): Promise<{ message: string }> {
|
||||
return api.patch(`/review/watchlist/${encodeURIComponent(identifier)}/remark`, { remark });
|
||||
},
|
||||
removeWatch(identifier: string): Promise<{ message: string }> {
|
||||
return api.delete(`/review/watchlist/${encodeURIComponent(identifier)}`);
|
||||
},
|
||||
saveNote(note: Omit<ReviewNote, "id" | "updated_at">): Promise<{ id: number }> {
|
||||
return api.put("/review/notes", note);
|
||||
},
|
||||
stockNotes(code: string): Promise<ReviewNote[]> {
|
||||
return api.get(`/review/stock-notes/${encodeURIComponent(code)}`);
|
||||
},
|
||||
deleteNote(id: number): Promise<{ message: string }> {
|
||||
return api.delete(`/review/notes/${id}`);
|
||||
},
|
||||
saveTrade(trade: Omit<TradeEntry, "action_label" | "emotion_label">): Promise<{ id: number }> {
|
||||
return api.put("/review/trades", trade);
|
||||
},
|
||||
deleteTrade(id: number): Promise<{ message: string }> {
|
||||
return api.delete(`/review/trades/${id}`);
|
||||
},
|
||||
alerts(unread = false): Promise<AlertCenter> {
|
||||
return api.get(`/review/alerts?unread=${unread}`);
|
||||
},
|
||||
createAlert(value: { title: string; remind_date: string; code: string; content: string }): Promise<{ id: number }> {
|
||||
return api.post("/review/alerts", value);
|
||||
},
|
||||
markAlert(id: number): Promise<{ message: string }> {
|
||||
return api.patch(`/review/alerts/${id}/read`);
|
||||
},
|
||||
markAllAlerts(): Promise<{ updated: number }> {
|
||||
return api.patch("/review/alerts/read-all");
|
||||
},
|
||||
deleteAlert(id: number): Promise<{ message: string }> {
|
||||
return api.delete(`/review/alerts/${id}`);
|
||||
},
|
||||
assistantMessages(): Promise<AssistantMessage[]> {
|
||||
return api.get("/review/assistant/messages");
|
||||
},
|
||||
clearAssistant(): Promise<{ deleted: number }> {
|
||||
return api.delete("/review/assistant/messages");
|
||||
},
|
||||
chat(date: string, question: string, onEvent: (event: AssistantEvent) => void, signal?: AbortSignal): Promise<void> {
|
||||
return api.stream("/review/assistant/chat", { trade_date: date, question }, onEvent, signal);
|
||||
},
|
||||
};
|
||||
@@ -3,19 +3,26 @@ import ProfilePanel from "../account/ProfilePanel.vue";
|
||||
import MembershipPanel from "../account/MembershipPanel.vue";
|
||||
import PasswordPanel from "../account/PasswordPanel.vue";
|
||||
import SearchPanel from "../account/SearchPanel.vue";
|
||||
import AlertsPanel from "../../pages/review/AlertsPanel.vue";
|
||||
import AssistantPanel from "../../pages/review/AssistantPanel.vue";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import BaseDialog from "./BaseDialog.vue";
|
||||
|
||||
const ui = useUiStore();
|
||||
const titles = { profile: "个人资料", membership: "会员状态", password: "修改密码", search: "全局搜索" } as const;
|
||||
const titles = {
|
||||
profile: "个人资料", membership: "会员状态", password: "修改密码",
|
||||
search: "全局搜索", alerts: "提醒中心", assistant: "复盘助手",
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDialog v-if="ui.dialog" :title="titles[ui.dialog]" :wide="ui.dialog === 'membership' || ui.dialog === 'search'" @close="ui.closeDialog">
|
||||
<BaseDialog v-if="ui.dialog" :title="titles[ui.dialog]" :wide="['membership', 'search', 'alerts', 'assistant'].includes(ui.dialog)" @close="ui.closeDialog">
|
||||
<ProfilePanel v-if="ui.dialog === 'profile'" />
|
||||
<MembershipPanel v-else-if="ui.dialog === 'membership'" />
|
||||
<PasswordPanel v-else-if="ui.dialog === 'password'" />
|
||||
<SearchPanel v-else />
|
||||
<SearchPanel v-else-if="ui.dialog === 'search'" />
|
||||
<AlertsPanel v-else-if="ui.dialog === 'alerts'" />
|
||||
<AssistantPanel v-else />
|
||||
</BaseDialog>
|
||||
<BaseDialog v-else-if="ui.confirmation" :title="ui.confirmation.title" @close="ui.resolveConfirmation(false)">
|
||||
<div class="form-grid">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export type DialogName = "profile" | "membership" | "password" | "search" | null;
|
||||
export type DialogName = "profile" | "membership" | "password" | "search" | "alerts" | "assistant" | null;
|
||||
export type Theme = "light" | "dark";
|
||||
export type Confirmation = { title: string; message: string; confirmLabel: string };
|
||||
|
||||
@@ -23,6 +23,7 @@ export const useUiStore = defineStore("ui", () => {
|
||||
const dialog = ref<DialogName>(null);
|
||||
const confirmation = ref<Confirmation | null>(null);
|
||||
const toast = ref("");
|
||||
const alertUnread = ref(0);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let confirmationResolve: ((value: boolean) => void) | undefined;
|
||||
|
||||
@@ -69,7 +70,7 @@ export const useUiStore = defineStore("ui", () => {
|
||||
}
|
||||
|
||||
return {
|
||||
theme, dialog, confirmation, toast, setTheme, toggleTheme, openDialog, closeDialog,
|
||||
theme, dialog, confirmation, toast, alertUnread, setTheme, toggleTheme, openDialog, closeDialog,
|
||||
askConfirmation, resolveConfirmation, showToast,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -246,6 +246,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.review-main-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.trade-card, .daily-review-card { min-height: auto; }
|
||||
.review-watch-table { max-height: var(--s-320); }
|
||||
.trade-scroll { max-height: var(--s-320); }
|
||||
.trade-form-grid, .alert-create-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.review-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.review-summary > div:last-child { grid-column: 1 / -1; }
|
||||
.review-history-list article, .stock-note-history article { grid-template-columns: minmax(0, 1fr); }
|
||||
.review-history-list p, .review-history-list small,
|
||||
.stock-note-history p, .stock-note-history small { grid-column: 1; }
|
||||
.assistant-messages article { max-width: 94%; }
|
||||
}
|
||||
|
||||
@media (max-width: 1399px) {
|
||||
.auction-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
.review-page {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.review-heading,
|
||||
.review-watch-card .card-header,
|
||||
.trade-card .card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.review-heading > div,
|
||||
.review-watch-card .card-header h2,
|
||||
.trade-card .card-header h2 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.review-watch-table {
|
||||
width: 100%;
|
||||
max-height: var(--s-260);
|
||||
}
|
||||
|
||||
.review-watch-card,
|
||||
.review-main-grid,
|
||||
.review-main-grid > *,
|
||||
.trade-scroll {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.review-watch-table th:first-child,
|
||||
.review-watch-table td:first-child {
|
||||
width: var(--s-44);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.review-watch-table th:nth-child(8),
|
||||
.review-watch-table td:nth-child(8) {
|
||||
min-width: var(--s-200);
|
||||
}
|
||||
|
||||
.watch-star {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.review-remark {
|
||||
width: 100%;
|
||||
min-height: var(--s-30);
|
||||
padding: var(--s-4) var(--s-7);
|
||||
border: var(--s-1) solid var(--c-transparent);
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface-muted);
|
||||
font-size: var(--font-12);
|
||||
}
|
||||
|
||||
.review-remark:focus {
|
||||
border-color: var(--color-primary-border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.review-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(var(--s-360), 0.8fr);
|
||||
gap: var(--layout-gap);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.trade-card,
|
||||
.daily-review-card {
|
||||
min-height: var(--s-480);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.review-card-action {
|
||||
margin-left: var(--s-8);
|
||||
}
|
||||
|
||||
.review-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--s-1);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
background: var(--color-divider);
|
||||
}
|
||||
|
||||
.review-summary > div {
|
||||
display: grid;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-8) var(--s-10);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.review-summary span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.review-summary strong {
|
||||
min-height: var(--s-18);
|
||||
font-size: var(--font-14);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.trade-scroll {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.trade-scroll .data-table {
|
||||
min-width: var(--s-dialog-wide);
|
||||
}
|
||||
|
||||
.daily-review-card {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.daily-review-form {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(var(--s-96), 1fr) minmax(var(--s-80), 0.8fr) auto;
|
||||
gap: var(--s-10);
|
||||
padding: var(--s-14);
|
||||
}
|
||||
|
||||
.daily-review-form .field,
|
||||
.daily-review-form .textarea {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.daily-short {
|
||||
height: var(--s-64);
|
||||
}
|
||||
|
||||
.review-history-toggle {
|
||||
width: 100%;
|
||||
min-height: var(--s-44);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--s-10) var(--s-14);
|
||||
color: var(--color-text);
|
||||
background: var(--c-transparent);
|
||||
font-size: var(--font-13);
|
||||
font-weight: var(--weight-600);
|
||||
}
|
||||
|
||||
.review-history-toggle span:last-child {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
font-weight: var(--weight-400);
|
||||
}
|
||||
|
||||
.review-history-list {
|
||||
max-height: var(--s-320);
|
||||
overflow: auto;
|
||||
border-top: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.review-history-list article,
|
||||
.stock-note-history article {
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-96) minmax(0, 1fr);
|
||||
gap: var(--s-6) var(--s-12);
|
||||
padding: var(--s-12) var(--s-14);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.review-history-list time,
|
||||
.stock-note-history time {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.review-history-list p,
|
||||
.review-history-list small,
|
||||
.stock-note-history p,
|
||||
.stock-note-history small {
|
||||
grid-column: 2;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--s-20);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.watch-search-results {
|
||||
display: grid;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.watch-search-results button {
|
||||
min-height: var(--s-44);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-7) var(--s-10);
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface-muted);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.watch-search-results button:hover {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.watch-search-results button span:first-child {
|
||||
display: grid;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.watch-search-results small {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.trade-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--s-10);
|
||||
}
|
||||
|
||||
.trade-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.alerts-panel,
|
||||
.assistant-panel {
|
||||
display: grid;
|
||||
gap: var(--s-12);
|
||||
}
|
||||
|
||||
.alerts-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.segmented button,
|
||||
.assistant-prompts button {
|
||||
min-height: var(--s-30);
|
||||
padding: var(--s-4) var(--s-10);
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-12);
|
||||
}
|
||||
|
||||
.segmented button.active,
|
||||
.assistant-prompts button:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.alert-create {
|
||||
display: grid;
|
||||
gap: var(--s-10);
|
||||
padding: var(--s-12);
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.alert-create-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(var(--s-160), 0.8fr) minmax(var(--s-120), 0.7fr);
|
||||
gap: var(--s-10);
|
||||
}
|
||||
|
||||
.alert-list {
|
||||
max-height: var(--s-320);
|
||||
overflow: auto;
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
}
|
||||
|
||||
.alert-list article {
|
||||
display: grid;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-12);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.alert-list article.unread {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.alert-list article > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.alert-list p,
|
||||
.alert-list small {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.assistant-panel .notice {
|
||||
display: grid;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.assistant-content {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(var(--s-240), 1fr) auto auto;
|
||||
gap: var(--s-10);
|
||||
}
|
||||
|
||||
.assistant-messages {
|
||||
max-height: var(--s-400);
|
||||
overflow: auto;
|
||||
padding: var(--s-10);
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.assistant-messages article {
|
||||
max-width: 86%;
|
||||
display: grid;
|
||||
gap: var(--s-4);
|
||||
margin-bottom: var(--s-10);
|
||||
padding: var(--s-9) var(--s-12);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.assistant-messages article.user {
|
||||
margin-left: auto;
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.assistant-messages article span,
|
||||
.assistant-empty {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.assistant-messages article p {
|
||||
line-height: var(--s-22);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.assistant-prompts,
|
||||
.assistant-compose > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.assistant-compose {
|
||||
display: grid;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.stock-notes-card {
|
||||
margin-top: var(--layout-gap);
|
||||
}
|
||||
|
||||
.stock-note-form {
|
||||
display: grid;
|
||||
gap: var(--s-10);
|
||||
padding: var(--s-14);
|
||||
}
|
||||
|
||||
.stock-note-history {
|
||||
max-height: var(--s-320);
|
||||
overflow: auto;
|
||||
border-top: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.topbar-alert {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alert-badge {
|
||||
position: absolute;
|
||||
top: calc(var(--s-6) * -1);
|
||||
right: calc(var(--s-8) * -1);
|
||||
min-width: var(--s-18);
|
||||
height: var(--s-18);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 var(--s-4);
|
||||
border-radius: var(--radius-round);
|
||||
color: var(--c-gray-050);
|
||||
background: var(--color-up);
|
||||
font-size: var(--font-10-5);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ module.exports = defineConfig({
|
||||
outputDir: "./data/playwright",
|
||||
timeout: 30_000,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: "line",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:5173",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { expect, test } = require("@playwright/test");
|
||||
|
||||
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-12");
|
||||
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
|
||||
|
||||
async function authenticate(page, username, password) {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("账号名").fill(username);
|
||||
await page.getByLabel("密码").fill(password);
|
||||
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
}
|
||||
}
|
||||
|
||||
function reviewPayload(trades = []) {
|
||||
return {
|
||||
trade_date: "2026-07-30",
|
||||
watchlist: [{ identifier: "000001.SZ", code: "000001", name: "平安银行", sector: "银行", remark: "观察承接", pct_chg: 1.28, return_5d: 3.6, attention_score: 72 }],
|
||||
daily: { id: 1, code: "", stock_name: "", trade_date: "2026-07-30", summary: "缩量分化", content: "按计划等待", plan: "观察主线承接", updated_at: "now" },
|
||||
history: [{ id: 1, code: "", stock_name: "", trade_date: "2026-07-30", summary: "缩量分化", content: "按计划等待", plan: "观察主线承接", updated_at: "now" }],
|
||||
trades,
|
||||
trade_summary: { total: trades.length, realized: trades.length, win_rate: trades.length ? 100 : null, pnl_amount: trades.length ? 120 : null, average_position: trades.length ? 20 : null },
|
||||
};
|
||||
}
|
||||
|
||||
async function mockReview(page) {
|
||||
let trades = [];
|
||||
await page.route("**/api/review?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(reviewPayload(trades)) }));
|
||||
await page.route("**/api/review/trades", async (route) => {
|
||||
const value = route.request().postDataJSON();
|
||||
trades = [{ ...value, id: 11, action_label: "买入", emotion_label: "平静" }];
|
||||
await route.fulfill({ contentType: "application/json", body: JSON.stringify({ id: 11 }) });
|
||||
});
|
||||
await page.route("**/api/review/notes", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ id: 1 }) }));
|
||||
await page.route("**/api/review/watchlist/**", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ message: "已保存" }) }));
|
||||
await page.route("**/api/review/alerts?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ items: [{ id: 1, kind: "manual", title: "复核承接", content: "开盘后观察", available_date: "2026-07-30", code: "000001", is_read: false, due: true, created_at: "now" }], unread_count: 1, as_of: "2026-07-30" }) }));
|
||||
await page.route("**/api/review/alerts", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ id: 2 }) }));
|
||||
await page.route("**/api/review/assistant/messages", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(route.request().method() === "DELETE" ? { deleted: 0 } : []) }));
|
||||
await page.route("**/api/review/assistant/chat", (route) => route.fulfill({ contentType: "application/x-ndjson", body: `${JSON.stringify({ type: "delta", content: "【市场事实】温度回落。" })}\n${JSON.stringify({ type: "delta", content: "【条件化计划】等待承接确认。" })}\n${JSON.stringify({ type: "done" })}\n` }));
|
||||
await page.route("**/api/review/stock-notes/**", (route) => route.fulfill({ contentType: "application/json", body: "[]" }));
|
||||
}
|
||||
|
||||
test("stage 12 private review, alerts and assistant remain usable", async ({ page }) => {
|
||||
const consoleErrors = [];
|
||||
page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); });
|
||||
await mockReview(page);
|
||||
await authenticate(page, "stage4admin", "Stage4-pass-123!");
|
||||
await page.goto("/workspace/review");
|
||||
await expect(page.getByRole("heading", { name: "我的复盘" })).toBeVisible();
|
||||
await expect(page.getByText("平安银行", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("跟踪备注")).toHaveValue("观察承接");
|
||||
await expect(page.getByLabel("今日盘面一句话")).toHaveValue("缩量分化");
|
||||
await expect(page.getByLabel("今日做对了什么 / 做错了什么")).toHaveValue("按计划等待");
|
||||
await expect(page.getByLabel("明日策略")).toHaveValue("观察主线承接");
|
||||
|
||||
await page.getByRole("button", { name: "交易日志", exact: true }).click();
|
||||
const tradeDialog = page.getByRole("dialog", { name: "交易日志" });
|
||||
await tradeDialog.getByLabel("代码").fill("000001");
|
||||
await tradeDialog.getByLabel("名称").fill("平安银行");
|
||||
await tradeDialog.getByLabel("价格(元)").fill("10.20");
|
||||
await tradeDialog.getByLabel("仓位(%)").fill("20");
|
||||
await tradeDialog.getByLabel("盈亏(%)").fill("1.2");
|
||||
await tradeDialog.getByRole("button", { name: "保存", exact: true }).click();
|
||||
await expect(tradeDialog).toHaveCount(0);
|
||||
await expect(page.getByRole("status")).toContainText("交易记录已保存");
|
||||
await expect(page.locator(".trade-scroll").getByText("平安银行", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".dialog")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "提醒中心" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "提醒中心" })).toContainText("复核承接");
|
||||
await page.getByLabel("关闭").click();
|
||||
await page.getByRole("button", { name: "复盘助手" }).click();
|
||||
const assistant = page.getByRole("dialog", { name: "复盘助手" });
|
||||
await assistant.getByRole("button", { name: "市场位置" }).click();
|
||||
await assistant.getByRole("button", { name: "发送", exact: true }).click();
|
||||
await expect(assistant.getByText("【市场事实】温度回落。【条件化计划】等待承接确认。", { exact: true })).toBeVisible();
|
||||
await page.getByLabel("关闭").click();
|
||||
await page.getByRole("button", { name: "夜间" }).click();
|
||||
await page.screenshot({ path: path.join(evidence, "review-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
|
||||
await expect(page.locator(".mobile-nav")).toBeVisible();
|
||||
await page.screenshot({ path: path.join(evidence, "review-dark-390x844.jpg"), type: "jpeg", quality: 82 });
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("nonmembers see the complete disabled review assistant", async ({ page }) => {
|
||||
await mockReview(page);
|
||||
await authenticate(page, "stage4user", "Stage4-user-123!");
|
||||
await page.getByRole("button", { name: "复盘助手" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "复盘助手" });
|
||||
await expect(dialog.getByText("复盘助手仅对会员开放")).toBeVisible();
|
||||
await expect(dialog.getByPlaceholder(/询问市场位置/)).toBeDisabled();
|
||||
await expect(dialog.getByRole("button", { name: "市场位置" })).toBeVisible();
|
||||
});
|
||||
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
||||
database = Database(tmp_path / "app.db")
|
||||
runner = MigrationRunner(database)
|
||||
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9)
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
|
||||
assert {
|
||||
"users",
|
||||
"memberships",
|
||||
@@ -140,8 +140,23 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
||||
"llm_requests",
|
||||
"llm_attempts",
|
||||
"heaven_readings",
|
||||
"review_notes",
|
||||
"trade_entries",
|
||||
"alerts",
|
||||
"review_assistant_messages",
|
||||
} <= table_names(database)
|
||||
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (9, 8, 7, 6, 5, 4, 3, 2, 1)
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (
|
||||
10,
|
||||
9,
|
||||
8,
|
||||
7,
|
||||
6,
|
||||
5,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
)
|
||||
assert "users" not in table_names(database)
|
||||
assert "llm_models" not in table_names(database)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.database import MIGRATIONS, Database, MigrationRunner
|
||||
from backend.features.review.prompt import messages
|
||||
from backend.features.review.repository import ReviewRepository
|
||||
from backend.features.review.views import trade_summary
|
||||
|
||||
|
||||
def _database(tmp_path) -> Database:
|
||||
database = Database(tmp_path / "review.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
with database.transaction() as connection:
|
||||
connection.executemany(
|
||||
"""INSERT INTO users (
|
||||
id, username, username_key, password_hash, is_admin,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'hash', 0, 'active', 'now', 'now')""",
|
||||
((1, "first", "first"), (2, "second", "second")),
|
||||
)
|
||||
return database
|
||||
|
||||
|
||||
def test_private_review_records_are_strictly_scoped_and_daily_notes_upsert(tmp_path) -> None:
|
||||
database = _database(tmp_path)
|
||||
repository = ReviewRepository()
|
||||
note = {
|
||||
"code": "",
|
||||
"stock_name": "",
|
||||
"trade_date": "2026-07-30",
|
||||
"summary": "缩量分化",
|
||||
"content": "追高一次",
|
||||
"plan": "等待承接",
|
||||
}
|
||||
trade = {
|
||||
"trade_date": "2026-07-30",
|
||||
"code": "000001",
|
||||
"name": "平安银行",
|
||||
"action": "buy",
|
||||
"price": 10.0,
|
||||
"quantity": 100,
|
||||
"position_pct": 20.0,
|
||||
"pnl_amount": None,
|
||||
"pnl_pct": None,
|
||||
"emotion": "calm",
|
||||
"tags": ["计划内"],
|
||||
"thesis": "承接",
|
||||
"execution": "符合",
|
||||
"id": None,
|
||||
}
|
||||
with database.transaction() as connection:
|
||||
repository.save_watch(
|
||||
connection,
|
||||
1,
|
||||
{
|
||||
"identifier": "000001.SZ",
|
||||
"name": "平安银行",
|
||||
"sector": "银行",
|
||||
},
|
||||
"now",
|
||||
)
|
||||
repository.save_watch(
|
||||
connection,
|
||||
2,
|
||||
{
|
||||
"identifier": "600000.SH",
|
||||
"name": "浦发银行",
|
||||
"sector": "银行",
|
||||
},
|
||||
"now",
|
||||
)
|
||||
assert repository.save_watch_remark(connection, 1, "000001.SZ", "观察承接")
|
||||
repository.save_watch(
|
||||
connection,
|
||||
1,
|
||||
{"identifier": "000001.SZ", "name": "平安银行", "sector": "银行"},
|
||||
"later",
|
||||
)
|
||||
note_id = repository.save_note(connection, 1, note, "now")
|
||||
note["summary"] = "更新后的盘面"
|
||||
assert repository.save_note(connection, 1, note, "later") == note_id
|
||||
trade_id = repository.save_trade(connection, 1, trade, "now")
|
||||
repository.save_alert(
|
||||
connection,
|
||||
1,
|
||||
{
|
||||
"kind": "manual",
|
||||
"title": "复核",
|
||||
"content": "看承接",
|
||||
"available_date": "2026-07-30",
|
||||
"code": "000001",
|
||||
"dedupe_key": "one",
|
||||
},
|
||||
"now",
|
||||
)
|
||||
repository.add_message(
|
||||
connection,
|
||||
1,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "今天如何",
|
||||
"context_date": "2026-07-30",
|
||||
"request_id": None,
|
||||
"status": "complete",
|
||||
},
|
||||
"now",
|
||||
)
|
||||
with database.read() as connection:
|
||||
assert [row["identifier"] for row in repository.watchlist(connection, 1)] == ["000001.SZ"]
|
||||
assert repository.watchlist(connection, 1)[0]["remark"] == "观察承接"
|
||||
assert [row["identifier"] for row in repository.watchlist(connection, 2)] == ["600000.SH"]
|
||||
assert repository.note(connection, 1, "", "2026-07-30")["summary"] == "更新后的盘面"
|
||||
assert repository.note(connection, 2, "", "2026-07-30") is None
|
||||
assert [row["id"] for row in repository.trades(connection, 1)] == [trade_id]
|
||||
assert repository.trades(connection, 2) == ()
|
||||
assert len(repository.alerts(connection, 1, False)) == 1
|
||||
assert repository.alerts(connection, 2, False) == ()
|
||||
assert len(repository.messages(connection, 1)) == 1
|
||||
assert repository.messages(connection, 2) == ()
|
||||
|
||||
|
||||
def test_trade_summary_uses_only_realized_fields_and_keeps_zero_in_denominator() -> None:
|
||||
rows = [
|
||||
{"pnl_amount": None, "pnl_pct": None, "position_pct": None},
|
||||
{"pnl_amount": 100.0, "pnl_pct": None, "position_pct": 20.0},
|
||||
{"pnl_amount": None, "pnl_pct": 0.0, "position_pct": 40.0},
|
||||
{"pnl_amount": -20.0, "pnl_pct": -2.0, "position_pct": 60.0},
|
||||
]
|
||||
summary = trade_summary(rows)
|
||||
assert summary == {
|
||||
"total": 4,
|
||||
"realized": 3,
|
||||
"win_rate": 33.3,
|
||||
"pnl_amount": 80.0,
|
||||
"average_position": 40.0,
|
||||
}
|
||||
|
||||
|
||||
def test_review_prompt_separates_facts_records_inference_and_plan() -> None:
|
||||
prompt = messages({"market_facts": {"temperature": 42}}, [], "明天怎么看")
|
||||
system = prompt[0]["content"]
|
||||
assert all(label in system for label in ("市场事实", "用户记录", "推断", "条件化计划"))
|
||||
assert "不执行交易" in system
|
||||
Reference in New Issue
Block a user