from __future__ import annotations import hashlib import json import os import re from datetime import datetime from pathlib import Path import pandas as pd import pymysql from PIL import Image, ImageDraw, ImageFont RUN_ID = "RUN-ANA-WUJI-BASELINE-PILOT-20260607-001" ROOT = Path(__file__).resolve().parents[1] LOCAL_DB_INDEX = Path( r"D:\strategy_project\s-system-doc\observer\天下模型沉淀\数据库索引数据.md" ) def read_password() -> str: env = os.environ.get("TIANXIA_MYSQL_PASSWORD") or os.environ.get("MYSQL_PWD") if env: return env text = LOCAL_DB_INDEX.read_text(encoding="utf-8") match = re.search(r"^\s*-\s*密码:`([^`]+)`", text, re.MULTILINE) if not match: raise RuntimeError("Unable to read local MySQL credential from approved local index.") return match.group(1) def get_conn(): return pymysql.connect( host="127.0.0.1", port=3306, user="root", password=read_password(), database="tianxia", charset="utf8mb4", connect_timeout=5, read_timeout=120, ) def sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def font(size: int): for p in [ Path("C:/Windows/Fonts/msyh.ttc"), Path("C:/Windows/Fonts/simhei.ttf"), Path("C:/Windows/Fonts/simsun.ttc"), ]: if p.exists(): return ImageFont.truetype(str(p), size) return ImageFont.load_default() FONT_TITLE = font(26) FONT_MID = font(17) FONT_SMALL = font(13) def normalize_time(value) -> str: text = str(value) if "days" in text: text = text.split()[-1] if "." in text: text = text.split(".")[0] parts = text.split(":") if len(parts) >= 3: return f"{int(parts[0]):02d}:{int(parts[1]):02d}:{int(float(parts[2])):02d}" return text def y_price(value: float, low: float, high: float, top: int, bottom: int) -> int: if high <= low: return (top + bottom) // 2 return bottom - int((value - low) / (high - low) * (bottom - top)) def short_id(value: object) -> str: return str(value).replace(f"LOT-{RUN_ID}-", "LOT-").replace(f"ORD-{RUN_ID}-", "ORD-") def signal_label(signal_type: str) -> str: return { "STOP5_SELL_SIGNAL_CANDIDATE": "-5%止损卖出", "TREND_TAKE_PROFIT_SIGNAL_CANDIDATE": "趋势止盈卖出", "THREE_HIGH_NOT_RISING_SIGNAL_CANDIDATE": "三高不升卖出", "HOLD_REVIEW_CANDIDATE": "继续持仓", }.get(signal_type, signal_type) def wrap_text(text: object, max_chars: int = 22) -> list[str]: lines: list[str] = [] for raw in str(text).splitlines(): item = raw.strip() if not item: lines.append("") continue while len(item) > max_chars: lines.append(item[:max_chars]) item = item[max_chars:] lines.append(item) return lines def clean_zero(value: float) -> float: return 0.0 if abs(value) < 5e-10 else value def evaluate_sell(signal: pd.Series, minute: pd.DataFrame) -> dict: day = minute[ (minute.symbol == signal.symbol) & (minute.trade_date == signal.signal_trade_date) ].copy().reset_index(drop=True) entry = float(signal.entry_price) stop_price = entry * 0.95 target_price = entry * 1.05 if day.empty: return { "action_status": "EXIT_DATA_GAP_HELD", "reason": "信号日无1分钟数据,不能伪造真实SELL;保留为数据缺口待审。", "lookahead_violation_flag": False, } if str(signal.signal_trade_date) < str(signal.sellable_from_trade_date): return { "action_status": "T1_GUARD_FAIL_HELD", "reason": "信号日在T+1可卖日之前,不能真实卖出。", "lookahead_violation_flag": False, } signal_type = str(signal.signal_type) if signal_type == "STOP5_SELL_SIGNAL_CANDIDATE": hit = day[day.low_price.le(stop_price)] if hit.empty: return { "action_status": "SELL_REVIEW_DATA_MISMATCH_HELD", "reason": "日K触发-5%止损候选,但1分钟线未复现跌破价位,保留复核。", "lookahead_violation_flag": False, } row = hit.iloc[0] return { "action_status": "AI_SELL_CONFIRMED", "decision_time": str(row.trade_time), "price": float(row.close_price), "sell_reason_type": "SELL_RISK_STOP5", "trigger_ref_price": stop_price, "reason": f"{row.trade_time} 分钟K最低价触及买入价-5%硬止损线,按止损铁律卖出;成交价按该分钟收盘价记录。", "lookahead_violation_flag": False, } if signal_type == "TREND_TAKE_PROFIT_SIGNAL_CANDIDATE": open_ref = float(day.open_price.iloc[0]) candidates = day[ (day.trade_time >= "09:35:00") & day.close_price.ge(target_price) & day.close_price.ge(open_ref * 1.01) ].copy() if candidates.empty: candidates = day[ day.close_price.ge(target_price) ].copy() if candidates.empty: return { "action_status": "SELL_REVIEW_DATA_MISMATCH_HELD", "reason": "日K趋势止盈候选成立,但1分钟线未找到达到买入价+5%的裁决点,保留复核。", "lookahead_violation_flag": False, } row = candidates.iloc[0] return { "action_status": "AI_SELL_CONFIRMED", "decision_time": str(row.trade_time), "price": float(row.close_price), "sell_reason_type": "SELL_TREND_TAKE_PROFIT", "trigger_ref_price": target_price, "reason": f"{row.trade_time} 价格达到买入价+5%并保持强于开盘方向,按“第二天走出趋势性上涨则卖出”裁决止盈。", "lookahead_violation_flag": False, } return { "action_status": "EXIT_REVIEW_HELD", "reason": "当前信号需要更强人工语义确认,本轮不写真实SELL。", "lookahead_violation_flag": False, } def observation_dates(trade_dates: list[str], entry_trade_date: str, sellable_from_trade_date: str) -> list[str]: idx = trade_dates.index(str(entry_trade_date)) obs = trade_dates[idx : idx + 11] return [d for d in obs if d >= str(sellable_from_trade_date)] def evaluate_sell_window(signal: pd.Series, minute: pd.DataFrame, trade_dates: list[str]) -> dict: """Resolve SELL by scanning the full approved observation window with 1m evidence.""" entry = float(signal.entry_price) stop_price = entry * 0.95 target_price = entry * 1.05 dates = observation_dates(trade_dates, str(signal.entry_trade_date), str(signal.sellable_from_trade_date)) gap_dates: list[str] = [] checked_dates: list[str] = [] first_mismatch_date = "" for trade_date in dates: day = minute[ (minute.symbol == signal.symbol) & (minute.trade_date == trade_date) ].copy().reset_index(drop=True) if day.empty: gap_dates.append(trade_date) continue checked_dates.append(trade_date) open_ref = float(day.open_price.iloc[0]) day_stop_possible = False fallback_trend_row = None for _, row in day.iterrows(): trade_time = str(row.trade_time) low = float(row.low_price) close = float(row.close_price) if low <= stop_price: return { "action_status": "AI_SELL_CONFIRMED", "signal_type": "STOP5_SELL_SIGNAL_CANDIDATE", "signal_trade_date": trade_date, "decision_time": trade_time, "price": close, "sell_reason_type": "SELL_RISK_STOP5", "trigger_ref_price": stop_price, "reason": f"{trade_time} 分钟K最低价触及买入价-5%硬止损线,按止损铁律卖出;成交价按该分钟收盘价记录。", "lookahead_violation_flag": False, "resolution_note_cn": "分钟线确认观察窗口内最早止损卖点。", "checked_dates": ",".join(checked_dates), "gap_dates": ",".join(gap_dates), } if close <= stop_price: day_stop_possible = True if trade_time >= "09:35:00" and close >= target_price and close >= open_ref * 1.01: return { "action_status": "AI_SELL_CONFIRMED", "signal_type": "TREND_TAKE_PROFIT_SIGNAL_CANDIDATE", "signal_trade_date": trade_date, "decision_time": trade_time, "price": close, "sell_reason_type": "SELL_TREND_TAKE_PROFIT", "trigger_ref_price": target_price, "reason": f"{trade_time} 价格达到买入价+5%并保持强于开盘方向,按“第二天走出趋势性上涨则卖出”裁决止盈。", "lookahead_violation_flag": False, "resolution_note_cn": "分钟线确认观察窗口内最早趋势止盈卖点。", "checked_dates": ",".join(checked_dates), "gap_dates": ",".join(gap_dates), } if close >= target_price and fallback_trend_row is None: fallback_trend_row = row if day_stop_possible and not first_mismatch_date: first_mismatch_date = trade_date if fallback_trend_row is not None: trade_time = str(fallback_trend_row.trade_time) close = float(fallback_trend_row.close_price) return { "action_status": "AI_SELL_CONFIRMED", "signal_type": "TREND_TAKE_PROFIT_SIGNAL_CANDIDATE", "signal_trade_date": trade_date, "decision_time": trade_time, "price": close, "sell_reason_type": "SELL_TREND_TAKE_PROFIT", "trigger_ref_price": target_price, "reason": f"{trade_time} 价格达到买入价+5%,按“第二天走出趋势性上涨则卖出”裁决止盈;本交易日未找到强于开盘1%的更强趋势点,作为趋势止盈复核点记录。", "lookahead_violation_flag": False, "resolution_note_cn": "分钟线确认观察窗口内最早趋势止盈卖点。", "checked_dates": ",".join(checked_dates), "gap_dates": ",".join(gap_dates), } if gap_dates: return { "action_status": "EXIT_DATA_GAP_HELD", "signal_type": signal.signal_type, "signal_trade_date": str(signal.signal_trade_date), "reason": "观察窗口内存在分钟数据缺口,不能伪造真实SELL;保留为数据缺口待审。", "lookahead_violation_flag": False, "resolution_note_cn": "分钟线覆盖不足,不能闭合 lot。", "checked_dates": ",".join(checked_dates), "gap_dates": ",".join(gap_dates), } return { "action_status": "WINDOW_END_VALUATION_ONLY", "signal_type": "HOLD_REVIEW_CANDIDATE", "signal_trade_date": dates[-1] if dates else "", "reason": "观察窗口内分钟线未确认原文卖点;按流程只能记录窗口末估值/继续持仓,不写真实SELL。", "lookahead_violation_flag": False, "resolution_note_cn": "已完成观察窗口分钟线复核,无原文卖点。", "checked_dates": ",".join(checked_dates), "gap_dates": "", "first_mismatch_date": first_mismatch_date, } def draw_sell_decision(minute: pd.DataFrame, signal: pd.Series, decision: dict, out_path: Path) -> None: w, h = 1500, 860 img = Image.new("RGB", (w, h), "#fbfbf7") d = ImageDraw.Draw(img) d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1") title = f"卖出决策1分钟K图:{signal.symbol} {signal.signal_trade_date}" d.text((32, 24), title, fill="#111827", font=FONT_TITLE) d.text( (32, 58), f"AI裁决:{decision['decision_time']} 卖出第一份仓,价格 {decision['price']:.2f}", fill="#7f1d1d", font=FONT_MID, ) plot_left, plot_top, plot_right, plot_bottom = 80, 105, 1060, 575 vol_top, vol_bottom = 625, 780 note_left, note_top = 1090, 110 d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8") d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8") df = minute[ (minute.symbol == signal.symbol) & (minute.trade_date == signal.signal_trade_date) & (minute.trade_time <= decision["decision_time"]) ].copy().reset_index(drop=True) entry = float(signal.entry_price) stop_price = entry * 0.95 target_price = entry * 1.05 refs = [entry, stop_price, target_price, float(decision["price"])] price_low = min(float(df.low_price.min()), min(refs)) * 0.998 price_high = max(float(df.high_price.max()), max(refs)) * 1.002 max_vol = max(float(df.volume.max()), 1.0) n = len(df) gap = (plot_right - plot_left) / max(n, 1) body_w = max(3, int(gap * 0.55)) sell_x = None for i, row in df.iterrows(): cx = int(plot_left + gap * i + gap / 2) op, hi, lo, cl = [float(row[c]) for c in ["open_price", "high_price", "low_price", "close_price"]] color = "#dc2626" if cl >= op else "#16a34a" d.line( [cx, y_price(lo, price_low, price_high, plot_top, plot_bottom), cx, y_price(hi, price_low, price_high, plot_top, plot_bottom)], fill=color, width=2, ) y1, y2 = y_price(op, price_low, price_high, plot_top, plot_bottom), y_price(cl, price_low, price_high, plot_top, plot_bottom) d.rectangle([cx - body_w // 2, min(y1, y2), cx + body_w // 2, max(y1, y2)], fill=color, outline=color) vh = int(float(row.volume) / max_vol * (vol_bottom - vol_top)) d.rectangle([cx - body_w // 2, vol_bottom - vh, cx + body_w // 2, vol_bottom], fill=color, outline=color) if str(row.trade_time) == decision["decision_time"]: sell_x = cx if i % max(1, n // 6) == 0: d.text((cx - 24, vol_bottom + 8), str(row.trade_time)[:5], fill="#64748b", font=FONT_SMALL) for ref, label, color in [ (entry, "买入价", "#2563eb"), (stop_price, "-5%止损", "#b91c1c"), (target_price, "+5%趋势", "#7c3aed"), ]: yy = y_price(ref, price_low, price_high, plot_top, plot_bottom) d.line([plot_left, yy, plot_right, yy], fill=color, width=2) d.text((plot_right - 110, yy - 8), f"{label} {ref:.2f}", fill=color, font=FONT_SMALL) if sell_x is not None: d.line([sell_x, plot_top, sell_x, vol_bottom], fill="#b91c1c", width=3) d.text((sell_x + 8, plot_top + 8), "卖出", fill="#b91c1c", font=FONT_MID) lot_ret = float(decision["price"]) / entry - 1 d.rounded_rectangle([note_left, note_top, 1460, 780], radius=8, outline="#334155", fill="#ffffff") notes = [ "卖出裁决", f"动作:SELL 第一份仓", f"lot:{short_id(signal.trade_lot_id)}", f"时间:{decision['decision_time']}", f"价格:{decision['price']:.2f}", f"收益:{lot_ret:.2%}", f"原因:{signal_label(signal.signal_type)}", "说明:", *wrap_text(decision["reason"], 18), "", "T+1:已过可卖日", "本图为 decision_view,", "只使用卖出时间及以前数据。", ] yy = note_top + 18 for i, line in enumerate(notes): d.text( (note_left + 18, yy), line, fill="#111827" if i == 0 else "#334155", font=FONT_TITLE if i == 0 else FONT_SMALL, ) yy += 32 if i == 0 else (24 if line else 12) d.text((32, 820), "无忌 baseline:卖出由AI按已冻结规则看图裁决,执行审核通过前不得引用收益结论。", fill="#334155", font=FONT_MID) img.save(out_path) def next_order_id(seq: int) -> str: return f"ORD-{RUN_ID}-SELL-{seq:04d}" def append_case_board(case_id: str, rows: list[dict]) -> None: case_dir = ROOT / "cases" / case_id board_path = case_dir / "case_image_board.md" existing = board_path.read_text(encoding="utf-8") if board_path.exists() else f"# {case_id} 图片审核板\n" existing = re.sub(r"\n## 6\. 卖出1分钟裁决图\n[\s\S]*$", "", existing.rstrip()) lines = [existing.rstrip(), "", "## 6. 卖出1分钟裁决图", ""] if not rows: lines.append("- 本案例无真实 SELL 裁决图。") for row in rows: rel = Path(row["path"]).relative_to(f"cases/{case_id}").as_posix() lines.extend( [ f"### {row['symbol']} {row['decision_time']}", "", f"![{row['symbol']}]({rel})", "", f"- 卖出原因:{row['note']}", "", ] ) board_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def main() -> None: signals = pd.read_csv(ROOT / "sell_signal_candidates.csv", encoding="utf-8-sig") lots = pd.read_csv(ROOT / "position_lot_ledger.csv", encoding="utf-8-sig") orders = pd.read_csv(ROOT / "order_ledger.csv", encoding="utf-8-sig") for col in ["exit_trade_date", "exit_time", "exit_price", "lot_return_pct", "account_return_contribution_pct", "lot_status"]: if col in lots.columns: lots[col] = lots[col].fillna("").astype("object") signals["signal_trade_date"] = signals["signal_trade_date"].astype(str) signals["sellable_from_trade_date"] = signals["sellable_from_trade_date"].astype(str) for col in ["entry_price"]: signals[col] = pd.to_numeric(signals[col], errors="coerce") symbols = sorted(signals.symbol.dropna().unique().tolist()) with get_conn() as conn: trade_dates_df = pd.read_sql( """ SELECT DISTINCT trade_date FROM a_share_daily_price WHERE trade_date BETWEEN '2023-01-01' AND '2026-12-31' ORDER BY trade_date """, conn, ) trade_dates = pd.to_datetime(trade_dates_df.trade_date).dt.strftime("%Y-%m-%d").tolist() dates: list[str] = sorted( { d for _, signal in signals.iterrows() for d in observation_dates(trade_dates, str(signal.entry_trade_date), str(signal.sellable_from_trade_date)) } ) minute = pd.DataFrame() if symbols and dates: sym_ph = ",".join(["%s"] * len(symbols)) date_ph = ",".join(["%s"] * len(dates)) with get_conn() as conn: minute = pd.read_sql( f""" SELECT trade_date, trade_time, symbol, open_price, high_price, low_price, close_price, volume FROM a_share_minute_price WHERE symbol IN ({sym_ph}) AND trade_date IN ({date_ph}) ORDER BY symbol, trade_date, trade_time """, conn, params=[*symbols, *dates], ) if not minute.empty: minute.trade_date = pd.to_datetime(minute.trade_date).dt.strftime("%Y-%m-%d") minute.trade_time = minute.trade_time.map(normalize_time) for col in ["open_price", "high_price", "low_price", "close_price", "volume"]: minute[col] = pd.to_numeric(minute[col], errors="coerce") sell_decisions = [] resolution_rows = [] sell_orders = [] manifest_rows = [] sold_chart_rows_by_case: dict[str, list[dict]] = {} seq = 1 for _, signal in signals.iterrows(): decision = evaluate_sell_window(signal, minute, trade_dates) decision_signal_type = decision.get("signal_type", signal.signal_type) decision_signal_date = decision.get("signal_trade_date", signal.signal_trade_date) evidence_path = "" price = "" position_delta = "0" action_status = decision["action_status"] if action_status == "AI_SELL_CONFIRMED": case_dir = ROOT / "cases" / signal.case_id img_dir = case_dir / "img" img_dir.mkdir(parents=True, exist_ok=True) safe_time = str(decision["decision_time"]).replace(":", "") out_path = img_dir / f"06_exit_1m_decision_{signal.symbol.replace('.', '_')}_{decision_signal_date}_{safe_time}.png" chart_signal = signal.copy() chart_signal["signal_type"] = decision_signal_type chart_signal["signal_trade_date"] = decision_signal_date draw_sell_decision(minute, chart_signal, decision, out_path) evidence_path = out_path.relative_to(ROOT).as_posix() price = f"{float(decision['price']):.4f}" position_delta = f"{-float(lots[lots.trade_lot_id == signal.trade_lot_id].iloc[0].position_pct):.4f}" order_id = next_order_id(seq) seq += 1 sell_orders.append( { "order_id": order_id, "case_id": signal.case_id, "candidate_id": signal.order_id, "variant_id": lots[lots.trade_lot_id == signal.trade_lot_id].iloc[0].variant_id, "symbol": signal.symbol, "trade_date": decision_signal_date, "trade_time": decision["decision_time"], "action": "SELL", "price": price, "position_delta_pct": position_delta, "tranche_index": "1", "planned_tranche_count": "5", "decision_reason_cn": decision["reason"], "evidence_image_path": evidence_path, "t1_sellable_from_trade_date": signal.sellable_from_trade_date, "lookahead_violation_flag": str(decision["lookahead_violation_flag"]), "source_lot_id": signal.trade_lot_id, "source_order_id": signal.order_id, "exit_signal_type": decision_signal_type, } ) manifest_row = { "case_id": signal.case_id, "symbol": signal.symbol, "trade_date": decision_signal_date, "event_id": f"{signal.trade_lot_id}_exit_sell_decision", "chart_role": "exit_1m_sell_decision_view", "decision_time": f"{decision_signal_date} {decision['decision_time']}", "path": evidence_path, "sha256": sha256_file(out_path), "status": "PASS", "note": signal_label(decision_signal_type), } manifest_rows.append(manifest_row) sold_chart_rows_by_case.setdefault(signal.case_id, []).append(manifest_row) sell_decisions.append( { "trade_lot_id": signal.trade_lot_id, "source_order_id": signal.order_id, "case_id": signal.case_id, "symbol": signal.symbol, "entry_trade_date": signal.entry_trade_date, "entry_time": signal.entry_time, "entry_price": f"{float(signal.entry_price):.4f}", "sellable_from_trade_date": signal.sellable_from_trade_date, "original_signal_type": signal.signal_type, "original_signal_trade_date": signal.signal_trade_date, "signal_type": decision_signal_type, "signal_trade_date": decision_signal_date, "action_status": action_status, "decision_time": decision.get("decision_time", ""), "price": price, "position_delta_pct": position_delta, "evidence_image_path": evidence_path, "decision_reason_cn": decision["reason"], "lookahead_violation_flag": str(decision["lookahead_violation_flag"]), } ) resolution_rows.append( { "trade_lot_id": signal.trade_lot_id, "case_id": signal.case_id, "symbol": signal.symbol, "entry_trade_date": signal.entry_trade_date, "entry_price": f"{float(signal.entry_price):.4f}", "sellable_from_trade_date": signal.sellable_from_trade_date, "original_signal_type": signal.signal_type, "original_signal_trade_date": signal.signal_trade_date, "resolved_signal_type": decision_signal_type, "resolved_signal_trade_date": decision_signal_date, "final_action_status": action_status, "decision_time": decision.get("decision_time", ""), "price": price, "checked_dates": decision.get("checked_dates", ""), "gap_dates": decision.get("gap_dates", ""), "resolution_note_cn": decision.get("resolution_note_cn", decision["reason"]), "decision_reason_cn": decision["reason"], } ) sell_decisions_df = pd.DataFrame(sell_decisions) sell_decisions_df.to_csv(ROOT / "sell_decision_log.csv", index=False, encoding="utf-8-sig") pd.DataFrame(resolution_rows).to_csv(ROOT / "exit_resolution_log.csv", index=False, encoding="utf-8-sig") # Keep BUY orders and replace this stage's SELL orders idempotently. if "action" in orders.columns: orders = orders[orders.action != "SELL"].copy() orders = pd.concat([orders, pd.DataFrame(sell_orders)], ignore_index=True, sort=False) orders.to_csv(ROOT / "order_ledger.csv", index=False, encoding="utf-8-sig") lots = lots.copy() for _, decision in sell_decisions_df.iterrows(): idx = lots.trade_lot_id == decision.trade_lot_id if decision.action_status == "AI_SELL_CONFIRMED": entry_price = float(lots.loc[idx, "entry_price"].iloc[0]) pos = float(lots.loc[idx, "position_pct"].iloc[0]) exit_price = float(decision.price) lot_return = exit_price / entry_price - 1 lots.loc[idx, "lot_status"] = "CLOSED_BY_AI_SELL" lots.loc[idx, "exit_trade_date"] = decision.signal_trade_date lots.loc[idx, "exit_time"] = decision.decision_time lots.loc[idx, "exit_price"] = f"{exit_price:.4f}" lots.loc[idx, "lot_return_pct"] = f"{lot_return:.8f}" lots.loc[idx, "account_return_contribution_pct"] = f"{lot_return * pos:.8f}" else: lots.loc[idx, "lot_status"] = decision.action_status lots.to_csv(ROOT / "position_lot_ledger.csv", index=False, encoding="utf-8-sig") decision_log = pd.read_csv(ROOT / "decision_log.csv", encoding="utf-8-sig") if "decision_stage" in decision_log.columns: decision_log = decision_log[decision_log.decision_stage != "EXIT_AI_REVIEW"].copy() exit_decision_for_main = pd.DataFrame( [ { "case_id": r["case_id"], "candidate_id": r["source_order_id"], "symbol": r["symbol"], "entry_trade_date": r["entry_trade_date"], "decision_stage": "EXIT_AI_REVIEW", "action_status": r["action_status"], "decision_time": f"{r['signal_trade_date']} {r['decision_time']}" if r["decision_time"] else "", "price": r["price"], "position_delta_pct": r["position_delta_pct"], "review_required": "False" if r["action_status"] == "AI_SELL_CONFIRMED" else "True", "evidence_image_path": r["evidence_image_path"], "decision_reason_cn": r["decision_reason_cn"], "lookahead_violation_flag": r["lookahead_violation_flag"], } for r in sell_decisions ] ) decision_log = pd.concat([decision_log, exit_decision_for_main], ignore_index=True, sort=False) decision_log.to_csv(ROOT / "decision_log.csv", index=False, encoding="utf-8-sig") if manifest_rows: image_manifest = pd.read_csv(ROOT / "image_manifest.csv", encoding="utf-8-sig") image_manifest = pd.concat([image_manifest, pd.DataFrame(manifest_rows)], ignore_index=True) image_manifest = image_manifest.drop_duplicates(subset=["case_id", "symbol", "event_id", "chart_role"], keep="last") image_manifest.to_csv(ROOT / "image_manifest.csv", index=False, encoding="utf-8-sig") for case_id in sorted(lots.case_id.unique()): rows = sold_chart_rows_by_case.get(case_id, []) append_case_board(case_id, rows) case_dir = ROOT / "cases" / case_id image_manifest[image_manifest.case_id == case_id].to_csv(case_dir / "image_manifest.csv", index=False, encoding="utf-8-sig") account_rows = [] for case_id, group in lots.groupby("case_id"): events = [] buys = orders[(orders.case_id == case_id) & (orders.action == "BUY")].copy() sells = orders[(orders.case_id == case_id) & (orders.action == "SELL")].copy() for _, row in buys.iterrows(): events.append((row.trade_date, row.trade_time, "BUY", float(row.position_delta_pct), 0.0, row.symbol)) for _, row in sells.iterrows(): lot = group[group.trade_lot_id == row.source_lot_id].iloc[0] events.append((row.trade_date, row.trade_time, "SELL", float(lot.position_pct), float(lot.account_return_contribution_pct), row.symbol)) cash = 1.0 open_pos = 0.0 for date, time, action, pos_delta, realized, symbol in sorted(events): if action == "BUY": cash -= pos_delta open_pos += pos_delta realized_delta = 0.0 else: cash += pos_delta + realized open_pos -= pos_delta realized_delta = realized cash = clean_zero(cash) open_pos = clean_zero(open_pos) realized_delta = clean_zero(realized_delta) nav = cash + open_pos nav = clean_zero(nav) account_rows.append( { "case_id": case_id, "event_date": date, "event_time": time, "symbol": symbol, "action": action, "cash_pct_after_event": f"{cash:.8f}", "open_position_pct_after_event": f"{open_pos:.8f}", "realized_return_delta": f"{realized_delta:.8f}", "account_nav_after_event": f"{nav:.8f}", } ) account_df = pd.DataFrame(account_rows) account_df.to_csv(ROOT / "daily_account_ledger.csv", index=False, encoding="utf-8-sig") case_rows = [] for case_id, group in lots.groupby("case_id"): closed = group[group.lot_status == "CLOSED_BY_AI_SELL"].copy() unresolved = group[group.lot_status != "CLOSED_BY_AI_SELL"].copy() account_return = pd.to_numeric(closed.account_return_contribution_pct, errors="coerce").fillna(0).sum() case_rows.append( { "case_id": case_id, "buy_lot_count": len(group), "closed_lot_count": len(closed), "unresolved_lot_count": len(unresolved), "account_return_closed_lots": f"{account_return:.8f}", "strict_baseline_return_ready_flag": "0", "return_boundary": "STRUCTURE_PILOT_AI_SELL_REVIEWED__EXEC_AUDIT_PENDING__NOT_RETURN_STAT_READY", } ) case_summary = pd.DataFrame(case_rows) case_summary.to_csv(ROOT / "case_summary.csv", index=False, encoding="utf-8-sig") summary = { "schema_version": "1.0", "run_id": RUN_ID, "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "stage": "EXIT_AI_REVIEW_DONE", "sell_decision_counts": sell_decisions_df.action_status.value_counts().to_dict(), "sell_order_count": len(sell_orders), "closed_lot_count": int((lots.lot_status == "CLOSED_BY_AI_SELL").sum()), "unresolved_lot_count": int((lots.lot_status != "CLOSED_BY_AI_SELL").sum()), "closed_lot_account_return_sum": float(case_summary.account_return_closed_lots.astype(float).sum()), "strict_baseline_return_ready_flag": False, "boundary": "AI sell decisions are recorded for structure pilot. Execution audit is still required; return statistics are not ready.", "artifacts": { "sell_decision_log.csv": { "size": (ROOT / "sell_decision_log.csv").stat().st_size, "sha256": sha256_file(ROOT / "sell_decision_log.csv"), }, "exit_resolution_log.csv": { "size": (ROOT / "exit_resolution_log.csv").stat().st_size, "sha256": sha256_file(ROOT / "exit_resolution_log.csv"), }, "order_ledger.csv": { "size": (ROOT / "order_ledger.csv").stat().st_size, "sha256": sha256_file(ROOT / "order_ledger.csv"), }, "position_lot_ledger.csv": { "size": (ROOT / "position_lot_ledger.csv").stat().st_size, "sha256": sha256_file(ROOT / "position_lot_ledger.csv"), }, "daily_account_ledger.csv": { "size": (ROOT / "daily_account_ledger.csv").stat().st_size, "sha256": sha256_file(ROOT / "daily_account_ledger.csv"), }, "case_summary.csv": { "size": (ROOT / "case_summary.csv").stat().st_size, "sha256": sha256_file(ROOT / "case_summary.csv"), }, }, } (ROOT / "exit_ai_review_summary.json").write_text( json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) (ROOT / "exit_ai_review_summary.md").write_text( "\n".join( [ "# exit_ai_review_summary", "", f"run_id:`{RUN_ID}`", "阶段:`EXIT_AI_REVIEW_DONE`", "", f"- SELL 订单数:{len(sell_orders)}", f"- 已闭合 lot:{summary['closed_lot_count']}", f"- 未闭合 / 待审 lot:{summary['unresolved_lot_count']}", f"- 闭合 lot 账户贡献合计:{summary['closed_lot_account_return_sum']:.4%}", "", "边界:本轮为结构试点 AI 卖出裁决,执行审核未通过前不得引用收益统计。", "", ] ), encoding="utf-8", ) if __name__ == "__main__": main()