feat: auto sick tag in archive from journal mood issues
内照明心同步时匹配实例复盘情绪标签,自动标注犯病并锁定标签;中控不再手动选犯病。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -468,7 +468,23 @@ def _trade_row_to_dict(row: sqlite3.Row, overlay: dict | None = None) -> dict[st
|
|||||||
if key in d and d[key] not in (None, ""):
|
if key in d and d[key] not in (None, ""):
|
||||||
out[key] = d[key]
|
out[key] = d[key]
|
||||||
ov = overlay or {}
|
ov = overlay or {}
|
||||||
out["behavior_tag"] = ov.get("behavior_tag") or ""
|
from lib.trade.account_risk_lib import parse_mood_issues
|
||||||
|
|
||||||
|
journal_issues = parse_mood_issues(
|
||||||
|
out.get("journal_mood_issues") or payload.get("journal_mood_issues")
|
||||||
|
)
|
||||||
|
tag_from_journal = bool(
|
||||||
|
out.get("journal_mood_sick")
|
||||||
|
or payload.get("journal_mood_sick")
|
||||||
|
or journal_issues
|
||||||
|
)
|
||||||
|
tag = (ov.get("behavior_tag") or "").strip().lower()
|
||||||
|
if tag_from_journal:
|
||||||
|
tag = "sick"
|
||||||
|
out["behavior_tag"] = tag
|
||||||
|
out["behavior_tag_from_journal"] = tag_from_journal
|
||||||
|
if journal_issues:
|
||||||
|
out["journal_mood_issues"] = journal_issues
|
||||||
out["note"] = ov.get("note") or ""
|
out["note"] = ov.get("note") or ""
|
||||||
out["trade_id"] = out.get("trade_id") or out.get("id")
|
out["trade_id"] = out.get("trade_id") or out.get("id")
|
||||||
ex_col = str(d.get("exchange_key") or "").strip().lower()
|
ex_col = str(d.get("exchange_key") or "").strip().lower()
|
||||||
@@ -551,6 +567,31 @@ def upsert_trade_overlay(
|
|||||||
return {"exchange_key": ex_k, "trade_id": tid, "behavior_tag": tag, "note": note_text}
|
return {"exchange_key": ex_k, "trade_id": tid, "behavior_tag": tag, "note": note_text}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_journal_behavior_overlays(
|
||||||
|
exchange_key: str,
|
||||||
|
trades: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""复盘情绪标签 → 写入 trade_overlay.behavior_tag=sick(仅正 trade_id)。"""
|
||||||
|
ex_k = (exchange_key or "").strip().lower()
|
||||||
|
if not ex_k:
|
||||||
|
return 0
|
||||||
|
n = 0
|
||||||
|
for t in trades or []:
|
||||||
|
if not isinstance(t, dict) or not t.get("journal_mood_sick"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
tid = int(t.get("id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if tid <= 0:
|
||||||
|
continue
|
||||||
|
upsert_trade_overlay(ex_k, tid, behavior_tag="sick", db_path=db_path)
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def list_symbol_rows(
|
def list_symbol_rows(
|
||||||
*,
|
*,
|
||||||
exchange_key: str = "",
|
exchange_key: str = "",
|
||||||
@@ -1154,6 +1195,7 @@ def sync_exchange_symbol_archives(
|
|||||||
"""同步单所:交易缓存 + 各币种 K 线种子/增量。"""
|
"""同步单所:交易缓存 + 各币种 K 线种子/增量。"""
|
||||||
ex_k = (exchange_key or "").strip().lower()
|
ex_k = (exchange_key or "").strip().lower()
|
||||||
cache_stats = upsert_trades_cache(ex_k, trades, db_path=db_path, prune_missing=True)
|
cache_stats = upsert_trades_cache(ex_k, trades, db_path=db_path, prune_missing=True)
|
||||||
|
apply_journal_behavior_overlays(ex_k, trades, db_path=db_path)
|
||||||
|
|
||||||
by_sym: dict[str, int] = {}
|
by_sym: dict[str, int] = {}
|
||||||
for t in trades or []:
|
for t in trades or []:
|
||||||
|
|||||||
@@ -617,7 +617,98 @@ def fetch_trades_for_archive(
|
|||||||
key=lambda x: int(x.get("closed_at_ms") or 0),
|
key=lambda x: int(x.get("closed_at_ms") or 0),
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
return merged[:lim]
|
merged = merged[:lim]
|
||||||
|
attach_journal_mood_tags(conn, merged, cutoff_s=cutoff_s)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _symbol_coin_base(symbol: str) -> str:
|
||||||
|
s = (symbol or "").strip().upper()
|
||||||
|
if "/" in s:
|
||||||
|
return s.split("/")[0]
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _datetime_minute_key(raw: Any) -> str:
|
||||||
|
if raw is None:
|
||||||
|
return ""
|
||||||
|
s = str(raw).strip().replace("T", " ").replace("Z", "")
|
||||||
|
if len(s) >= 16:
|
||||||
|
return s[:16]
|
||||||
|
return s[:10] if len(s) >= 10 else s
|
||||||
|
|
||||||
|
|
||||||
|
def journal_trade_match_key(symbol: str, opened_at: Any, closed_at: Any) -> tuple[str, str, str]:
|
||||||
|
return (
|
||||||
|
_symbol_coin_base(symbol),
|
||||||
|
_datetime_minute_key(opened_at),
|
||||||
|
_datetime_minute_key(closed_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_journal_mood_match_index(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
cutoff_s: str,
|
||||||
|
) -> dict[tuple[str, str, str], list[str]]:
|
||||||
|
"""复盘 mood_issues → 交易匹配键(币种 + 开/平仓分钟)。"""
|
||||||
|
from lib.trade.account_risk_lib import parse_mood_issues
|
||||||
|
|
||||||
|
cols = _table_columns(conn, "journal_entries")
|
||||||
|
if not cols or "mood_issues" not in cols:
|
||||||
|
return {}
|
||||||
|
close_expr = "REPLACE(COALESCE(close_datetime, open_datetime, created_at), 'T', ' ')"
|
||||||
|
open_expr = "REPLACE(COALESCE(open_datetime, created_at), 'T', ' ')"
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT coin, open_datetime, close_datetime, mood_issues
|
||||||
|
FROM journal_entries
|
||||||
|
WHERE {close_expr} >= ? OR {open_expr} >= ?
|
||||||
|
""",
|
||||||
|
(cutoff_s, cutoff_s),
|
||||||
|
).fetchall()
|
||||||
|
out: dict[tuple[str, str, str], list[str]] = {}
|
||||||
|
for row in rows:
|
||||||
|
d = _row_dict(row)
|
||||||
|
issues = parse_mood_issues(d.get("mood_issues"))
|
||||||
|
if not issues:
|
||||||
|
continue
|
||||||
|
coin = str(d.get("coin") or "").strip().upper()
|
||||||
|
sym = coin if "/" in coin else (f"{coin}/USDT" if coin else "")
|
||||||
|
key = journal_trade_match_key(sym, d.get("open_datetime"), d.get("close_datetime"))
|
||||||
|
if not key[0]:
|
||||||
|
continue
|
||||||
|
out[key] = issues
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def attach_journal_mood_tags(
|
||||||
|
conn,
|
||||||
|
trades: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
cutoff_s: str,
|
||||||
|
) -> None:
|
||||||
|
"""实例复盘勾选情绪标签 → 档案交易自动标犯病(hub 同步用)。"""
|
||||||
|
if not trades:
|
||||||
|
return
|
||||||
|
mood_index = load_journal_mood_match_index(conn, cutoff_s=cutoff_s)
|
||||||
|
if not mood_index:
|
||||||
|
return
|
||||||
|
for t in trades:
|
||||||
|
if not isinstance(t, dict):
|
||||||
|
continue
|
||||||
|
key = journal_trade_match_key(
|
||||||
|
str(t.get("symbol") or ""),
|
||||||
|
t.get("opened_at"),
|
||||||
|
t.get("closed_at"),
|
||||||
|
)
|
||||||
|
issues = mood_index.get(key)
|
||||||
|
if not issues:
|
||||||
|
continue
|
||||||
|
t["journal_mood_issues"] = issues
|
||||||
|
t["journal_mood_sick"] = True
|
||||||
|
t["behavior_tag_from_journal"] = True
|
||||||
|
t["behavior_tag"] = "sick"
|
||||||
|
|
||||||
|
|
||||||
def summarize_trades(trades: list[dict]) -> dict[str, Any]:
|
def summarize_trades(trades: list[dict]) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -7126,6 +7126,18 @@ body.funds-fullscreen-open {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.archive-tag-fixed {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.archive-tag-fixed.is-tag-sick {
|
||||||
|
color: var(--red);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--red) 45%, var(--border-soft));
|
||||||
|
background: color-mix(in srgb, var(--red) 14%, var(--inset-surface));
|
||||||
|
}
|
||||||
.archive-tag-select.is-tag-sick {
|
.archive-tag-select.is-tag-sick {
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
border-color: color-mix(in srgb, var(--red) 45%, var(--border-soft));
|
border-color: color-mix(in srgb, var(--red) 45%, var(--border-soft));
|
||||||
|
|||||||
@@ -1230,7 +1230,8 @@
|
|||||||
const tid = t.trade_id || t.id;
|
const tid = t.trade_id || t.id;
|
||||||
const exKey = String(t.exchange_key || "").toLowerCase();
|
const exKey = String(t.exchange_key || "").toLowerCase();
|
||||||
const rowKey = tradeRowKey(t);
|
const rowKey = tradeRowKey(t);
|
||||||
const tag = t.behavior_tag || "";
|
const journalSick = !!t.behavior_tag_from_journal;
|
||||||
|
const tag = journalSick ? "sick" : (t.behavior_tag || "");
|
||||||
const sick = tag === "sick";
|
const sick = tag === "sick";
|
||||||
const active = rowKey && rowKey === selectedTradeKey ? " is-active" : "";
|
const active = rowKey && rowKey === selectedTradeKey ? " is-active" : "";
|
||||||
const rev = reviewMark(t);
|
const rev = reviewMark(t);
|
||||||
@@ -1283,21 +1284,23 @@
|
|||||||
"<td>" +
|
"<td>" +
|
||||||
fmtFeeStat(t.exchange_commission_usdt) +
|
fmtFeeStat(t.exchange_commission_usdt) +
|
||||||
"</td>" +
|
"</td>" +
|
||||||
'<td><select class="archive-tag-select" data-id="' +
|
(journalSick
|
||||||
tid +
|
? '<td><span class="archive-tag-fixed is-tag-sick" title="实例复盘已勾选情绪标签">犯病</span></td>'
|
||||||
'" data-ex="' +
|
: '<td><select class="archive-tag-select" data-id="' +
|
||||||
esc(exKey) +
|
tid +
|
||||||
'">' +
|
'" data-ex="' +
|
||||||
'<option value=""' +
|
esc(exKey) +
|
||||||
(tag === "" ? " selected" : "") +
|
'">' +
|
||||||
">—</option>" +
|
'<option value=""' +
|
||||||
'<option value="sick"' +
|
(tag === "" ? " selected" : "") +
|
||||||
(tag === "sick" ? " selected" : "") +
|
">—</option>" +
|
||||||
">犯病</option>" +
|
'<option value="sick"' +
|
||||||
'<option value="emotion"' +
|
(tag === "sick" ? " selected" : "") +
|
||||||
(tag === "emotion" ? " selected" : "") +
|
">犯病</option>" +
|
||||||
">情绪</option>" +
|
'<option value="emotion"' +
|
||||||
"</select></td>" +
|
(tag === "emotion" ? " selected" : "") +
|
||||||
|
">情绪</option>" +
|
||||||
|
"</select></td>") +
|
||||||
'<td><input class="archive-note-input" data-id="' +
|
'<td><input class="archive-note-input" data-id="' +
|
||||||
tid +
|
tid +
|
||||||
'" data-ex="' +
|
'" data-ex="' +
|
||||||
@@ -1366,10 +1369,17 @@
|
|||||||
inp.addEventListener("change", function () {
|
inp.addEventListener("change", function () {
|
||||||
const row = inp.closest(".archive-trade-row");
|
const row = inp.closest(".archive-trade-row");
|
||||||
const tagSel = row && row.querySelector(".archive-tag-select");
|
const tagSel = row && row.querySelector(".archive-tag-select");
|
||||||
|
const tr = findTradeByKey(row && row.getAttribute("data-key"));
|
||||||
|
const tag =
|
||||||
|
tr && tr.behavior_tag_from_journal
|
||||||
|
? "sick"
|
||||||
|
: tagSel
|
||||||
|
? tagSel.value
|
||||||
|
: "";
|
||||||
saveOverlay(
|
saveOverlay(
|
||||||
inp.getAttribute("data-id"),
|
inp.getAttribute("data-id"),
|
||||||
inp.getAttribute("data-ex"),
|
inp.getAttribute("data-ex"),
|
||||||
tagSel ? tagSel.value : "",
|
tag,
|
||||||
inp.value
|
inp.value
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1398,7 +1408,19 @@
|
|||||||
async function saveOverlay(tradeId, exchangeKey, tag, note) {
|
async function saveOverlay(tradeId, exchangeKey, tag, note) {
|
||||||
const exKey = exchangeKey || (selected && selected.exchange_key);
|
const exKey = exchangeKey || (selected && selected.exchange_key);
|
||||||
if (!exKey) return;
|
if (!exKey) return;
|
||||||
const body = { behavior_tag: tag || "", note: note != null ? note : undefined };
|
const tr = dailyTrades.find(function (t) {
|
||||||
|
return (
|
||||||
|
String(t.trade_id || t.id) === String(tradeId) &&
|
||||||
|
String(t.exchange_key || "").toLowerCase() === String(exKey).toLowerCase()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (tr && tr.behavior_tag_from_journal && tag != null && String(tag) !== "sick") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
behavior_tag: tr && tr.behavior_tag_from_journal ? "sick" : tag || "",
|
||||||
|
note: note != null ? note : undefined,
|
||||||
|
};
|
||||||
if (note == null) {
|
if (note == null) {
|
||||||
const row = elTrades.querySelector(
|
const row = elTrades.querySelector(
|
||||||
'.archive-trade-row[data-id="' + tradeId + '"][data-ex="' + exKey + '"]'
|
'.archive-trade-row[data-id="' + tradeId + '"][data-ex="' + exKey + '"]'
|
||||||
@@ -1411,12 +1433,6 @@
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const tr = dailyTrades.find(function (t) {
|
|
||||||
return (
|
|
||||||
String(t.trade_id || t.id) === String(tradeId) &&
|
|
||||||
String(t.exchange_key || "").toLowerCase() === String(exKey).toLowerCase()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (tr) {
|
if (tr) {
|
||||||
tr.behavior_tag = body.behavior_tag;
|
tr.behavior_tag = body.behavior_tag;
|
||||||
tr.note = body.note;
|
tr.note = body.note;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import unittest
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from lib.hub.hub_trades_lib import (
|
from lib.hub.hub_trades_lib import (
|
||||||
|
attach_journal_mood_tags,
|
||||||
fetch_trades_for_trading_day,
|
fetch_trades_for_trading_day,
|
||||||
|
journal_trade_match_key,
|
||||||
summarize_trades,
|
summarize_trades,
|
||||||
trading_day_from_dt,
|
trading_day_from_dt,
|
||||||
trading_day_window_bounds,
|
trading_day_window_bounds,
|
||||||
@@ -193,6 +195,35 @@ class HubTradesLibTest(unittest.TestCase):
|
|||||||
self.assertEqual(rows[0]["result"], "时间平仓")
|
self.assertEqual(rows[0]["result"], "时间平仓")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def test_attach_journal_mood_tags_marks_sick(self):
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE journal_entries (
|
||||||
|
coin TEXT, open_datetime TEXT, close_datetime TEXT, mood_issues TEXT, created_at TEXT
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO journal_entries VALUES (?,?,?,?,?)",
|
||||||
|
("ETH", "2026-07-06 21:51", "2026-07-07 00:00", "报复开仓,扛单", "2026-07-07 00:05"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
trades = [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"symbol": "ETH/USDT",
|
||||||
|
"opened_at": "2026-07-06 21:51:00",
|
||||||
|
"closed_at": "2026-07-07 00:00:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
attach_journal_mood_tags(conn, trades, cutoff_s="2026-01-01 00:00:00")
|
||||||
|
self.assertTrue(trades[0]["journal_mood_sick"])
|
||||||
|
self.assertEqual(trades[0]["behavior_tag"], "sick")
|
||||||
|
self.assertEqual(trades[0]["journal_mood_issues"], ["报复开仓", "扛单"])
|
||||||
|
key = journal_trade_match_key("ETH/USDT", "2026-07-06 21:51:00", "2026-07-07 00:00:00")
|
||||||
|
self.assertEqual(key, ("ETH", "2026-07-06 21:51", "2026-07-07 00:00"))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user