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)
|
||||
|
||||
Reference in New Issue
Block a user