Add lean review issues: up to two problem tags plus short note.
Persist review_issues, enforce max two on save, and show issue breakdown in stats. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -91,6 +91,7 @@ def init_db() -> None:
|
|||||||
account_id INTEGER,
|
account_id INTEGER,
|
||||||
machine_id INTEGER,
|
machine_id INTEGER,
|
||||||
entry_style TEXT DEFAULT '',
|
entry_style TEXT DEFAULT '',
|
||||||
|
review_issues TEXT DEFAULT '',
|
||||||
review_note TEXT DEFAULT '',
|
review_note TEXT DEFAULT '',
|
||||||
snapshot_json TEXT DEFAULT '{}',
|
snapshot_json TEXT DEFAULT '{}',
|
||||||
created_at REAL NOT NULL,
|
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:
|
def entry_category(style: str) -> str:
|
||||||
s = (style or "").strip()
|
s = (style or "").strip()
|
||||||
@@ -560,7 +590,7 @@ def trade_stats(
|
|||||||
clause, params = _trades_where(account_id, machine_id)
|
clause, params = _trades_where(account_id, machine_id)
|
||||||
sql = f"""
|
sql = f"""
|
||||||
SELECT t.id, t.side, t.symbol, t.profit, t.open_time, t.close_time,
|
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
|
m.name AS machine_name
|
||||||
FROM trades t
|
FROM trades t
|
||||||
LEFT JOIN machines m ON m.id = t.machine_id
|
LEFT JOIN machines m ON m.id = t.machine_id
|
||||||
@@ -589,6 +619,8 @@ def trade_stats(
|
|||||||
k: _empty_group(k) for k in ("顺势", "反转", "其他", "未标注", "旧分类")
|
k: _empty_group(k) for k in ("顺势", "反转", "其他", "未标注", "旧分类")
|
||||||
}
|
}
|
||||||
by_style: dict[str, dict[str, Any]] = {s: _empty_group(s) for s in ENTRY_STYLES}
|
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] = []
|
rows: list[dict] = []
|
||||||
skipped_synthetic = 0
|
skipped_synthetic = 0
|
||||||
@@ -678,6 +710,10 @@ def trade_stats(
|
|||||||
if style:
|
if style:
|
||||||
annotated += 1
|
annotated += 1
|
||||||
|
|
||||||
|
issues = parse_review_issues(t.get("review_issues"))
|
||||||
|
if issues:
|
||||||
|
issue_tagged += 1
|
||||||
|
|
||||||
# 分组也只累加已平仓盈亏,避免浮盈干扰分类
|
# 分组也只累加已平仓盈亏,避免浮盈干扰分类
|
||||||
cat = entry_category(style)
|
cat = entry_category(style)
|
||||||
_accumulate(by_category[cat], profit, hold if closed_flag else None)
|
_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)
|
_accumulate(by_style[key], profit, hold if closed_flag else None)
|
||||||
|
|
||||||
if closed_flag:
|
if closed_flag:
|
||||||
|
for iss in issues:
|
||||||
|
if iss in by_issue:
|
||||||
|
_accumulate(by_issue[iss], profit, hold)
|
||||||
|
|
||||||
bk = _bucket_key(hold)
|
bk = _bucket_key(hold)
|
||||||
_accumulate(by_hold[bk], profit, hold)
|
_accumulate(by_hold[bk], profit, hold)
|
||||||
|
|
||||||
@@ -727,6 +767,7 @@ def trade_stats(
|
|||||||
"avg_hold": _fmt_hold_secs(avg_hold),
|
"avg_hold": _fmt_hold_secs(avg_hold),
|
||||||
"annotated": annotated,
|
"annotated": annotated,
|
||||||
"unannotated": total - annotated,
|
"unannotated": total - annotated,
|
||||||
|
"issue_tagged": issue_tagged,
|
||||||
"skipped_synthetic": skipped_synthetic,
|
"skipped_synthetic": skipped_synthetic,
|
||||||
"skipped_dup_ticket": skipped_dup_ticket,
|
"skipped_dup_ticket": skipped_dup_ticket,
|
||||||
"period": period,
|
"period": period,
|
||||||
@@ -738,6 +779,11 @@ def trade_stats(
|
|||||||
reverse=True,
|
reverse=True,
|
||||||
),
|
),
|
||||||
"by_side": [_finalize_group(g) for g in by_side.values()],
|
"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": [
|
"by_category": [
|
||||||
_finalize_group(by_category[k])
|
_finalize_group(by_category[k])
|
||||||
for k in ("顺势", "反转", "其他", "未标注", "旧分类")
|
for k in ("顺势", "反转", "其他", "未标注", "旧分类")
|
||||||
@@ -858,6 +904,13 @@ def seed_if_empty() -> None:
|
|||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
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"]
|
n_users = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"]
|
||||||
if n_users == 0:
|
if n_users == 0:
|
||||||
# 迁移旧 settings 密码,或使用 .env 默认
|
# 迁移旧 settings 密码,或使用 .env 默认
|
||||||
|
|||||||
+14
-2
@@ -278,6 +278,7 @@ def register_routes(app: Flask) -> None:
|
|||||||
t["hold_duration"] = _fmt_hold(t.get("open_time"), t.get("close_time"))
|
t["hold_duration"] = _fmt_hold(t.get("open_time"), t.get("close_time"))
|
||||||
if not closed and t.get("open_time"):
|
if not closed and t.get("open_time"):
|
||||||
t["hold_duration"] = t["hold_duration"] + "(持仓中)"
|
t["hold_duration"] = t["hold_duration"] + "(持仓中)"
|
||||||
|
t["review_issues_list"] = database.parse_review_issues(t.get("review_issues"))
|
||||||
|
|
||||||
def _dashboard_payload(u: dict) -> dict:
|
def _dashboard_payload(u: dict) -> dict:
|
||||||
# 总览只看当前持仓(未平仓),历史成交在「交易复盘」
|
# 总览只看当前持仓(未平仓),历史成交在「交易复盘」
|
||||||
@@ -1005,12 +1006,17 @@ def register_routes(app: Flask) -> None:
|
|||||||
mid - span / 2.0,
|
mid - span / 2.0,
|
||||||
mid + span / 2.0,
|
mid + span / 2.0,
|
||||||
)
|
)
|
||||||
|
trade["review_issues_list"] = database.parse_review_issues(
|
||||||
|
trade.get("review_issues")
|
||||||
|
)
|
||||||
return render_template(
|
return render_template(
|
||||||
"record_detail.html",
|
"record_detail.html",
|
||||||
trade=trade,
|
trade=trade,
|
||||||
charts=charts,
|
charts=charts,
|
||||||
entry_styles=database.ENTRY_STYLES,
|
entry_styles=database.ENTRY_STYLES,
|
||||||
entry_patterns=database.ENTRY_PATTERN_TYPES,
|
entry_patterns=database.ENTRY_PATTERN_TYPES,
|
||||||
|
review_issues=database.REVIEW_ISSUES,
|
||||||
|
review_issues_max=database.REVIEW_ISSUES_MAX,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.post("/records/<int:tid>/annotate")
|
@app.post("/records/<int:tid>/annotate")
|
||||||
@@ -1019,6 +1025,12 @@ def register_routes(app: Flask) -> None:
|
|||||||
u = current_user()
|
u = current_user()
|
||||||
entry_style = (request.form.get("entry_style") or "").strip()
|
entry_style = (request.form.get("entry_style") or "").strip()
|
||||||
note = (request.form.get("review_note") 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)
|
allowed = set(database.ENTRY_STYLES)
|
||||||
with database.get_db() as conn:
|
with database.get_db() as conn:
|
||||||
trade = database.get_trade(conn, tid)
|
trade = database.get_trade(conn, tid)
|
||||||
@@ -1031,8 +1043,8 @@ def register_routes(app: Flask) -> None:
|
|||||||
flash("入场形式不在可选列表中", "error")
|
flash("入场形式不在可选列表中", "error")
|
||||||
return redirect(url_for("record_detail", tid=tid))
|
return redirect(url_for("record_detail", tid=tid))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE trades SET entry_style=?, review_note=? WHERE id=?",
|
"UPDATE trades SET entry_style=?, review_issues=?, review_note=? WHERE id=?",
|
||||||
(entry_style, note, tid),
|
(entry_style, issues_s, note, tid),
|
||||||
)
|
)
|
||||||
flash("复盘标注已保存", "ok")
|
flash("复盘标注已保存", "ok")
|
||||||
return redirect(url_for("record_detail", tid=tid))
|
return redirect(url_for("record_detail", tid=tid))
|
||||||
|
|||||||
@@ -94,6 +94,34 @@ input, select, textarea {
|
|||||||
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||||
label { display: block; margin: 0 0 6px; color: #a9a9ff; font-size: 13px; }
|
label { display: block; margin: 0 0 6px; color: #a9a9ff; font-size: 13px; }
|
||||||
.form-row { margin-bottom: 12px; }
|
.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; }
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
th, td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: left; }
|
th, td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: left; }
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>入场形式标注</h3>
|
<h3>复盘标注</h3>
|
||||||
<form method="post" action="{{ url_for('record_annotate', tid=trade.id) }}">
|
<form method="post" action="{{ url_for('record_annotate', tid=trade.id) }}" id="annotate-form">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>入场形式</label>
|
<label>入场形式</label>
|
||||||
<select name="entry_style">
|
<select name="entry_style">
|
||||||
@@ -55,11 +55,23 @@
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
<p class="muted" style="margin:8px 0 0">顺势/反转均含:窄幅区间突破、宽幅区间假突破、波段高低点假突破;对不上的选「乱作」。</p>
|
<p class="muted" style="margin:8px 0 0">管「按什么形进的」;对不上选乱作。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>复盘备注</label>
|
<label>主要问题 <span class="muted">(最多 {{ review_issues_max }} 个,做对可不勾)</span></label>
|
||||||
<textarea name="review_note" rows="5" placeholder="位不对 / 形不对 / 出场违纪 …">{{ trade.review_note or '' }}</textarea>
|
<div class="issue-checks" id="issue-checks" data-max="{{ review_issues_max }}">
|
||||||
|
{% for iss in review_issues %}
|
||||||
|
<label class="issue-chip">
|
||||||
|
<input type="checkbox" name="review_issues" value="{{ iss }}"
|
||||||
|
{% if iss in trade.review_issues_list %}checked{% endif %}>
|
||||||
|
<span>{{ iss }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>短备注</label>
|
||||||
|
<textarea name="review_note" rows="3" placeholder="补一句细节即可,如「假突破没等回踩」">{{ trade.review_note or '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" type="submit">保存标注</button>
|
<button class="btn" type="submit">保存标注</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -103,4 +115,19 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var box = document.getElementById("issue-checks");
|
||||||
|
if (!box) return;
|
||||||
|
var maxN = parseInt(box.dataset.max || "2", 10) || 2;
|
||||||
|
box.addEventListener("change", function (e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (!t || t.type !== "checkbox") return;
|
||||||
|
var checked = box.querySelectorAll('input[type="checkbox"]:checked');
|
||||||
|
if (checked.length <= maxN) return;
|
||||||
|
t.checked = false;
|
||||||
|
alert("主要问题最多选 " + maxN + " 个");
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
<th>账户</th>
|
<th>账户</th>
|
||||||
<th>机器</th>
|
<th>机器</th>
|
||||||
<th>入场形式</th>
|
<th>入场形式</th>
|
||||||
|
<th>主要问题</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -67,10 +68,17 @@
|
|||||||
<td>{{ t.account_name or '—' }}</td>
|
<td>{{ t.account_name or '—' }}</td>
|
||||||
<td>{{ t.machine_name or '—' }}</td>
|
<td>{{ t.machine_name or '—' }}</td>
|
||||||
<td>{{ t.entry_style or '—' }}</td>
|
<td>{{ t.entry_style or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if t.review_issues_list %}
|
||||||
|
{% for iss in t.review_issues_list %}<span class="tag red" style="margin:1px 2px">{{ iss }}</span>{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td><a href="{{ url_for('record_detail', tid=t.id) }}">打开复盘</a></td>
|
<td><a href="{{ url_for('record_detail', tid=t.id) }}">打开复盘</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="14" class="muted">暂无数据。{% if selected_machine_id %}当前机器下没有成交。{% else %}客户端上报成交后出现在此。{% endif %}</td></tr>
|
<tr><td colspan="15" class="muted">暂无数据。{% if selected_machine_id %}当前机器下没有成交。{% else %}客户端上报成交后出现在此。{% endif %}</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="muted">入场形式标注</div>
|
<div class="muted">入场形式标注</div>
|
||||||
<div class="stat" style="font-size:22px">{{ stats.annotated }} <span class="muted" style="font-size:14px">/ {{ stats.total }}</span></div>
|
<div class="stat" style="font-size:22px">{{ stats.annotated }} <span class="muted" style="font-size:14px">/ {{ stats.total }}</span></div>
|
||||||
<div class="muted" style="margin-top:6px">未标注 {{ stats.unannotated }}</div>
|
<div class="muted" style="margin-top:6px">未标注 {{ stats.unannotated }} · 问题标注 {{ stats.issue_tagged }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,6 +185,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:14px">
|
||||||
|
<h3>按主要问题</h3>
|
||||||
|
<p class="muted" style="margin:0 0 10px">一笔可勾 1~2 个问题,会分别计入对应行。</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>问题</th><th>笔数</th><th>胜率</th><th>盈亏</th><th>平均持仓</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for g in stats.by_issue %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ g.label }}</td>
|
||||||
|
<td>{{ g.count }}</td>
|
||||||
|
<td>{{ '%.1f'|format(g.win_rate) }}%</td>
|
||||||
|
<td style="color:{% if g.profit>0 %}var(--green){% elif g.profit<0 %}var(--red){% else %}inherit{% endif %}">
|
||||||
|
{{ '%.2f'|format(g.profit) }}
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ g.avg_hold }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="muted">暂无问题标注</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:14px">
|
<div class="card" style="margin-top:14px">
|
||||||
<h3>按机器</h3>
|
<h3>按机器</h3>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
Reference in New Issue
Block a user