from __future__ import annotations
|
|
import csv
|
import hashlib
|
import json
|
import math
|
import os
|
import re
|
import shutil
|
from collections import Counter, defaultdict
|
from dataclasses import dataclass
|
from datetime import datetime, timedelta
|
from pathlib import Path
|
|
import pandas as pd
|
import pymysql
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
RUN_ID = "RUN-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260609-001"
|
TASK_ID = "ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260609"
|
DESIGN_ID = "DESIGN-WUJI-STRICT-SELL-ROLLING-REPAIR-20260609"
|
DESIGN_AUDIT_ID = "AUDIT-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260610-DESIGN-002"
|
SUPP_DESIGN_AUDIT_ID = "AUDIT-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260610-SUPP-DESIGN-003"
|
SOURCE_RUN_ID = "RUN-ANA-WUJI-FULL-2023-2026-20260608-001"
|
SOURCE_EXEC_AUDIT_ID = "AUDIT-ANA-WUJI-FULL-2023-2026-20260608-EXEC-REREVIEW-002"
|
ISSUE_ID = "ANA-ISSUE-WUJI-STRICT-SELL-ROLLING-GAP-20260609-001"
|
|
ROOT = Path(__file__).resolve().parents[1]
|
SOURCE_ROOT = ROOT.parent / SOURCE_RUN_ID
|
PROJECT_ROOT = ROOT.parents[2]
|
|
OBSERVATION_TRADING_DAYS = 10
|
FAST_BREAKOUT_MAX_MINUTES = 10
|
GAIN_3 = 0.03
|
GAIN_5 = 0.05
|
GAIN_8 = 0.08
|
SUPPORT_BREAK_TOLERANCE = 0.003
|
OPEN_DOWN_TOLERANCE = 0.003
|
BREAKOUT_TOLERANCE = 0.001
|
ROLLING_NEAR_MA5_PCT = 0.012
|
ROLLING_VOLUME_MULTIPLE = 1.20
|
ROLLING_START_TIME = "10:40:00"
|
ROLLING_END_TIME = "14:40:00"
|
MAX_TRANCHES_PER_CASE_SYMBOL = 5
|
POSITION_PCT_PER_TRANCHE = 0.04
|
AUDIT_SAMPLE_MIN_RATIO = 0.20
|
AUDIT_SAMPLE_TARGET_RATIO = 0.30
|
|
|
def now_iso() -> str:
|
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
|
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 write_json(path: Path, data: dict) -> None:
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
def normalize_date(value) -> str:
|
return pd.to_datetime(value).strftime("%Y-%m-%d")
|
|
|
def normalize_time(value) -> str:
|
text = str(value)
|
if " " in text:
|
text = text.split(" ")[-1]
|
if "." in text:
|
text = text.split(".")[0]
|
parts = text.split(":")
|
if len(parts) == 2:
|
return f"{int(parts[0]):02d}:{int(parts[1]):02d}:00"
|
if len(parts) >= 3:
|
return f"{int(parts[0]):02d}:{int(parts[1]):02d}:{int(float(parts[2])):02d}"
|
return text
|
|
|
def read_mysql_password() -> str:
|
env = os.environ.get("TIANXIA_MYSQL_PASSWORD") or os.environ.get("MYSQL_PWD")
|
if env:
|
return env
|
index = Path(r"D:\strategy_project\s-system-doc\observer\天下模型沉淀\数据库索引数据.md")
|
text = 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_mysql_password(),
|
database="tianxia",
|
charset="utf8mb4",
|
connect_timeout=5,
|
read_timeout=120,
|
write_timeout=120,
|
)
|
|
|
def font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
for name in ["msyh.ttc", "simhei.ttf", "simsun.ttc"]:
|
path = Path("C:/Windows/Fonts") / name
|
if path.exists():
|
return ImageFont.truetype(str(path), size)
|
return ImageFont.load_default()
|
|
|
FONT_18 = font(18)
|
FONT_20 = font(20)
|
FONT_24 = font(24)
|
FONT_30 = font(30)
|
|
|
@dataclass
|
class LotInput:
|
source_lot_id: str
|
source_order_id: str
|
case_id: str
|
symbol: str
|
entry_trade_date: str
|
entry_time: str
|
entry_price: float
|
position_pct: float
|
tranche_index: int
|
sellable_from_trade_date: str
|
candidate_id: str
|
variant_id: str
|
decision_reason_cn: str
|
evidence_image_path: str
|
is_rolling: bool = False
|
parent_lot_id: str = ""
|
|
|
def safe_float(value, default=math.nan) -> float:
|
try:
|
if pd.isna(value):
|
return default
|
return float(value)
|
except Exception:
|
return default
|
|
|
def pct(value: float) -> str:
|
if pd.isna(value):
|
return ""
|
return f"{value * 100:.2f}%"
|
|
|
def load_inputs() -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, list[LotInput]]:
|
order = pd.read_csv(SOURCE_ROOT / "order_ledger.csv", encoding="utf-8-sig")
|
lots = pd.read_csv(SOURCE_ROOT / "position_lot_ledger.csv", encoding="utf-8-sig")
|
selected = pd.read_csv(SOURCE_ROOT / "selected_candidate_ledger.csv", encoding="utf-8-sig")
|
|
for df, date_cols in [
|
(order, ["trade_date", "t1_sellable_from_trade_date"]),
|
(lots, ["entry_trade_date", "sellable_from_trade_date", "exit_trade_date"]),
|
(selected, ["entry_trade_date", "signal_trade_date"]),
|
]:
|
for col in date_cols:
|
if col in df.columns:
|
df[col] = df[col].map(lambda v: "" if pd.isna(v) or str(v) == "" else normalize_date(v))
|
if "trade_time" in df.columns:
|
df["trade_time"] = df["trade_time"].map(lambda v: "" if pd.isna(v) else normalize_time(v))
|
if "entry_time" in df.columns:
|
df["entry_time"] = df["entry_time"].map(lambda v: "" if pd.isna(v) else normalize_time(v))
|
|
buy_order_by_id = order[order["action"] == "BUY"].set_index("order_id").to_dict("index")
|
source_lots: list[LotInput] = []
|
for _, row in lots.iterrows():
|
order_row = buy_order_by_id.get(row["order_id"], {})
|
source_lots.append(
|
LotInput(
|
source_lot_id=row["trade_lot_id"],
|
source_order_id=row["order_id"],
|
case_id=row["case_id"],
|
symbol=row["symbol"],
|
entry_trade_date=row["entry_trade_date"],
|
entry_time=row["entry_time"],
|
entry_price=safe_float(row["entry_price"]),
|
position_pct=safe_float(row["position_pct"], POSITION_PCT_PER_TRANCHE),
|
tranche_index=int(safe_float(row["tranche_index"], 1)),
|
sellable_from_trade_date=row["sellable_from_trade_date"],
|
candidate_id=str(order_row.get("candidate_id", "")),
|
variant_id=str(order_row.get("variant_id", "")),
|
decision_reason_cn=str(order_row.get("decision_reason_cn", "")),
|
evidence_image_path=str(order_row.get("evidence_image_path", "")),
|
)
|
)
|
return order, selected, lots, source_lots
|
|
|
def fetch_trade_calendar() -> list[str]:
|
with get_conn() as conn:
|
dates = 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,
|
)
|
return [normalize_date(v) for v in dates["trade_date"].tolist()]
|
|
|
def previous_trade_dates(trade_dates: list[str], trade_date: str, count: int) -> list[str]:
|
if trade_date not in trade_dates:
|
return []
|
idx = trade_dates.index(trade_date)
|
return trade_dates[max(0, idx - count) : idx]
|
|
|
def window_dates(trade_dates: list[str], entry_date: str, sellable_from: str) -> list[str]:
|
if entry_date not in trade_dates or sellable_from not in trade_dates:
|
return []
|
entry_idx = trade_dates.index(entry_date)
|
start_idx = trade_dates.index(sellable_from)
|
end_idx = min(len(trade_dates) - 1, entry_idx + OBSERVATION_TRADING_DAYS)
|
if start_idx > end_idx:
|
return []
|
return trade_dates[start_idx : end_idx + 1]
|
|
|
def build_fetch_maps(lots: list[LotInput], trade_dates: list[str]) -> tuple[dict[str, set[str]], set[str], str, str]:
|
date_symbols: dict[str, set[str]] = defaultdict(set)
|
symbols: set[str] = set()
|
all_dates: set[str] = set()
|
for lot in lots:
|
symbols.add(lot.symbol)
|
all_dates.add(lot.entry_trade_date)
|
date_symbols[lot.entry_trade_date].add(lot.symbol)
|
for d in window_dates(trade_dates, lot.entry_trade_date, lot.sellable_from_trade_date):
|
date_symbols[d].add(lot.symbol)
|
all_dates.add(d)
|
for prev in previous_trade_dates(trade_dates, d, 5):
|
date_symbols[prev].add(lot.symbol)
|
all_dates.add(prev)
|
min_date = min(all_dates)
|
max_date = max(all_dates)
|
return date_symbols, symbols, min_date, max_date
|
|
|
def fetch_market_data(date_symbols: dict[str, set[str]], symbols: set[str], min_date: str, max_date: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
minute_parts: list[pd.DataFrame] = []
|
with get_conn() as conn:
|
for trade_date in sorted(date_symbols):
|
day_symbols = sorted(date_symbols[trade_date])
|
if not day_symbols:
|
continue
|
ph = ",".join(["%s"] * len(day_symbols))
|
minute_parts.append(
|
pd.read_sql(
|
f"""
|
SELECT trade_date, trade_time, symbol, open_price, high_price, low_price, close_price, volume, amount
|
FROM a_share_minute_price
|
WHERE trade_date = %s AND symbol IN ({ph})
|
ORDER BY symbol, trade_date, trade_time
|
""",
|
conn,
|
params=[trade_date, *day_symbols],
|
)
|
)
|
sym_list = sorted(symbols)
|
ph = ",".join(["%s"] * len(sym_list))
|
daily = pd.read_sql(
|
f"""
|
SELECT trade_date, symbol, open_price, high_price, low_price, close_price, volume, amount
|
FROM a_share_daily_price
|
WHERE symbol IN ({ph}) AND trade_date BETWEEN %s AND %s
|
ORDER BY symbol, trade_date
|
""",
|
conn,
|
params=[*sym_list, min_date, max_date],
|
)
|
minute = pd.concat(minute_parts, ignore_index=True) if minute_parts else pd.DataFrame()
|
if not minute.empty:
|
minute["trade_date"] = minute["trade_date"].map(normalize_date)
|
minute["trade_time"] = minute["trade_time"].map(normalize_time)
|
for col in ["open_price", "high_price", "low_price", "close_price", "volume", "amount"]:
|
minute[col] = pd.to_numeric(minute[col], errors="coerce")
|
if not daily.empty:
|
daily["trade_date"] = daily["trade_date"].map(normalize_date)
|
for col in ["open_price", "high_price", "low_price", "close_price", "volume", "amount"]:
|
daily[col] = pd.to_numeric(daily[col], errors="coerce")
|
daily = daily.sort_values(["symbol", "trade_date"]).reset_index(drop=True)
|
daily["ma5_close"] = daily.groupby("symbol")["close_price"].transform(lambda s: s.rolling(5, min_periods=3).mean())
|
return minute, daily
|
|
|
def make_lookup(df: pd.DataFrame) -> dict[tuple[str, str], pd.DataFrame]:
|
lookup = {}
|
if df.empty:
|
return lookup
|
for (symbol, trade_date), g in df.groupby(["symbol", "trade_date"]):
|
lookup[(symbol, trade_date)] = g.sort_values("trade_time").reset_index(drop=True)
|
return lookup
|
|
|
def daily_lookup_map(daily: pd.DataFrame) -> dict[tuple[str, str], dict]:
|
return {(r["symbol"], r["trade_date"]): r.to_dict() for _, r in daily.iterrows()}
|
|
|
def time_minutes(start: str, end: str) -> float:
|
base = datetime(2000, 1, 1)
|
a = datetime.strptime(start, "%H:%M:%S")
|
b = datetime.strptime(end, "%H:%M:%S")
|
return ((base.replace(hour=b.hour, minute=b.minute, second=b.second) - base.replace(hour=a.hour, minute=a.minute, second=a.second)).total_seconds() / 60)
|
|
|
def support_price_for_lot(lot: LotInput, minute_lookup: dict[tuple[str, str], pd.DataFrame]) -> tuple[float, str]:
|
day = minute_lookup.get((lot.symbol, lot.entry_trade_date), pd.DataFrame())
|
if day.empty:
|
return lot.entry_price, "ENTRY_PRICE_FALLBACK"
|
prior = day[day["trade_time"] <= lot.entry_time]
|
if prior.empty:
|
prior = day.head(1)
|
open_price = safe_float(day.iloc[0]["open_price"], lot.entry_price)
|
prior_low = safe_float(prior["low_price"].min(), lot.entry_price)
|
support = min(open_price, prior_low, lot.entry_price)
|
return support, "ENTRY_DAY_OPEN_OR_PRE_BUY_LOW"
|
|
|
def day_prev_close(symbol: str, trade_date: str, trade_dates: list[str], daily_lookup: dict[tuple[str, str], dict]) -> float:
|
prevs = previous_trade_dates(trade_dates, trade_date, 1)
|
if not prevs:
|
return math.nan
|
return safe_float(daily_lookup.get((symbol, prevs[-1]), {}).get("close_price"))
|
|
|
def previous_first_minute_avg(symbol: str, trade_date: str, trade_dates: list[str], minute_lookup: dict[tuple[str, str], pd.DataFrame]) -> float:
|
vols = []
|
for d in previous_trade_dates(trade_dates, trade_date, 5):
|
day = minute_lookup.get((symbol, d), pd.DataFrame())
|
if not day.empty:
|
vols.append(safe_float(day.iloc[0]["volume"]))
|
return float(pd.Series(vols).mean()) if vols else math.nan
|
|
|
def find_row_at_or_before(day: pd.DataFrame, t: str) -> pd.Series | None:
|
frame = day[day["trade_time"] <= t]
|
if frame.empty:
|
return None
|
return frame.iloc[-1]
|
|
|
def high_rising_three_day(symbol: str, trade_date: str, day: pd.DataFrame, trade_dates: list[str], daily_lookup: dict[tuple[str, str], dict]) -> tuple[bool, str]:
|
prevs = previous_trade_dates(trade_dates, trade_date, 2)
|
if len(prevs) < 2:
|
return False, "THREE_DAY_SOURCE_GAP"
|
high1 = safe_float(daily_lookup.get((symbol, prevs[0]), {}).get("high_price"))
|
high2 = safe_float(daily_lookup.get((symbol, prevs[1]), {}).get("high_price"))
|
morning = day[day["trade_time"] <= "10:40:00"]
|
if morning.empty or math.isnan(high1) or math.isnan(high2):
|
return False, "THREE_DAY_SOURCE_GAP"
|
high3 = safe_float(morning["high_price"].max())
|
ok = (high2 >= high1 * (1 - BREAKOUT_TOLERANCE)) and (high3 >= high2 * (1 - BREAKOUT_TOLERANCE))
|
detail = f"前两日高点 {high1:.4f}->{high2:.4f},当日10:40前高点 {high3:.4f}"
|
return ok, detail
|
|
|
def add_signal(signals: list[dict], *, lot: LotInput, trade_date: str, time: str, signal_type: str, code_action: str, human_action: str, reason: str, price: float, gain_pct: float, support_price: float, support_type: str, extra: dict | None = None) -> dict:
|
extra = extra or {}
|
signal_id = f"SIG-{RUN_ID}-{len(signals)+1:06d}"
|
row = {
|
"signal_id": signal_id,
|
"run_id": RUN_ID,
|
"source_run_id": SOURCE_RUN_ID,
|
"source_lot_id": lot.source_lot_id,
|
"source_order_id": lot.source_order_id,
|
"case_id": lot.case_id,
|
"candidate_id": lot.candidate_id,
|
"symbol": lot.symbol,
|
"entry_trade_date": lot.entry_trade_date,
|
"entry_time": lot.entry_time,
|
"entry_price": f"{lot.entry_price:.4f}",
|
"sellable_from_trade_date": lot.sellable_from_trade_date,
|
"observation_trade_date": trade_date,
|
"candidate_time": time,
|
"signal_type": signal_type,
|
"code_suggested_action": code_action,
|
"human_decision_action": human_action,
|
"human_decision_reason_cn": reason,
|
"decision_operator": "case_analysis.analyst",
|
"decision_time": now_iso(),
|
"decision_source": "ANALYST_RULE_REPLAY_WITH_FROZEN_THRESHOLDS",
|
"action_price": "" if math.isnan(price) else f"{price:.4f}",
|
"gain_pct": "" if math.isnan(gain_pct) else f"{gain_pct:.8f}",
|
"entry_support_price": "" if math.isnan(support_price) else f"{support_price:.4f}",
|
"entry_support_policy": support_type,
|
"chart_path": "",
|
"chart_reason_rendered": "False",
|
"storyboard_reason_rendered": "False",
|
"v1_stats_inclusion_status": "INCLUDED_IF_ORDER_EXECUTED" if human_action in {"SELL", "BUY_ROLLING_LOW"} else "BOUNDARY_OR_HOLD_NOT_A_RETURN_EVENT",
|
}
|
row.update(extra)
|
signals.append(row)
|
return row
|
|
|
def evaluate_lot(lot: LotInput, trade_dates: list[str], minute_lookup: dict[tuple[str, str], pd.DataFrame], daily_lookup: dict[tuple[str, str], dict], allow_rolling: bool = True) -> tuple[list[dict], dict | None, list[dict]]:
|
signals: list[dict] = []
|
rolling_signals: list[dict] = []
|
support_price, support_type = support_price_for_lot(lot, minute_lookup)
|
dates = window_dates(trade_dates, lot.entry_trade_date, lot.sellable_from_trade_date)
|
if not dates:
|
add_signal(
|
signals,
|
lot=lot,
|
trade_date=lot.sellable_from_trade_date,
|
time="",
|
signal_type="SELL_REVIEW_DATA_GAP_HELD",
|
code_action="REVIEW_HELD",
|
human_action="REVIEW_HELD",
|
reason="交易日窗口缺失,无法按 V1 卖点规则裁决,保留待审。",
|
price=math.nan,
|
gain_pct=math.nan,
|
support_price=support_price,
|
support_type=support_type,
|
)
|
return signals, None, rolling_signals
|
|
trend_first3: tuple[str, str, float] | None = None
|
fast_watch_added = False
|
above8_added = False
|
final_sell: dict | None = None
|
|
for d in dates:
|
day = minute_lookup.get((lot.symbol, d), pd.DataFrame())
|
if day.empty:
|
add_signal(
|
signals,
|
lot=lot,
|
trade_date=d,
|
time="",
|
signal_type="SELL_REVIEW_DATA_GAP_HELD",
|
code_action="REVIEW_HELD",
|
human_action="REVIEW_HELD",
|
reason=f"{d} 分钟线缺失,无法裁决精准卖点,保留待审。",
|
price=math.nan,
|
gain_pct=math.nan,
|
support_price=support_price,
|
support_type=support_type,
|
)
|
continue
|
|
prev_close = day_prev_close(lot.symbol, d, trade_dates, daily_lookup)
|
# Hard support breach: buy reason support line is broken after T+1.
|
breach = day[day["low_price"] <= support_price * (1 - SUPPORT_BREAK_TOLERANCE)]
|
if not breach.empty:
|
r = breach.iloc[0]
|
gain = safe_float(r["close_price"]) / lot.entry_price - 1
|
final_sell = add_signal(
|
signals,
|
lot=lot,
|
trade_date=d,
|
time=r["trade_time"],
|
signal_type="SELL_SUPPORT_REASON_BROKEN",
|
code_action="SELL",
|
human_action="SELL",
|
reason=f"买入理由支撑线 {support_price:.4f} 被跌破,按止损铁律卖出。",
|
price=safe_float(r["close_price"]),
|
gain_pct=gain,
|
support_price=support_price,
|
support_type=support_type,
|
)
|
break
|
|
first = day.iloc[0]
|
if not math.isnan(prev_close):
|
first_gain = safe_float(first["close_price"]) / prev_close - 1
|
avg_first_vol = previous_first_minute_avg(lot.symbol, d, trade_dates, minute_lookup)
|
vol_ratio = safe_float(first["volume"]) / avg_first_vol if avg_first_vol and not math.isnan(avg_first_vol) and avg_first_vol > 0 else math.nan
|
if not math.isnan(vol_ratio) and vol_ratio >= 10 and 0.01 <= first_gain <= 0.02:
|
final_sell = add_signal(
|
signals,
|
lot=lot,
|
trade_date=d,
|
time=first["trade_time"],
|
signal_type="SELL_OPEN_VOLUME_STALL",
|
code_action="SELL",
|
human_action="SELL",
|
reason=f"开盘一分钟量比 {vol_ratio:.2f},涨幅 {pct(first_gain)},放量滞涨,直接卖出。",
|
price=safe_float(first["close_price"]),
|
gain_pct=safe_float(first["close_price"]) / lot.entry_price - 1,
|
support_price=support_price,
|
support_type=support_type,
|
extra={"open_volume_ratio": f"{vol_ratio:.4f}", "open_gain_pct": f"{first_gain:.8f}"},
|
)
|
break
|
|
row1040 = find_row_at_or_before(day, "10:40:00")
|
if row1040 is not None and not math.isnan(prev_close):
|
day_open = safe_float(day.iloc[0]["open_price"])
|
morning = day[day["trade_time"] <= "10:40:00"]
|
morning_high = safe_float(morning["high_price"].max())
|
open_down = day_open < prev_close * (1 - OPEN_DOWN_TOLERANCE)
|
if open_down and morning_high <= day_open * (1 + BREAKOUT_TOLERANCE):
|
final_sell = add_signal(
|
signals,
|
lot=lot,
|
trade_date=d,
|
time=row1040["trade_time"],
|
signal_type="SELL_REBOUND_FAIL_OPEN",
|
code_action="SELL",
|
human_action="SELL",
|
reason=f"开盘下跌后反弹最高 {morning_high:.4f} 未有效突破开盘价 {day_open:.4f},直接卖出。",
|
price=safe_float(row1040["close_price"]),
|
gain_pct=safe_float(row1040["close_price"]) / lot.entry_price - 1,
|
support_price=support_price,
|
support_type=support_type,
|
)
|
break
|
if open_down:
|
morning = morning.copy()
|
morning["ma5m"] = morning["close_price"].rolling(5, min_periods=3).mean()
|
seg1 = morning[(morning["trade_time"] >= "09:35:00") & (morning["trade_time"] <= "10:00:00")]
|
seg2 = morning[(morning["trade_time"] > "10:00:00") & (morning["trade_time"] <= "10:40:00")]
|
if not seg1.empty and not seg2.empty:
|
seg1_fail = safe_float(seg1["high_price"].max()) <= safe_float(seg1["ma5m"].max()) * (1 + BREAKOUT_TOLERANCE)
|
seg2_fail = safe_float(seg2["high_price"].max()) <= safe_float(seg2["ma5m"].max()) * (1 + BREAKOUT_TOLERANCE)
|
if seg1_fail and seg2_fail:
|
final_sell = add_signal(
|
signals,
|
lot=lot,
|
trade_date=d,
|
time=row1040["trade_time"],
|
signal_type="SELL_REBOUND_FAIL_MA_TWICE",
|
code_action="SELL",
|
human_action="SELL",
|
reason="开盘下跌后两段反弹均未有效突破 5 分钟均线,按精准卖点卖出。",
|
price=safe_float(row1040["close_price"]),
|
gain_pct=safe_float(row1040["close_price"]) / lot.entry_price - 1,
|
support_price=support_price,
|
support_type=support_type,
|
)
|
break
|
|
high_ok, high_detail = high_rising_three_day(lot.symbol, d, day, trade_dates, daily_lookup)
|
if not high_ok and high_detail != "THREE_DAY_SOURCE_GAP":
|
final_sell = add_signal(
|
signals,
|
lot=lot,
|
trade_date=d,
|
time=row1040["trade_time"],
|
signal_type="SELL_THREE_DAY_HIGH_NOT_RISING",
|
code_action="SELL",
|
human_action="SELL",
|
reason=f"10:40 前三日高点未逐步抬高({high_detail}),直接卖出。",
|
price=safe_float(row1040["close_price"]),
|
gain_pct=safe_float(row1040["close_price"]) / lot.entry_price - 1,
|
support_price=support_price,
|
support_type=support_type,
|
)
|
break
|
|
# Trend take-profit state machine. It logs HOLD decisions as formal decisions too.
|
for _, r in day.iterrows():
|
gain_high = safe_float(r["high_price"]) / lot.entry_price - 1
|
gain_low = safe_float(r["low_price"]) / lot.entry_price - 1
|
gain_close = safe_float(r["close_price"]) / lot.entry_price - 1
|
if trend_first3 is None and gain_high >= GAIN_3:
|
trend_first3 = (d, r["trade_time"], safe_float(r["close_price"]))
|
continue
|
if trend_first3 is None:
|
continue
|
first3_date, first3_time, _ = trend_first3
|
same_day_fast = d == first3_date and time_minutes(first3_time, r["trade_time"]) <= FAST_BREAKOUT_MAX_MINUTES
|
if not fast_watch_added and gain_high >= GAIN_5 and same_day_fast:
|
add_signal(
|
signals,
|
lot=lot,
|
trade_date=d,
|
time=r["trade_time"],
|
signal_type="TREND_FAST_BREAKOUT_5_WATCH",
|
code_action="HOLD_WATCH",
|
human_action="HOLD_WATCH",
|
reason="3% 以上后快速冲过 5%,按新版基版要求观望不卖。",
|
price=safe_float(r["close_price"]),
|
gain_pct=gain_close,
|
support_price=support_price,
|
support_type=support_type,
|
extra={"trend_first3_time": first3_time, "fast_breakout_minutes": f"{time_minutes(first3_time, r['trade_time']):.2f}"},
|
)
|
fast_watch_added = True
|
continue
|
if trend_first3 and not fast_watch_added and d == first3_date and time_minutes(first3_time, r["trade_time"]) > FAST_BREAKOUT_MAX_MINUTES and GAIN_3 <= gain_close < GAIN_5:
|
final_sell = add_signal(
|
signals,
|
lot=lot,
|
trade_date=d,
|
time=r["trade_time"],
|
signal_type="SELL_TREND_3_TO_5_GRADUAL",
|
code_action="SELL",
|
human_action="SELL",
|
reason="上涨不是快速冲 5%,趋势性上涨在 3%-5% 区间,卖出兑现。",
|
price=safe_float(r["close_price"]),
|
gain_pct=gain_close,
|
support_price=support_price,
|
support_type=support_type,
|
extra={"trend_first3_time": first3_time},
|
)
|
break
|
if fast_watch_added and not above8_added and gain_high >= GAIN_8:
|
add_signal(
|
signals,
|
lot=lot,
|
trade_date=d,
|
time=r["trade_time"],
|
signal_type="TREND_ABOVE_8_HOLD",
|
code_action="HOLD_ABOVE_8",
|
human_action="HOLD_ABOVE_8",
|
reason="快速冲过 5% 后继续超过 8%,按新版基版要求强势持有不卖。",
|
price=safe_float(r["close_price"]),
|
gain_pct=gain_close,
|
support_price=support_price,
|
support_type=support_type,
|
)
|
above8_added = True
|
continue
|
if fast_watch_added and not above8_added and gain_low < GAIN_5 and gain_close >= GAIN_3:
|
final_sell = add_signal(
|
signals,
|
lot=lot,
|
trade_date=d,
|
time=r["trade_time"],
|
signal_type="SELL_TREND_PULLBACK_3_TO_5",
|
code_action="SELL",
|
human_action="SELL",
|
reason="快速冲过 5% 后回落到 3%-5% 区间,按新版基版卖出。",
|
price=safe_float(r["close_price"]),
|
gain_pct=gain_close,
|
support_price=support_price,
|
support_type=support_type,
|
)
|
break
|
if final_sell:
|
break
|
|
if final_sell is None:
|
if any(s["human_decision_action"] in {"HOLD_WATCH", "HOLD_ABOVE_8"} for s in signals):
|
last_date = dates[-1]
|
day = minute_lookup.get((lot.symbol, last_date), pd.DataFrame())
|
last_price = safe_float(day.iloc[-1]["close_price"]) if not day.empty else lot.entry_price
|
add_signal(
|
signals,
|
lot=lot,
|
trade_date=last_date,
|
time=day.iloc[-1]["trade_time"] if not day.empty else "",
|
signal_type="SELL_WINDOW_END_HOLD_BOUNDARY",
|
code_action="REVIEW_HELD",
|
human_action="REVIEW_HELD",
|
reason="观察窗口结束仍未出现明确 V1 卖点,保留为窗口末待审边界,不强行卖出。",
|
price=last_price,
|
gain_pct=last_price / lot.entry_price - 1,
|
support_price=support_price,
|
support_type=support_type,
|
)
|
|
if allow_rolling and any(s["human_decision_action"] in {"HOLD_WATCH", "HOLD_ABOVE_8"} for s in signals):
|
rolling_signal = find_rolling_low_buy(lot, dates, trade_dates, minute_lookup, daily_lookup, support_price, support_type)
|
if rolling_signal:
|
rolling_signals.append(rolling_signal)
|
else:
|
add_rolling_signal(
|
rolling_signals,
|
lot=lot,
|
trade_date=dates[-1],
|
time="",
|
signal_type="ROLLING_LOW_BUY_REVIEW_HELD",
|
human_action="REVIEW_HELD",
|
reason="已出现趋势观察但观察窗口内未找到五日线附近止跌放量低吸确认,保留待审。",
|
price=math.nan,
|
ma5=math.nan,
|
volume_ratio=math.nan,
|
)
|
|
return signals, final_sell, rolling_signals
|
|
|
def add_rolling_signal(rows: list[dict], *, lot: LotInput, trade_date: str, time: str, signal_type: str, human_action: str, reason: str, price: float, ma5: float, volume_ratio: float) -> dict:
|
row = {
|
"rolling_signal_id": f"ROLL-{RUN_ID}-{len(rows)+1:06d}",
|
"run_id": RUN_ID,
|
"source_lot_id": lot.source_lot_id,
|
"case_id": lot.case_id,
|
"candidate_id": lot.candidate_id,
|
"symbol": lot.symbol,
|
"entry_trade_date": lot.entry_trade_date,
|
"rolling_trade_date": trade_date,
|
"rolling_time": time,
|
"signal_type": signal_type,
|
"human_decision_action": human_action,
|
"human_decision_reason_cn": reason,
|
"decision_operator": "case_analysis.analyst",
|
"decision_time": now_iso(),
|
"rolling_price": "" if math.isnan(price) else f"{price:.4f}",
|
"ma5_close": "" if math.isnan(ma5) else f"{ma5:.4f}",
|
"near_ma5_pct": "" if math.isnan(price) or math.isnan(ma5) or ma5 == 0 else f"{abs(price - ma5) / ma5:.8f}",
|
"volume_ratio_vs_prev20m": "" if math.isnan(volume_ratio) else f"{volume_ratio:.4f}",
|
"chart_path": "",
|
"chart_reason_rendered": "False",
|
"storyboard_reason_rendered": "False",
|
}
|
rows.append(row)
|
return row
|
|
|
def find_rolling_low_buy(lot: LotInput, dates: list[str], trade_dates: list[str], minute_lookup: dict[tuple[str, str], pd.DataFrame], daily_lookup: dict[tuple[str, str], dict], support_price: float, support_type: str) -> dict | None:
|
for d in dates[1:]:
|
day = minute_lookup.get((lot.symbol, d), pd.DataFrame())
|
if day.empty:
|
continue
|
daily_row = daily_lookup.get((lot.symbol, d), {})
|
ma5 = safe_float(daily_row.get("ma5_close"))
|
if math.isnan(ma5) or ma5 <= 0:
|
continue
|
window = day[(day["trade_time"] >= ROLLING_START_TIME) & (day["trade_time"] <= ROLLING_END_TIME)].copy()
|
if window.empty:
|
continue
|
window["prev_close"] = window["close_price"].shift(1)
|
window["vol20"] = window["volume"].rolling(20, min_periods=5).mean().shift(1)
|
candidates = window[
|
(abs(window["close_price"] - ma5) / ma5 <= ROLLING_NEAR_MA5_PCT)
|
& (window["close_price"] >= window["prev_close"])
|
& (window["volume"] >= window["vol20"] * ROLLING_VOLUME_MULTIPLE)
|
]
|
if not candidates.empty:
|
r = candidates.iloc[0]
|
vol_ratio = safe_float(r["volume"]) / safe_float(r["vol20"]) if safe_float(r["vol20"]) > 0 else math.nan
|
return add_rolling_signal(
|
[],
|
lot=lot,
|
trade_date=d,
|
time=r["trade_time"],
|
signal_type="ADD_ROLLING_LOW_BUY",
|
human_action="BUY_ROLLING_LOW",
|
reason=f"趋势观察后回到五日线附近,接近 MA5 {ma5:.4f},分钟止跌且量能放大 {vol_ratio:.2f} 倍,按滚动渣男低吸一份仓。",
|
price=safe_float(r["close_price"]),
|
ma5=ma5,
|
volume_ratio=vol_ratio,
|
)[0] if False else {
|
"rolling_signal_id": "",
|
"run_id": RUN_ID,
|
"source_lot_id": lot.source_lot_id,
|
"case_id": lot.case_id,
|
"candidate_id": lot.candidate_id,
|
"symbol": lot.symbol,
|
"entry_trade_date": lot.entry_trade_date,
|
"rolling_trade_date": d,
|
"rolling_time": r["trade_time"],
|
"signal_type": "ADD_ROLLING_LOW_BUY",
|
"human_decision_action": "BUY_ROLLING_LOW",
|
"human_decision_reason_cn": f"趋势观察后回到五日线附近,接近 MA5 {ma5:.4f},分钟止跌且量能放大 {vol_ratio:.2f} 倍,按滚动渣男低吸一份仓。",
|
"decision_operator": "case_analysis.analyst",
|
"decision_time": now_iso(),
|
"rolling_price": f"{safe_float(r['close_price']):.4f}",
|
"ma5_close": f"{ma5:.4f}",
|
"near_ma5_pct": f"{abs(safe_float(r['close_price']) - ma5) / ma5:.8f}",
|
"volume_ratio_vs_prev20m": f"{vol_ratio:.4f}",
|
"chart_path": "",
|
"chart_reason_rendered": "False",
|
"storyboard_reason_rendered": "False",
|
}
|
return None
|
|
|
def draw_line_chart(day: pd.DataFrame, signal: dict, out_path: Path, title: str, reason: str, point_time: str, point_price: float, ma5: float | None = None) -> None:
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
w, h = 1500, 900
|
left, top, right, bottom = 90, 90, 1040, 760
|
img = Image.new("RGB", (w, h), "#fffdf7")
|
draw = ImageDraw.Draw(img)
|
draw.text((40, 24), title, fill="#111111", font=FONT_30)
|
if day.empty:
|
draw.text((100, 200), "分钟数据缺失,无法绘图。", fill="#b00020", font=FONT_24)
|
img.save(out_path)
|
return
|
prices = day["close_price"].astype(float).tolist()
|
lows = day["low_price"].astype(float).tolist()
|
highs = day["high_price"].astype(float).tolist()
|
y_min = min(lows + [point_price])
|
y_max = max(highs + [point_price])
|
entry_price = safe_float(signal.get("entry_price"))
|
thresholds = []
|
if not math.isnan(entry_price):
|
thresholds = [entry_price, entry_price * (1 + GAIN_3), entry_price * (1 + GAIN_5), entry_price * (1 + GAIN_8)]
|
y_min = min(y_min, min(thresholds))
|
y_max = max(y_max, max(thresholds))
|
if ma5 and not math.isnan(ma5):
|
y_min = min(y_min, ma5)
|
y_max = max(y_max, ma5)
|
span = max(y_max - y_min, 0.01)
|
y_min -= span * 0.08
|
y_max += span * 0.08
|
span = y_max - y_min
|
|
def x_at(i: int) -> float:
|
return left + i * (right - left) / max(len(prices) - 1, 1)
|
|
def y_at(v: float) -> float:
|
return bottom - (v - y_min) * (bottom - top) / span
|
|
draw.rectangle((left, top, right, bottom), outline="#333333", width=2)
|
for frac in [0, 0.25, 0.5, 0.75, 1.0]:
|
y = top + frac * (bottom - top)
|
draw.line((left, y, right, y), fill="#e5e5e5")
|
points = [(x_at(i), y_at(p)) for i, p in enumerate(prices)]
|
if len(points) > 1:
|
draw.line(points, fill="#1f77b4", width=3)
|
time_to_idx = {t: i for i, t in enumerate(day["trade_time"].tolist())}
|
if point_time in time_to_idx:
|
i = time_to_idx[point_time]
|
else:
|
i = min(range(len(day)), key=lambda k: abs(safe_float(day.iloc[k]["close_price"]) - point_price))
|
px, py = x_at(i), y_at(point_price)
|
draw.line((px, top, px, bottom), fill="#c00000", width=3)
|
draw.ellipse((px - 7, py - 7, px + 7, py + 7), fill="#c00000")
|
draw.text((px + 10, max(top + 5, py - 22)), f"{point_time} {point_price:.2f}", fill="#c00000", font=FONT_20)
|
if not math.isnan(entry_price):
|
for label, value, color in [
|
("买入价", entry_price, "#444444"),
|
("3%", entry_price * 1.03, "#2ca02c"),
|
("5%", entry_price * 1.05, "#ff7f0e"),
|
("8%", entry_price * 1.08, "#9467bd"),
|
]:
|
y = y_at(value)
|
draw.line((left, y, right, y), fill=color, width=2)
|
draw.text((right + 8, y - 12), f"{label} {value:.2f}", fill=color, font=FONT_18)
|
if ma5 and not math.isnan(ma5):
|
y = y_at(ma5)
|
draw.line((left, y, right, y), fill="#17becf", width=2)
|
draw.text((right + 8, y - 12), f"日线MA5 {ma5:.2f}", fill="#008899", font=FONT_18)
|
times = day["trade_time"].tolist()
|
for t in ["09:30:00", "10:40:00", "14:40:00", "15:00:00"]:
|
if t in time_to_idx:
|
x = x_at(time_to_idx[t])
|
draw.line((x, bottom, x, bottom + 10), fill="#333333")
|
draw.text((x - 35, bottom + 14), t[:5], fill="#333333", font=FONT_18)
|
side_x = 1080
|
draw.text((side_x, 100), "人工裁决", fill="#111111", font=FONT_24)
|
lines = [
|
f"动作:{signal.get('human_decision_action', '')}",
|
f"信号:{signal.get('signal_type', signal.get('rolling_signal_type', ''))}",
|
f"股票:{signal.get('symbol', '')}",
|
f"案例:{signal.get('case_id', '')}",
|
f"触发:{signal.get('observation_trade_date', signal.get('rolling_trade_date', ''))} {point_time}",
|
f"价格:{point_price:.4f}",
|
"理由:",
|
]
|
y = 145
|
for line in lines:
|
draw.text((side_x, y), line, fill="#111111", font=FONT_20)
|
y += 34
|
for part in wrap_cn(reason, 19):
|
draw.text((side_x, y), part, fill="#111111", font=FONT_20)
|
y += 32
|
draw.text((side_x, 780), "audit_view:图用于人工复核,不反推当时决策。", fill="#666666", font=FONT_18)
|
img.save(out_path)
|
|
|
def wrap_cn(text: str, width: int) -> list[str]:
|
text = str(text)
|
return [text[i : i + width] for i in range(0, len(text), width)] or [""]
|
|
|
def render_charts(signals: list[dict], rolling_rows: list[dict], minute_lookup: dict[tuple[str, str], pd.DataFrame], daily_lookup: dict[tuple[str, str], dict]) -> None:
|
for sig in signals:
|
date = sig["observation_trade_date"]
|
day = minute_lookup.get((sig["symbol"], date), pd.DataFrame())
|
price = safe_float(sig.get("action_price"), safe_float(sig.get("entry_price")))
|
t = sig["candidate_time"] or (day.iloc[-1]["trade_time"] if not day.empty else "")
|
out_rel = f"cases/{sig['case_id']}/img/v1_sell_signal_{sig['signal_id']}.png"
|
draw_line_chart(
|
day,
|
sig,
|
ROOT / out_rel,
|
f"V1精准卖点/趋势裁决:{sig['case_id']} {sig['symbol']}",
|
sig["human_decision_reason_cn"],
|
t,
|
price,
|
)
|
sig["chart_path"] = out_rel
|
sig["chart_reason_rendered"] = "True"
|
sig["storyboard_reason_rendered"] = "True"
|
|
for i, row in enumerate(rolling_rows, 1):
|
if not row.get("rolling_signal_id"):
|
row["rolling_signal_id"] = f"ROLL-{RUN_ID}-{i:06d}"
|
date = row["rolling_trade_date"]
|
day = minute_lookup.get((row["symbol"], date), pd.DataFrame())
|
price = safe_float(row.get("rolling_price"))
|
ma5 = safe_float(row.get("ma5_close"))
|
t = row["rolling_time"] or (day.iloc[-1]["trade_time"] if not day.empty else "")
|
out_rel = f"cases/{row['case_id']}/img/v1_rolling_low_buy_{row['rolling_signal_id']}.png"
|
signal_like = {
|
"case_id": row["case_id"],
|
"symbol": row["symbol"],
|
"human_decision_action": row["human_decision_action"],
|
"signal_type": row["signal_type"],
|
"rolling_trade_date": date,
|
"entry_price": row.get("rolling_price", ""),
|
}
|
draw_line_chart(
|
day,
|
signal_like,
|
ROOT / out_rel,
|
f"V1滚动渣男低吸裁决:{row['case_id']} {row['symbol']}",
|
row["human_decision_reason_cn"],
|
t,
|
price if not math.isnan(price) else safe_float(day.iloc[-1]["close_price"], 0) if not day.empty else 0,
|
ma5,
|
)
|
row["chart_path"] = out_rel
|
row["chart_reason_rendered"] = "True"
|
row["storyboard_reason_rendered"] = "True"
|
|
|
def make_order(order_id: str, lot: LotInput, action: str, trade_date: str, trade_time: str, price: float, position_delta_pct: float, reason: str, evidence: str, source_lot_id: str = "", exit_signal_type: str = "") -> dict:
|
return {
|
"order_id": order_id,
|
"run_id": RUN_ID,
|
"source_run_id": SOURCE_RUN_ID,
|
"case_id": lot.case_id,
|
"candidate_id": lot.candidate_id,
|
"variant_id": lot.variant_id or "V1_STRICT_SELL_ROLLING",
|
"symbol": lot.symbol,
|
"trade_date": trade_date,
|
"trade_time": trade_time,
|
"action": action,
|
"price": f"{price:.4f}",
|
"position_delta_pct": f"{position_delta_pct:.8f}",
|
"tranche_index": lot.tranche_index,
|
"planned_tranche_count": MAX_TRANCHES_PER_CASE_SYMBOL,
|
"decision_reason_cn": reason,
|
"evidence_image_path": evidence,
|
"t1_sellable_from_trade_date": lot.sellable_from_trade_date if action == "BUY" else "",
|
"lookahead_violation_flag": "False",
|
"source_lot_id": source_lot_id,
|
"source_order_id": lot.source_order_id,
|
"exit_signal_type": exit_signal_type,
|
}
|
|
|
def build_ledgers(source_lots: list[LotInput], sell_signals: list[dict], final_sells: dict[str, dict], rolling_rows: list[dict], trade_dates: list[str], minute_lookup: dict[tuple[str, str], pd.DataFrame], daily_lookup: dict[tuple[str, str], dict]) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, list[LotInput]]:
|
orders: list[dict] = []
|
lots: list[dict] = []
|
new_lots: list[LotInput] = []
|
order_seq = 1
|
lot_seq = 1
|
tranche_counts: Counter[tuple[str, str]] = Counter()
|
|
for lot in source_lots:
|
tranche_counts[(lot.case_id, lot.symbol)] = max(tranche_counts[(lot.case_id, lot.symbol)], lot.tranche_index)
|
oid = f"ORD-{RUN_ID}-{order_seq:06d}"
|
order_seq += 1
|
orders.append(
|
make_order(
|
oid,
|
lot,
|
"BUY",
|
lot.entry_trade_date,
|
lot.entry_time,
|
lot.entry_price,
|
lot.position_pct,
|
"沿用已审核旧包买入裁决,V1 只返修卖点和滚动低吸;旧买入图片作为来源证据。",
|
lot.evidence_image_path,
|
)
|
)
|
sell = final_sells.get(lot.source_lot_id)
|
status = "V1_OPEN_OR_BOUNDARY_HELD"
|
exit_date = exit_time = exit_price = ""
|
lot_ret = account_ret = ""
|
if sell and sell["human_decision_action"] == "SELL":
|
price = safe_float(sell["action_price"])
|
oid_sell = f"ORD-{RUN_ID}-{order_seq:06d}"
|
order_seq += 1
|
orders.append(
|
make_order(
|
oid_sell,
|
lot,
|
"SELL",
|
sell["observation_trade_date"],
|
sell["candidate_time"],
|
price,
|
lot.position_pct,
|
sell["human_decision_reason_cn"],
|
sell["chart_path"],
|
source_lot_id=lot.source_lot_id,
|
exit_signal_type=sell["signal_type"],
|
)
|
)
|
status = "CLOSED_BY_V1_SELL"
|
exit_date = sell["observation_trade_date"]
|
exit_time = sell["candidate_time"]
|
exit_price = f"{price:.4f}"
|
ret = price / lot.entry_price - 1
|
lot_ret = f"{ret:.8f}"
|
account_ret = f"{ret * lot.position_pct:.8f}"
|
lots.append(
|
{
|
"strict_lot_id": f"LOT-{RUN_ID}-{lot_seq:06d}",
|
"source_lot_id": lot.source_lot_id,
|
"source_order_id": lot.source_order_id,
|
"case_id": lot.case_id,
|
"symbol": lot.symbol,
|
"entry_trade_date": lot.entry_trade_date,
|
"entry_time": lot.entry_time,
|
"entry_price": f"{lot.entry_price:.4f}",
|
"position_pct": f"{lot.position_pct:.8f}",
|
"tranche_index": lot.tranche_index,
|
"sellable_from_trade_date": lot.sellable_from_trade_date,
|
"lot_status": status,
|
"exit_trade_date": exit_date,
|
"exit_time": exit_time,
|
"exit_price": exit_price,
|
"lot_return_pct": lot_ret,
|
"account_return_contribution_pct": account_ret,
|
"parent_lot_id": lot.parent_lot_id,
|
"lot_source_type": "SOURCE_BUY" if not lot.is_rolling else "ROLLING_LOW_BUY",
|
}
|
)
|
lot_seq += 1
|
|
confirmed_rolls = [r for r in rolling_rows if r["human_decision_action"] == "BUY_ROLLING_LOW"]
|
for row in confirmed_rolls:
|
key = (row["case_id"], row["symbol"])
|
if tranche_counts[key] >= MAX_TRANCHES_PER_CASE_SYMBOL:
|
continue
|
tranche_counts[key] += 1
|
roll_price = safe_float(row["rolling_price"])
|
if math.isnan(roll_price):
|
continue
|
sellable = next_trade_date(trade_dates, row["rolling_trade_date"])
|
roll_lot = LotInput(
|
source_lot_id=row["rolling_signal_id"],
|
source_order_id=row["rolling_signal_id"],
|
case_id=row["case_id"],
|
symbol=row["symbol"],
|
entry_trade_date=row["rolling_trade_date"],
|
entry_time=row["rolling_time"],
|
entry_price=roll_price,
|
position_pct=POSITION_PCT_PER_TRANCHE,
|
tranche_index=tranche_counts[key],
|
sellable_from_trade_date=sellable or row["rolling_trade_date"],
|
candidate_id=row["candidate_id"],
|
variant_id="V1_ROLLING_LOW_BUY",
|
decision_reason_cn=row["human_decision_reason_cn"],
|
evidence_image_path=row["chart_path"],
|
is_rolling=True,
|
parent_lot_id=row["source_lot_id"],
|
)
|
new_lots.append(roll_lot)
|
oid = f"ORD-{RUN_ID}-{order_seq:06d}"
|
order_seq += 1
|
orders.append(
|
make_order(
|
oid,
|
roll_lot,
|
"BUY",
|
roll_lot.entry_trade_date,
|
roll_lot.entry_time,
|
roll_lot.entry_price,
|
roll_lot.position_pct,
|
roll_lot.decision_reason_cn,
|
roll_lot.evidence_image_path,
|
)
|
)
|
|
# Evaluate rolling lots once, without recursive additional rolling.
|
rolling_sell_signals: list[dict] = []
|
rolling_final_sells: dict[str, dict] = {}
|
for roll_lot in new_lots:
|
sigs, final_sell, _ = evaluate_lot(roll_lot, trade_dates, minute_lookup, daily_lookup, allow_rolling=False)
|
rolling_sell_signals.extend(sigs)
|
if final_sell:
|
rolling_final_sells[roll_lot.source_lot_id] = final_sell
|
assign_sell_signal_ids(rolling_sell_signals, len(sell_signals) + 1)
|
sell_signals.extend(rolling_sell_signals)
|
render_charts(rolling_sell_signals, [], minute_lookup, daily_lookup)
|
for roll_lot in new_lots:
|
sell = rolling_final_sells.get(roll_lot.source_lot_id)
|
status = "V1_OPEN_OR_BOUNDARY_HELD"
|
exit_date = exit_time = exit_price = ""
|
lot_ret = account_ret = ""
|
if sell and sell["human_decision_action"] == "SELL":
|
price = safe_float(sell["action_price"])
|
oid_sell = f"ORD-{RUN_ID}-{order_seq:06d}"
|
order_seq += 1
|
orders.append(
|
make_order(
|
oid_sell,
|
roll_lot,
|
"SELL",
|
sell["observation_trade_date"],
|
sell["candidate_time"],
|
price,
|
roll_lot.position_pct,
|
sell["human_decision_reason_cn"],
|
sell["chart_path"],
|
source_lot_id=roll_lot.source_lot_id,
|
exit_signal_type=sell["signal_type"],
|
)
|
)
|
status = "CLOSED_BY_V1_SELL"
|
exit_date = sell["observation_trade_date"]
|
exit_time = sell["candidate_time"]
|
exit_price = f"{price:.4f}"
|
ret = price / roll_lot.entry_price - 1
|
lot_ret = f"{ret:.8f}"
|
account_ret = f"{ret * roll_lot.position_pct:.8f}"
|
lots.append(
|
{
|
"strict_lot_id": f"LOT-{RUN_ID}-{lot_seq:06d}",
|
"source_lot_id": roll_lot.source_lot_id,
|
"source_order_id": roll_lot.source_order_id,
|
"case_id": roll_lot.case_id,
|
"symbol": roll_lot.symbol,
|
"entry_trade_date": roll_lot.entry_trade_date,
|
"entry_time": roll_lot.entry_time,
|
"entry_price": f"{roll_lot.entry_price:.4f}",
|
"position_pct": f"{roll_lot.position_pct:.8f}",
|
"tranche_index": roll_lot.tranche_index,
|
"sellable_from_trade_date": roll_lot.sellable_from_trade_date,
|
"lot_status": status,
|
"exit_trade_date": exit_date,
|
"exit_time": exit_time,
|
"exit_price": exit_price,
|
"lot_return_pct": lot_ret,
|
"account_return_contribution_pct": account_ret,
|
"parent_lot_id": roll_lot.parent_lot_id,
|
"lot_source_type": "ROLLING_LOW_BUY",
|
}
|
)
|
lot_seq += 1
|
|
order_df = pd.DataFrame(orders)
|
lot_df = pd.DataFrame(lots)
|
account_df = build_account_ledger(order_df, lot_df)
|
case_df = build_case_summary(lot_df)
|
return order_df, lot_df, account_df, case_df, new_lots
|
|
|
def next_trade_date(trade_dates: list[str], date: str) -> str:
|
if date not in trade_dates:
|
return ""
|
idx = trade_dates.index(date)
|
return trade_dates[idx + 1] if idx + 1 < len(trade_dates) else ""
|
|
|
def build_account_ledger(order_df: pd.DataFrame, lot_df: pd.DataFrame) -> pd.DataFrame:
|
lot_by_source = lot_df.set_index("source_lot_id").to_dict("index") if not lot_df.empty else {}
|
rows = []
|
cash = 1.0
|
open_pos = 0.0
|
realized = 0.0
|
seq = 1
|
df = order_df.copy()
|
df["_dt"] = pd.to_datetime(df["trade_date"] + " " + df["trade_time"].replace("", "00:00:00"))
|
for _, order in df.sort_values(["_dt", "order_id"]).iterrows():
|
pos = safe_float(order["position_delta_pct"], 0.0)
|
flow = 0.0
|
if order["action"] == "BUY":
|
cash -= pos
|
open_pos += pos
|
flow = -pos
|
elif order["action"] == "SELL":
|
source_lot_id = order.get("source_lot_id", "")
|
lot = lot_by_source.get(source_lot_id, {})
|
entry_price = safe_float(lot.get("entry_price"), safe_float(order["price"]))
|
sell_price = safe_float(order["price"])
|
ret = sell_price / entry_price - 1 if entry_price else 0.0
|
release = pos * (1 + ret)
|
cash += release
|
open_pos -= pos
|
realized += pos * ret
|
flow = release
|
rows.append(
|
{
|
"seq": seq,
|
"order_id": order["order_id"],
|
"case_id": order["case_id"],
|
"symbol": order["symbol"],
|
"trade_date": order["trade_date"],
|
"trade_time": order["trade_time"],
|
"action": order["action"],
|
"cash_flow_pct": f"{flow:.8f}",
|
"cash_pct_after_event": f"{cash:.8f}",
|
"open_position_pct_after_event": f"{open_pos:.8f}",
|
"realized_return_pct_after_event": f"{realized:.8f}",
|
"nav_pct_after_event": f"{cash + open_pos:.8f}",
|
}
|
)
|
seq += 1
|
return pd.DataFrame(rows)
|
|
|
def build_case_summary(lot_df: pd.DataFrame) -> pd.DataFrame:
|
rows = []
|
for case_id, g in lot_df.groupby("case_id"):
|
closed = g[g["lot_status"] == "CLOSED_BY_V1_SELL"]
|
unresolved = g[g["lot_status"] != "CLOSED_BY_V1_SELL"]
|
account_ret = pd.to_numeric(closed["account_return_contribution_pct"], errors="coerce").fillna(0).sum()
|
rows.append(
|
{
|
"case_id": case_id,
|
"buy_lot_count": len(g),
|
"closed_lot_count": len(closed),
|
"unresolved_lot_count": len(unresolved),
|
"account_return_closed_lots": f"{account_ret:.8f}",
|
"v1_primary_strict_closed_case_flag": 1 if len(g) > 0 and len(unresolved) == 0 else 0,
|
"v1_case_success_flag": 1 if len(g) > 0 and len(unresolved) == 0 and account_ret > 0 else 0,
|
"v1_return_scope": "V1_PRIMARY_STRICT_CLOSED_CASE" if len(g) > 0 and len(unresolved) == 0 else "V1_RETURN_HELD_BOUNDARY_TABLE",
|
"v1_boundary_reason": "" if len(unresolved) == 0 else "存在 V1 未闭合 / 待审 lot,不进入 V1 主收益口径。",
|
}
|
)
|
return pd.DataFrame(rows).sort_values("case_id")
|
|
|
def build_scope_and_boundary(case_df: pd.DataFrame, lot_df: pd.DataFrame, sell_signals: list[dict], rolling_rows: list[dict]) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
case_scope = case_df.copy()
|
lot_scope = lot_df.copy()
|
lot_scope["v1_lot_scope"] = lot_scope["lot_status"].map(lambda s: "V1_STRICT_CLOSED_LOT_RECALC_ONLY" if s == "CLOSED_BY_V1_SELL" else "V1_RETURN_HELD_BOUNDARY_TABLE")
|
boundary_rows = []
|
for _, row in case_scope[case_scope["v1_primary_strict_closed_case_flag"] == 0].iterrows():
|
boundary_rows.append(
|
{
|
"boundary_id": f"BOUND-CASE-{row['case_id']}",
|
"boundary_level": "CASE",
|
"case_id": row["case_id"],
|
"source_lot_id": "",
|
"symbol": "",
|
"boundary_category": "V1_UNRESOLVED_LOT_OR_REVIEW_HELD",
|
"boundary_reason": row["v1_boundary_reason"],
|
}
|
)
|
for _, row in lot_scope[lot_scope["lot_status"] != "CLOSED_BY_V1_SELL"].iterrows():
|
boundary_rows.append(
|
{
|
"boundary_id": f"BOUND-LOT-{row['source_lot_id']}",
|
"boundary_level": "LOT",
|
"case_id": row["case_id"],
|
"source_lot_id": row["source_lot_id"],
|
"symbol": row["symbol"],
|
"boundary_category": row["lot_status"],
|
"boundary_reason": "V1 观察窗口内没有真实 SELL 或存在人工待审 / 数据缺口。",
|
}
|
)
|
if not boundary_rows:
|
boundary_rows.append(
|
{
|
"boundary_id": "BOUND-GLOBAL-MARKET-RISK-DATA-GAP",
|
"boundary_level": "GLOBAL",
|
"case_id": "",
|
"source_lot_id": "",
|
"symbol": "",
|
"boundary_category": "MARKET_RISK_MINUTE_BREADTH_DATA_GAP_HELD",
|
"boundary_reason": "本地数据源未提供开盘 10 分钟全 A 下跌家数分钟级广度;市场风险卖点以数据缺口边界保留。",
|
}
|
)
|
else:
|
boundary_rows.append(
|
{
|
"boundary_id": "BOUND-GLOBAL-MARKET-RISK-DATA-GAP",
|
"boundary_level": "GLOBAL",
|
"case_id": "",
|
"source_lot_id": "",
|
"symbol": "",
|
"boundary_category": "MARKET_RISK_MINUTE_BREADTH_DATA_GAP_HELD",
|
"boundary_reason": "本地数据源未提供开盘 10 分钟全 A 下跌家数分钟级广度;市场风险卖点以数据缺口边界保留。",
|
}
|
)
|
return case_scope, lot_scope, pd.DataFrame(boundary_rows)
|
|
|
def build_sampling_index(sell_signals: list[dict], rolling_rows: list[dict]) -> pd.DataFrame:
|
rows = []
|
combined = []
|
for s in sell_signals:
|
combined.append(("SELL_SIGNAL", s["human_decision_action"], s["signal_id"], s["case_id"], s["symbol"], s["chart_path"], s["human_decision_reason_cn"]))
|
for r in rolling_rows:
|
combined.append(("ROLLING_SIGNAL", r["human_decision_action"], r["rolling_signal_id"], r["case_id"], r["symbol"], r["chart_path"], r["human_decision_reason_cn"]))
|
by_action: dict[str, list[tuple]] = defaultdict(list)
|
for item in combined:
|
by_action[item[1]].append(item)
|
target = max(math.ceil(len(combined) * AUDIT_SAMPLE_TARGET_RATIO), math.ceil(len(combined) * AUDIT_SAMPLE_MIN_RATIO))
|
selected = []
|
for action, items in sorted(by_action.items()):
|
take = len(items) if len(items) <= 30 else max(1, math.ceil(len(items) * AUDIT_SAMPLE_TARGET_RATIO))
|
selected.extend(items[:take])
|
if len(selected) < target:
|
remaining = [x for x in combined if x not in selected]
|
selected.extend(remaining[: target - len(selected)])
|
for i, item in enumerate(selected, 1):
|
rows.append(
|
{
|
"sample_seq": i,
|
"artifact_type": item[0],
|
"human_decision_action": item[1],
|
"signal_id": item[2],
|
"case_id": item[3],
|
"symbol": item[4],
|
"chart_path": item[5],
|
"human_decision_reason_cn": item[6],
|
"sample_policy": "30% target / 20% minimum, full check when action bucket <= 30",
|
}
|
)
|
return pd.DataFrame(rows)
|
|
|
def write_config() -> None:
|
config = {
|
"schema_version": "1.0",
|
"run_id": RUN_ID,
|
"task_id": TASK_ID,
|
"design_id": DESIGN_ID,
|
"design_audit_id": DESIGN_AUDIT_ID,
|
"supplemental_design_audit_id": SUPP_DESIGN_AUDIT_ID,
|
"source_run_id": SOURCE_RUN_ID,
|
"source_execution_audit_id": SOURCE_EXEC_AUDIT_ID,
|
"issue_id": ISSUE_ID,
|
"generated_at": now_iso(),
|
"scope": "V1 strict sellpoint and rolling-low-buy repair replay over the audited full baseline source package.",
|
"source_package_policy": "Read-only. Do not overwrite V0 audited package.",
|
"observation_trading_days": OBSERVATION_TRADING_DAYS,
|
"trend_take_profit": {
|
"gain_reference": "entry_price",
|
"thresholds": {"trend_low": GAIN_3, "fast_cross": GAIN_5, "strong_hold": GAIN_8},
|
"fast_breakout_max_minutes": FAST_BREAKOUT_MAX_MINUTES,
|
"human_decision_required": True,
|
"actions": ["SELL", "HOLD_WATCH", "HOLD_ABOVE_8", "REVIEW_HELD"],
|
},
|
"precise_sellpoints": {
|
"support_break": {"support_price_source": "ENTRY_DAY_OPEN_OR_PRE_BUY_LOW", "break_tolerance": SUPPORT_BREAK_TOLERANCE},
|
"rebound_fail_open": {"open_down_tolerance": OPEN_DOWN_TOLERANCE, "breakout_tolerance": BREAKOUT_TOLERANCE},
|
"rebound_fail_ma_twice": {"ma_type": "5-minute rolling close MA", "segments": ["09:35-10:00", "10:00-10:40"]},
|
"open_volume_stall": {"first_minute_volume_ratio_threshold": 10, "gain_range": [0.01, 0.02]},
|
"three_day_high": {"decision_time": "10:40:00", "policy": "D-2 high -> D-1 high -> current day high before 10:40 should rise"},
|
"market_risk": {"minute_breadth_required": True, "data_gap_status": "MARKET_RISK_MINUTE_BREADTH_DATA_GAP_HELD"},
|
},
|
"rolling_low_buy": {
|
"enabled": True,
|
"window": [ROLLING_START_TIME, ROLLING_END_TIME],
|
"near_ma5_pct": ROLLING_NEAR_MA5_PCT,
|
"volume_multiple_vs_prev20m": ROLLING_VOLUME_MULTIPLE,
|
"position_pct_per_tranche": POSITION_PCT_PER_TRANCHE,
|
"max_tranches_per_case_symbol": MAX_TRANCHES_PER_CASE_SYMBOL,
|
"human_decision_actions": ["BUY_ROLLING_LOW", "REVIEW_HELD"],
|
},
|
"manual_review_sampling": {
|
"minimum_ratio": AUDIT_SAMPLE_MIN_RATIO,
|
"target_ratio": AUDIT_SAMPLE_TARGET_RATIO,
|
"small_bucket_full_check_threshold": 30,
|
"must_cover_actions": ["SELL", "HOLD_WATCH", "HOLD_ABOVE_8", "REVIEW_HELD", "BUY_ROLLING_LOW"],
|
},
|
"return_boundary": {
|
"return_stat_ready": False,
|
"v1_execution_review_required_before_any_v1_performance_claim": True,
|
},
|
}
|
write_json(ROOT / "strict_sell_rolling_run_config.json", config)
|
lines = [
|
"# 无忌 V1 精准卖点与滚动渣男返修执行配置冻结",
|
"",
|
f"- run_id:`{RUN_ID}`",
|
f"- source_run_id:`{SOURCE_RUN_ID}`(只读,不覆盖)",
|
f"- design_audit_id:`{DESIGN_AUDIT_ID}`",
|
f"- supplemental_design_audit_id:`{SUPP_DESIGN_AUDIT_ID}`",
|
f"- 观察窗口:买入后 {OBSERVATION_TRADING_DAYS} 个交易日,T+1 后才允许 SELL。",
|
f"- 趋势阈值:3% / 5% / 8%,快速冲 5% 的最长窗口为 {FAST_BREAKOUT_MAX_MINUTES} 分钟。",
|
"- 3% / 5% / 8% 不是机械止盈;代码只产出候选和证据,裁决字段必须保留动作与中文理由。",
|
"- 滚动低吸:10:40-14:40,靠近日线 MA5,分钟止跌并放量,单次一份仓。",
|
f"- 人工裁决审核抽样:最低 {AUDIT_SAMPLE_MIN_RATIO:.0%},原则 {AUDIT_SAMPLE_TARGET_RATIO:.0%};小桶不超过 30 条时倾向全量检查。",
|
"- RETURN_STAT_READY=false;V1 执行审核通过前不得引用 V1 业绩结论。",
|
"",
|
]
|
(ROOT / "strict_sell_rolling_run_config.md").write_text("\n".join(lines), encoding="utf-8")
|
|
|
def write_rule_mapping() -> None:
|
rows = [
|
["买入理由跌破就是止损线", "SELL_SUPPORT_REASON_BROKEN", "entry_day open/pre-buy low support with tolerance", "strict_sell_signal_ledger.csv"],
|
["两次反弹破不了均线", "SELL_REBOUND_FAIL_MA_TWICE", "open down + two morning rebound segments below 5m MA", "strict_sell_signal_ledger.csv"],
|
["反弹连开盘价都破不了", "SELL_REBOUND_FAIL_OPEN", "open down + morning high below open", "strict_sell_signal_ledger.csv"],
|
["开盘一分钟放量滞涨", "SELL_OPEN_VOLUME_STALL", "first-minute volume ratio >=10 and gain 1%-2%", "strict_sell_signal_ledger.csv"],
|
["10:40前三日高点未逐步抬高", "SELL_THREE_DAY_HIGH_NOT_RISING", "D-2/D-1/current morning high not rising", "strict_sell_signal_ledger.csv"],
|
["3%-5%非快速趋势上涨", "SELL_TREND_3_TO_5_GRADUAL", "3% entered but no fast 5% cross within frozen window", "strict_sell_signal_ledger.csv"],
|
["快速冲过5%观望", "TREND_FAST_BREAKOUT_5_WATCH", "hold/watch decision with chart reason", "strict_sell_signal_ledger.csv"],
|
["超过8%不动", "TREND_ABOVE_8_HOLD", "hold above 8 decision with chart reason", "strict_sell_signal_ledger.csv"],
|
["回落到3%-5%卖出", "SELL_TREND_PULLBACK_3_TO_5", "fast 5 watch then pullback into 3%-5%", "strict_sell_signal_ledger.csv"],
|
["五日线附近止跌放量反复低吸", "ADD_ROLLING_LOW_BUY", "near daily MA5 and minute stop-fall volume confirmation", "rolling_low_buy_signal_ledger.csv"],
|
["市场开盘10分钟下杀", "MARKET_RISK_MINUTE_BREADTH_DATA_GAP_HELD", "minute breadth unavailable; retained as boundary", "strict_boundary_table.csv"],
|
]
|
with (ROOT / "strict_rule_mapping.csv").open("w", encoding="utf-8-sig", newline="") as f:
|
writer = csv.writer(f)
|
writer.writerow(["source_note_semantic", "v1_rule_code", "frozen_execution_policy", "evidence_artifact"])
|
writer.writerows(rows)
|
|
|
def write_boards(case_df: pd.DataFrame, sell_signals: list[dict], rolling_rows: list[dict]) -> None:
|
signals_by_case = defaultdict(list)
|
for s in sell_signals:
|
signals_by_case[s["case_id"]].append(s)
|
rolls_by_case = defaultdict(list)
|
for r in rolling_rows:
|
rolls_by_case[r["case_id"]].append(r)
|
|
root_lines = [
|
"# 无忌 V1 精准卖点与滚动渣男返修图片入口",
|
"",
|
f"- run_id:`{RUN_ID}`",
|
f"- 来源包:`{SOURCE_RUN_ID}`",
|
f"- 当前阶段:`V1_STRICT_SELL_ROLLING_SELF_CHECK_DONE_EXEC_REVIEW_PENDING`",
|
"- 说明:本入口展示 V1 精准卖点、趋势 3%/5%/8% 人工裁决、滚动低吸候选与中文理由。",
|
"- 边界:RETURN_STAT_READY=false;执行审核通过前不得引用 V1 业绩结论。",
|
"",
|
"## 案例入口",
|
"",
|
]
|
for _, row in case_df.sort_values("case_id").iterrows():
|
root_lines.append(
|
f"- [{row['case_id']}](cases/{row['case_id']}/case_image_board.md):scope={row['v1_return_scope']},闭合 lot={row['closed_lot_count']},未闭合/待审 lot={row['unresolved_lot_count']},收益贡献={row['account_return_closed_lots']}"
|
)
|
(ROOT / "case_image_board.md").write_text("\n".join(root_lines) + "\n", encoding="utf-8")
|
|
for _, row in case_df.iterrows():
|
case_id = row["case_id"]
|
case_dir = ROOT / "cases" / case_id
|
case_dir.mkdir(parents=True, exist_ok=True)
|
lines = [
|
f"# {case_id} V1 精准卖点与滚动低吸图板",
|
"",
|
f"- 当前收益口径:`{row['v1_return_scope']}`",
|
f"- 主口径标记:`{row['v1_primary_strict_closed_case_flag']}`",
|
f"- 中文边界原因:{row['v1_boundary_reason'] or '所有 V1 lot 均已真实 SELL 闭合。'}",
|
f"- 闭合 lot:{row['closed_lot_count']};未闭合/待审 lot:{row['unresolved_lot_count']}",
|
"",
|
"## 精准卖点 / 趋势裁决",
|
"",
|
]
|
for sig in signals_by_case.get(case_id, []):
|
rel = Path(sig["chart_path"]).relative_to(f"cases/{case_id}").as_posix() if sig.get("chart_path") else ""
|
lines.extend(
|
[
|
f"### {sig['signal_type']} / {sig['human_decision_action']}",
|
"",
|
f"- 时间:{sig['observation_trade_date']} {sig['candidate_time']}",
|
f"- 理由:{sig['human_decision_reason_cn']}",
|
f"- 图:![{sig['signal_id']}]({rel})",
|
"",
|
]
|
)
|
lines.extend(["## 滚动低吸裁决", ""])
|
for roll in rolls_by_case.get(case_id, []):
|
rel = Path(roll["chart_path"]).relative_to(f"cases/{case_id}").as_posix() if roll.get("chart_path") else ""
|
lines.extend(
|
[
|
f"### {roll['signal_type']} / {roll['human_decision_action']}",
|
"",
|
f"- 时间:{roll['rolling_trade_date']} {roll['rolling_time']}",
|
f"- 理由:{roll['human_decision_reason_cn']}",
|
f"- 图:![{roll['rolling_signal_id']}]({rel})",
|
"",
|
]
|
)
|
lines.extend(
|
[
|
"## 账本追溯",
|
"",
|
"- 根订单账本:`../../strict_order_ledger.csv`",
|
"- 根 lot 账本:`../../strict_position_lot_ledger.csv`",
|
"- 根 case scope:`../../strict_return_scope_case.csv`",
|
"- 根 boundary table:`../../strict_boundary_table.csv`",
|
"",
|
]
|
)
|
text = "\n".join(lines) + "\n"
|
(case_dir / "case_image_board.md").write_text(text, encoding="utf-8")
|
(case_dir / "case_story_board.md").write_text(text.replace("图板", "故事板"), encoding="utf-8")
|
|
|
def audit_links() -> pd.DataFrame:
|
rows = []
|
pattern = re.compile(r"\[[^\]]*\]\(([^)]+)\)|!\[[^\]]*\]\(([^)]+)\)")
|
for md in ROOT.rglob("*.md"):
|
text = md.read_text(encoding="utf-8", errors="ignore")
|
for match in pattern.finditer(text):
|
target = match.group(1) or match.group(2)
|
if not target or target.startswith(("http://", "https://", "#")):
|
continue
|
path = (md.parent / target).resolve()
|
rows.append(
|
{
|
"markdown_path": md.relative_to(ROOT).as_posix(),
|
"target": target,
|
"resolved_project_path": path.as_posix(),
|
"exists": path.exists(),
|
}
|
)
|
return pd.DataFrame(rows)
|
|
|
def manifest() -> pd.DataFrame:
|
rows = []
|
for path in sorted(ROOT.rglob("*")):
|
if path.is_file():
|
if "__pycache__" in path.parts:
|
continue
|
rel = path.relative_to(ROOT).as_posix()
|
rows.append({"path": rel, "size": path.stat().st_size, "sha256": sha256_file(path)})
|
return pd.DataFrame(rows)
|
|
|
def clean_generated_outputs() -> None:
|
if ROOT.name != RUN_ID:
|
raise RuntimeError(f"Refusing to clean unexpected result root: {ROOT}")
|
for child in ROOT.iterdir():
|
if child.name == "tools":
|
continue
|
if child.is_dir():
|
shutil.rmtree(child)
|
else:
|
child.unlink()
|
|
|
def assign_sell_signal_ids(signals: list[dict], start: int = 1) -> None:
|
for i, sig in enumerate(signals, start):
|
sig["signal_id"] = f"SIG-{RUN_ID}-{i:06d}"
|
sig["chart_path"] = ""
|
sig["chart_reason_rendered"] = "False"
|
sig["storyboard_reason_rendered"] = "False"
|
|
|
def assign_rolling_signal_ids(rows: list[dict], start: int = 1) -> None:
|
for i, row in enumerate(rows, start):
|
row["rolling_signal_id"] = f"ROLL-{RUN_ID}-{i:06d}"
|
row["chart_path"] = ""
|
row["chart_reason_rendered"] = "False"
|
row["storyboard_reason_rendered"] = "False"
|
|
|
def chart_audit(signals: list[dict], rolling_rows: list[dict]) -> pd.DataFrame:
|
rows = []
|
for s in signals:
|
p = ROOT / s["chart_path"] if s.get("chart_path") else ROOT / "__missing__"
|
rows.append({"artifact_type": "strict_sell_signal", "signal_id": s["signal_id"], "case_id": s["case_id"], "chart_path": s.get("chart_path", ""), "exists": p.exists()})
|
for r in rolling_rows:
|
p = ROOT / r["chart_path"] if r.get("chart_path") else ROOT / "__missing__"
|
rows.append({"artifact_type": "rolling_low_buy_signal", "signal_id": r["rolling_signal_id"], "case_id": r["case_id"], "chart_path": r.get("chart_path", ""), "exists": p.exists()})
|
return pd.DataFrame(rows)
|
|
|
def write_summary_and_self_check(order_df: pd.DataFrame, lot_df: pd.DataFrame, case_df: pd.DataFrame, sell_signals: list[dict], rolling_rows: list[dict], link_df: pd.DataFrame, chart_df: pd.DataFrame, manifest_df: pd.DataFrame, sampling_df: pd.DataFrame) -> None:
|
action_counts = Counter([s["human_decision_action"] for s in sell_signals] + [r["human_decision_action"] for r in rolling_rows])
|
signal_type_counts = Counter([s["signal_type"] for s in sell_signals] + [r["signal_type"] for r in rolling_rows])
|
closed_cases = int(case_df["v1_primary_strict_closed_case_flag"].sum()) if not case_df.empty else 0
|
positive_cases = int(case_df[(case_df["v1_primary_strict_closed_case_flag"] == 1) & (pd.to_numeric(case_df["account_return_closed_lots"], errors="coerce") > 0)].shape[0])
|
contribution = float(pd.to_numeric(case_df[case_df["v1_primary_strict_closed_case_flag"] == 1]["account_return_closed_lots"], errors="coerce").fillna(0).sum()) if closed_cases else 0.0
|
summary = {
|
"schema_version": "1.0",
|
"task_id": TASK_ID,
|
"run_id": RUN_ID,
|
"generated_at": now_iso(),
|
"stage": "V1_STRICT_SELL_ROLLING_SELF_CHECK_DONE_EXEC_REVIEW_PENDING",
|
"source_run_id": SOURCE_RUN_ID,
|
"design_audit_id": DESIGN_AUDIT_ID,
|
"supplemental_design_audit_id": SUPP_DESIGN_AUDIT_ID,
|
"issue_id": ISSUE_ID,
|
"return_stat_ready": False,
|
"v1_execution_review_required": True,
|
"scope": {
|
"source_buy_lots": int((lot_df["lot_source_type"] == "SOURCE_BUY").sum()),
|
"rolling_buy_lots": int((lot_df["lot_source_type"] == "ROLLING_LOW_BUY").sum()),
|
"buy_orders_total": int((order_df["action"] == "BUY").sum()),
|
"sell_orders": int((order_df["action"] == "SELL").sum()),
|
"strict_lot_rows": int(len(lot_df)),
|
"case_rows": int(len(case_df)),
|
"v1_primary_strict_closed_cases": closed_cases,
|
"v1_positive_primary_cases": positive_cases,
|
"v1_primary_success_readout": positive_cases / closed_cases if closed_cases else None,
|
"v1_primary_account_contribution_readout": contribution,
|
},
|
"manual_decision_action_counts": dict(action_counts),
|
"signal_type_counts": dict(signal_type_counts),
|
"artifacts": {
|
"strict_sell_signal_ledger": "strict_sell_signal_ledger.csv",
|
"rolling_low_buy_signal_ledger": "rolling_low_buy_signal_ledger.csv",
|
"manual_review_sampling_index": "manual_review_sampling_index.csv",
|
"case_image_board": "case_image_board.md",
|
"manifest": "manifest.json",
|
},
|
"boundaries": [
|
"V1 execution review is pending; do not cite V1 success, return, win rate, drawdown, or strategy validity before review passes.",
|
"Human-decision sampling must be at least 20%, target 30%, and cover major action buckets.",
|
"Minute-level market breadth for market-risk sellpoint is unavailable and retained as a boundary.",
|
],
|
}
|
write_json(ROOT / "summary.json", summary)
|
(ROOT / "summary.md").write_text(
|
"\n".join(
|
[
|
"# V1 精准卖点与滚动渣男返修执行包摘要",
|
"",
|
f"- run_id:`{RUN_ID}`",
|
f"- 阶段:`{summary['stage']}`",
|
f"- 来源包:`{SOURCE_RUN_ID}`",
|
f"- V1 主口径严格闭合 case 候选读数:{closed_cases}",
|
f"- V1 主口径正收益 case 候选读数:{positive_cases}",
|
f"- V1 主口径成功率候选读数:{summary['scope']['v1_primary_success_readout']}",
|
f"- V1 主口径账户贡献候选读数:{contribution:.8f}",
|
"",
|
"边界:执行审核通过前,上述仅为待审候选读数,不得引用为 V1 正式结论。",
|
"",
|
]
|
),
|
encoding="utf-8",
|
)
|
|
checks = []
|
|
def check(name: str, passed: bool, detail: str) -> None:
|
checks.append({"check_id": name, "status": "PASS" if passed else "FAIL", "detail": detail})
|
|
check("CONFIG_FILES_EXIST", (ROOT / "strict_sell_rolling_run_config.json").exists() and (ROOT / "strict_sell_rolling_run_config.md").exists(), "strict sell rolling config frozen")
|
check("SELL_SIGNAL_REQUIRED_FIELDS", all(s.get("human_decision_action") and s.get("human_decision_reason_cn") and s.get("decision_operator") for s in sell_signals), f"sell_signals={len(sell_signals)}")
|
check("ROLLING_SIGNAL_REQUIRED_FIELDS", all(r.get("human_decision_action") and r.get("human_decision_reason_cn") for r in rolling_rows), f"rolling_signals={len(rolling_rows)}")
|
check("SELL_SIGNAL_IDS_UNIQUE", len({s.get("signal_id") for s in sell_signals}) == len(sell_signals), f"sell_signal_ids={len(sell_signals)}")
|
check("ROLLING_SIGNAL_IDS_UNIQUE", len({r.get("rolling_signal_id") for r in rolling_rows}) == len(rolling_rows), f"rolling_signal_ids={len(rolling_rows)}")
|
check("CHART_REASONS_RENDERED", all(s.get("chart_reason_rendered") == "True" for s in sell_signals) and all(r.get("chart_reason_rendered") == "True" for r in rolling_rows), "all signal charts render reason")
|
check("STORYBOARD_REASONS_RENDERED", all(s.get("storyboard_reason_rendered") == "True" for s in sell_signals) and all(r.get("storyboard_reason_rendered") == "True" for r in rolling_rows), "all story boards can show reason")
|
check("NO_V0_V1_MIXED_OUTPUT", all(str(v).startswith("ORD-" + RUN_ID) for v in order_df["order_id"]), "strict orders use V1 run id")
|
check("MARKET_RISK_BOUNDARY_RETAINED", (ROOT / "strict_boundary_table.csv").read_text(encoding="utf-8-sig").find("MARKET_RISK_MINUTE_BREADTH_DATA_GAP_HELD") >= 0, "market risk data gap retained")
|
check("LINKS_REACHABLE", link_df.empty or bool(link_df["exists"].all()), f"links={len(link_df)}, missing={0 if link_df.empty else int((~link_df['exists']).sum())}")
|
check("CHARTS_EXIST", chart_df.empty or bool(chart_df["exists"].all()), f"charts={len(chart_df)}, missing={0 if chart_df.empty else int((~chart_df['exists']).sum())}")
|
check("MANUAL_REVIEW_SAMPLING_RATIO", len(sampling_df) >= math.ceil((len(sell_signals) + len(rolling_rows)) * AUDIT_SAMPLE_MIN_RATIO), f"samples={len(sampling_df)}, total={len(sell_signals)+len(rolling_rows)}")
|
actions = set(sampling_df["human_decision_action"].tolist()) if not sampling_df.empty else set()
|
must = {a for a in ["SELL", "HOLD_WATCH", "HOLD_ABOVE_8", "REVIEW_HELD", "BUY_ROLLING_LOW"] if action_counts.get(a, 0) > 0}
|
check("MANUAL_REVIEW_SAMPLING_COVERS_ACTIONS", must.issubset(actions), f"required={sorted(must)}, sampled={sorted(actions)}")
|
check("MANIFEST_PRELIMINARY_READY", not manifest_df.empty, f"manifest preliminary files={len(manifest_df)}")
|
checks_df = pd.DataFrame(checks)
|
checks_df.to_csv(ROOT / "self_check_items.csv", index=False, encoding="utf-8-sig")
|
self_check = {
|
"schema_version": "1.0",
|
"run_id": RUN_ID,
|
"generated_at": now_iso(),
|
"status": "PASS_FOR_V1_EXECUTION_REVIEW_READY" if (checks_df["status"] == "PASS").all() else "FAIL",
|
"pass_count": int((checks_df["status"] == "PASS").sum()),
|
"fail_count": int((checks_df["status"] == "FAIL").sum()),
|
"return_stat_ready": False,
|
"items_path": "self_check_items.csv",
|
}
|
write_json(ROOT / "self_check.json", self_check)
|
(ROOT / "self_check.md").write_text(
|
f"# 自检\n\n- status:`{self_check['status']}`\n- PASS:{self_check['pass_count']}\n- FAIL:{self_check['fail_count']}\n",
|
encoding="utf-8",
|
)
|
|
|
def main() -> None:
|
ROOT.mkdir(parents=True, exist_ok=True)
|
clean_generated_outputs()
|
(ROOT / "cases").mkdir(exist_ok=True)
|
write_config()
|
write_rule_mapping()
|
order_src, selected, source_lots_df, lots = load_inputs()
|
trade_dates = fetch_trade_calendar()
|
date_symbols, symbols, min_date, max_date = build_fetch_maps(lots, trade_dates)
|
minute, daily = fetch_market_data(date_symbols, symbols, min_date, max_date)
|
minute_lookup = make_lookup(minute)
|
daily_lookup = daily_lookup_map(daily)
|
|
sell_signals: list[dict] = []
|
rolling_rows: list[dict] = []
|
final_sells: dict[str, dict] = {}
|
for lot in lots:
|
sigs, final_sell, rolls = evaluate_lot(lot, trade_dates, minute_lookup, daily_lookup, allow_rolling=True)
|
sell_signals.extend(sigs)
|
rolling_rows.extend(rolls)
|
if final_sell:
|
final_sells[lot.source_lot_id] = final_sell
|
|
assign_sell_signal_ids(sell_signals)
|
assign_rolling_signal_ids(rolling_rows)
|
render_charts(sell_signals, rolling_rows, minute_lookup, daily_lookup)
|
order_df, lot_df, account_df, case_df, new_lots = build_ledgers(lots, sell_signals, final_sells, rolling_rows, trade_dates, minute_lookup, daily_lookup)
|
case_scope, lot_scope, boundary = build_scope_and_boundary(case_df, lot_df, sell_signals, rolling_rows)
|
sampling = build_sampling_index(sell_signals, rolling_rows)
|
|
pd.DataFrame(sell_signals).to_csv(ROOT / "strict_sell_signal_ledger.csv", index=False, encoding="utf-8-sig")
|
pd.DataFrame(rolling_rows).to_csv(ROOT / "rolling_low_buy_signal_ledger.csv", index=False, encoding="utf-8-sig")
|
order_df.to_csv(ROOT / "strict_order_ledger.csv", index=False, encoding="utf-8-sig")
|
lot_df.to_csv(ROOT / "strict_position_lot_ledger.csv", index=False, encoding="utf-8-sig")
|
account_df.to_csv(ROOT / "strict_daily_account_ledger.csv", index=False, encoding="utf-8-sig")
|
case_df.to_csv(ROOT / "strict_case_summary.csv", index=False, encoding="utf-8-sig")
|
case_scope.to_csv(ROOT / "strict_return_scope_case.csv", index=False, encoding="utf-8-sig")
|
lot_scope.to_csv(ROOT / "strict_return_scope_lot.csv", index=False, encoding="utf-8-sig")
|
boundary.to_csv(ROOT / "strict_boundary_table.csv", index=False, encoding="utf-8-sig")
|
sampling.to_csv(ROOT / "manual_review_sampling_index.csv", index=False, encoding="utf-8-sig")
|
|
write_boards(case_df, sell_signals, rolling_rows)
|
chart_df = chart_audit(sell_signals, rolling_rows)
|
chart_df.to_csv(ROOT / "chart_evidence_audit.csv", index=False, encoding="utf-8-sig")
|
link_df = audit_links()
|
link_df.to_csv(ROOT / "link_evidence_audit.csv", index=False, encoding="utf-8-sig")
|
prelim_manifest = manifest()
|
write_summary_and_self_check(order_df, lot_df, case_df, sell_signals, rolling_rows, link_df, chart_df, prelim_manifest, sampling)
|
readme = [
|
"# RUN-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260609-001",
|
"",
|
"本包用于执行 V1 精准卖点与滚动渣男返修。旧 V0 全量包只作为只读来源,本包不覆盖旧包。",
|
"",
|
"阅读入口:",
|
"",
|
"- `case_image_board.md`:人工图片第一入口。",
|
"- `strict_sell_signal_ledger.csv`:精准卖点与 3% / 5% / 8% 趋势候选账本。",
|
"- `rolling_low_buy_signal_ledger.csv`:滚动低吸候选账本。",
|
"- `manual_review_sampling_index.csv`:审核员 20% 起、原则 30% 抽样入口。",
|
"- `summary.md/json`:待审候选读数和边界。",
|
"",
|
"边界:V1 执行审核通过前,不得引用 V1 成功率、收益率、胜率、回撤或策略有效性结论。",
|
"",
|
]
|
(ROOT / "README.md").write_text("\n".join(readme), encoding="utf-8")
|
final_manifest = manifest()
|
final_manifest = final_manifest[~final_manifest["path"].isin(["manifest.csv", "manifest.json"])].reset_index(drop=True)
|
final_manifest.to_csv(ROOT / "manifest.csv", index=False, encoding="utf-8-sig")
|
write_json(ROOT / "manifest.json", {"schema_version": "1.0", "run_id": RUN_ID, "generated_at": now_iso(), "files": final_manifest.to_dict("records")})
|
|
print(json.dumps({"run_id": RUN_ID, "sell_signals": len(sell_signals), "rolling_signals": len(rolling_rows), "orders": len(order_df), "lots": len(lot_df), "cases": len(case_df)}, ensure_ascii=False))
|
|
|
if __name__ == "__main__":
|
main()
|