diff --git a/app/db.py b/app/db.py index e7799a7..d2e76fe 100644 --- a/app/db.py +++ b/app/db.py @@ -91,6 +91,7 @@ def init_db() -> None: account_id INTEGER, machine_id INTEGER, entry_style TEXT DEFAULT '', + review_issues TEXT DEFAULT '', review_note TEXT DEFAULT '', snapshot_json TEXT DEFAULT '{}', created_at REAL NOT NULL, @@ -457,6 +458,35 @@ ENTRY_STYLES = ( + ["其他 · 乱作"] ) +# 复盘主要问题(少而狠):最多勾 1~2 个 +REVIEW_ISSUES = ("位不对", "形不对", "时机差", "出场违纪", "情绪单") +REVIEW_ISSUES_MAX = 2 + + +def parse_review_issues(raw: Any) -> list[str]: + """逗号分隔 → 有序列表,仅保留标准项。""" + if not raw: + return [] + if isinstance(raw, (list, tuple)): + parts = [str(x).strip() for x in raw] + else: + parts = [p.strip() for p in str(raw).replace(",", ",").split(",")] + allowed = set(REVIEW_ISSUES) + seen: set[str] = set() + out: list[str] = [] + for p in parts: + if p in allowed and p not in seen: + seen.add(p) + out.append(p) + # 按标准顺序排,方便展示一致 + order = {name: i for i, name in enumerate(REVIEW_ISSUES)} + out.sort(key=lambda x: order.get(x, 99)) + return out + + +def serialize_review_issues(items: Any) -> str: + return ",".join(parse_review_issues(items)[:REVIEW_ISSUES_MAX]) + def entry_category(style: str) -> str: s = (style or "").strip() @@ -560,7 +590,7 @@ def trade_stats( clause, params = _trades_where(account_id, machine_id) sql = f""" SELECT t.id, t.side, t.symbol, t.profit, t.open_time, t.close_time, - t.volume, t.machine_id, t.entry_style, t.snapshot_json, + t.volume, t.machine_id, t.entry_style, t.review_issues, t.snapshot_json, m.name AS machine_name FROM trades t LEFT JOIN machines m ON m.id = t.machine_id @@ -589,6 +619,8 @@ def trade_stats( k: _empty_group(k) for k in ("顺势", "反转", "其他", "未标注", "旧分类") } by_style: dict[str, dict[str, Any]] = {s: _empty_group(s) for s in ENTRY_STYLES} + by_issue: dict[str, dict[str, Any]] = {s: _empty_group(s) for s in REVIEW_ISSUES} + issue_tagged = 0 rows: list[dict] = [] skipped_synthetic = 0 @@ -678,6 +710,10 @@ def trade_stats( if style: annotated += 1 + issues = parse_review_issues(t.get("review_issues")) + if issues: + issue_tagged += 1 + # 分组也只累加已平仓盈亏,避免浮盈干扰分类 cat = entry_category(style) _accumulate(by_category[cat], profit, hold if closed_flag else None) @@ -690,6 +726,10 @@ def trade_stats( _accumulate(by_style[key], profit, hold if closed_flag else None) if closed_flag: + for iss in issues: + if iss in by_issue: + _accumulate(by_issue[iss], profit, hold) + bk = _bucket_key(hold) _accumulate(by_hold[bk], profit, hold) @@ -727,6 +767,7 @@ def trade_stats( "avg_hold": _fmt_hold_secs(avg_hold), "annotated": annotated, "unannotated": total - annotated, + "issue_tagged": issue_tagged, "skipped_synthetic": skipped_synthetic, "skipped_dup_ticket": skipped_dup_ticket, "period": period, @@ -738,6 +779,11 @@ def trade_stats( reverse=True, ), "by_side": [_finalize_group(g) for g in by_side.values()], + "by_issue": [ + _finalize_group(by_issue[k]) + for k in REVIEW_ISSUES + if by_issue[k]["count"] + ], "by_category": [ _finalize_group(by_category[k]) for k in ("顺势", "反转", "其他", "未标注", "旧分类") @@ -858,6 +904,13 @@ def seed_if_empty() -> None: except sqlite3.OperationalError: pass + trade_cols = {r["name"] for r in conn.execute("PRAGMA table_info(trades)").fetchall()} + if "review_issues" not in trade_cols: + try: + conn.execute("ALTER TABLE trades ADD COLUMN review_issues TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass + n_users = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"] if n_users == 0: # 迁移旧 settings 密码,或使用 .env 默认 diff --git a/app/routes.py b/app/routes.py index 42197de..de1d0f3 100644 --- a/app/routes.py +++ b/app/routes.py @@ -278,6 +278,7 @@ def register_routes(app: Flask) -> None: t["hold_duration"] = _fmt_hold(t.get("open_time"), t.get("close_time")) if not closed and t.get("open_time"): t["hold_duration"] = t["hold_duration"] + "(持仓中)" + t["review_issues_list"] = database.parse_review_issues(t.get("review_issues")) def _dashboard_payload(u: dict) -> dict: # 总览只看当前持仓(未平仓),历史成交在「交易复盘」 @@ -1005,12 +1006,17 @@ def register_routes(app: Flask) -> None: mid - span / 2.0, mid + span / 2.0, ) + trade["review_issues_list"] = database.parse_review_issues( + trade.get("review_issues") + ) return render_template( "record_detail.html", trade=trade, charts=charts, entry_styles=database.ENTRY_STYLES, entry_patterns=database.ENTRY_PATTERN_TYPES, + review_issues=database.REVIEW_ISSUES, + review_issues_max=database.REVIEW_ISSUES_MAX, ) @app.post("/records//annotate") @@ -1019,6 +1025,12 @@ def register_routes(app: Flask) -> None: u = current_user() entry_style = (request.form.get("entry_style") or "").strip() note = (request.form.get("review_note") or "").strip() + raw_issues = request.form.getlist("review_issues") + issues = database.parse_review_issues(raw_issues) + if len(issues) > database.REVIEW_ISSUES_MAX: + flash(f"主要问题最多选 {database.REVIEW_ISSUES_MAX} 个", "error") + return redirect(url_for("record_detail", tid=tid)) + issues_s = database.serialize_review_issues(issues) allowed = set(database.ENTRY_STYLES) with database.get_db() as conn: trade = database.get_trade(conn, tid) @@ -1031,8 +1043,8 @@ def register_routes(app: Flask) -> None: flash("入场形式不在可选列表中", "error") return redirect(url_for("record_detail", tid=tid)) conn.execute( - "UPDATE trades SET entry_style=?, review_note=? WHERE id=?", - (entry_style, note, tid), + "UPDATE trades SET entry_style=?, review_issues=?, review_note=? WHERE id=?", + (entry_style, issues_s, note, tid), ) flash("复盘标注已保存", "ok") return redirect(url_for("record_detail", tid=tid)) diff --git a/app/static/css/app.css b/app/static/css/app.css index 10c7b1b..5052290 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -94,6 +94,34 @@ input, select, textarea { input:focus, select:focus, textarea:focus { border-color: var(--accent); } label { display: block; margin: 0 0 6px; color: #a9a9ff; font-size: 13px; } .form-row { margin-bottom: 12px; } +.issue-checks { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.issue-chip { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: #161622; + color: var(--text); + font-size: 13px; + cursor: pointer; + user-select: none; +} +.issue-chip input { + accent-color: var(--accent); + margin: 0; +} +.issue-chip:has(input:checked) { + border-color: rgba(246, 70, 93, 0.55); + background: rgba(246, 70, 93, 0.12); + color: #ffb4bc; +} table { width: 100%; border-collapse: collapse; font-size: 13px; } th, td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: left; } diff --git a/app/templates/record_detail.html b/app/templates/record_detail.html index faf0ea7..0616fb3 100644 --- a/app/templates/record_detail.html +++ b/app/templates/record_detail.html @@ -28,8 +28,8 @@
-

入场形式标注

-
+

复盘标注

+
-

顺势/反转均含:窄幅区间突破、宽幅区间假突破、波段高低点假突破;对不上的选「乱作」。

+

管「按什么形进的」;对不上选乱作。

- - + +
+ {% for iss in review_issues %} + + {% endfor %} +
+
+
+ +
@@ -103,4 +115,19 @@ {% block scripts %} + {% endblock %} diff --git a/app/templates/records.html b/app/templates/records.html index 2f5c902..b03d97c 100644 --- a/app/templates/records.html +++ b/app/templates/records.html @@ -46,6 +46,7 @@ 账户 机器 入场形式 + 主要问题 @@ -67,10 +68,17 @@ {{ t.account_name or '—' }} {{ t.machine_name or '—' }} {{ t.entry_style or '—' }} + + {% if t.review_issues_list %} + {% for iss in t.review_issues_list %}{{ iss }}{% endfor %} + {% else %} + — + {% endif %} + 打开复盘 {% else %} - 暂无数据。{% if selected_machine_id %}当前机器下没有成交。{% else %}客户端上报成交后出现在此。{% endif %} + 暂无数据。{% if selected_machine_id %}当前机器下没有成交。{% else %}客户端上报成交后出现在此。{% endif %} {% endfor %} diff --git a/app/templates/records_stats.html b/app/templates/records_stats.html index 4075d62..a5b5bd7 100644 --- a/app/templates/records_stats.html +++ b/app/templates/records_stats.html @@ -75,7 +75,7 @@
入场形式标注
{{ stats.annotated }} / {{ stats.total }}
-
未标注 {{ stats.unannotated }}
+
未标注 {{ stats.unannotated }} · 问题标注 {{ stats.issue_tagged }}
@@ -185,6 +185,33 @@ +
+

按主要问题

+

一笔可勾 1~2 个问题,会分别计入对应行。

+ + + + + + + + {% for g in stats.by_issue %} + + + + + + + + {% else %} + + {% endfor %} + +
问题笔数胜率盈亏平均持仓
{{ g.label }}{{ g.count }}{{ '%.1f'|format(g.win_rate) }}% + {{ '%.2f'|format(g.profit) }} + {{ g.avg_hold }}
暂无问题标注
+
+

按机器