from __future__ import annotations
|
|
import hashlib
|
import json
|
import math
|
import os
|
import re
|
from collections import Counter
|
from datetime import datetime, timedelta, timezone
|
from pathlib import Path
|
|
import pandas as pd
|
import pymysql
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
RUN_ID = "RUN-ANA-WUJI-V1-STRICT-NOTE-FULL-RERUN-20260614-001"
|
ROOT = Path(__file__).resolve().parents[1]
|
LOCAL_DB_INDEX = Path(r"D:\strategy_project\s-system-doc\observer\天下模型沉淀\数据库索引数据.md")
|
TZ = timezone(timedelta(hours=8))
|
|
OBSERVATION_TRADING_DAYS = 10
|
GAIN_3 = 0.03
|
GAIN_5 = 0.05
|
GAIN_8 = 0.08
|
STOP_LOSS = -0.05
|
|
|
def now_iso() -> str:
|
return datetime.now(TZ).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 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) >= 3:
|
return f"{int(parts[0]):02d}:{int(parts[1]):02d}:{int(float(parts[2])):02d}"
|
if len(parts) == 2:
|
return f"{int(parts[0]):02d}:{int(parts[1]):02d}:00"
|
return text
|
|
|
def read_mysql_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"密码:`([^`]+)`", text)
|
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=240,
|
write_timeout=240,
|
)
|
|
|
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_22 = font(22)
|
FONT_28 = font(28)
|
|
|
def next_trade_date(trade_dates: list[str], trade_date: str) -> str:
|
if trade_date not in trade_dates:
|
return ""
|
idx = trade_dates.index(trade_date)
|
return trade_dates[idx + 1] if idx + 1 < len(trade_dates) else ""
|
|
|
def window_dates(trade_dates: list[str], entry_date: str) -> list[str]:
|
sellable = next_trade_date(trade_dates, entry_date)
|
if not sellable or entry_date not in trade_dates:
|
return []
|
start = trade_dates.index(sellable)
|
end = min(len(trade_dates) - 1, trade_dates.index(entry_date) + OBSERVATION_TRADING_DAYS)
|
return trade_dates[start : end + 1]
|
|
|
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 chunked(values: list, size: int):
|
for i in range(0, len(values), size):
|
yield values[i : i + size]
|
|
|
def fetch_market(
|
lots: pd.DataFrame, trade_dates: list[str]
|
) -> tuple[dict[tuple[str, str], pd.DataFrame], dict[tuple[str, str], dict]]:
|
minute_pairs: set[tuple[str, str]] = set()
|
all_daily_dates: set[str] = set()
|
symbols = sorted(lots["symbol"].unique())
|
for row in lots.itertuples(index=False):
|
dates = window_dates(trade_dates, row.entry_trade_date)
|
for d in dates:
|
minute_pairs.add((row.symbol, d))
|
all_daily_dates.add(d)
|
if row.entry_trade_date in trade_dates:
|
idx = trade_dates.index(row.entry_trade_date)
|
for d in trade_dates[max(0, idx - 5) : min(len(trade_dates), idx + OBSERVATION_TRADING_DAYS + 1)]:
|
all_daily_dates.add(d)
|
if not minute_pairs:
|
return {}, {}
|
|
minute_parts = []
|
pair_list = sorted(minute_pairs, key=lambda x: (x[1], x[0]))
|
with get_conn() as conn:
|
for pairs in chunked(pair_list, 600):
|
placeholders = ",".join(["(%s,%s)"] * len(pairs))
|
params = []
|
for symbol, trade_date in pairs:
|
params.extend([trade_date, symbol])
|
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, symbol) IN ({placeholders})
|
ORDER BY symbol, trade_date, trade_time
|
""",
|
conn,
|
params=params,
|
)
|
)
|
ph = ",".join(["%s"] * len(symbols))
|
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=[*symbols, min(all_daily_dates), max(all_daily_dates)],
|
)
|
|
minute = pd.concat(minute_parts, ignore_index=True) if minute_parts else pd.DataFrame()
|
minute_lookup = {}
|
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")
|
for (symbol, trade_date), group in minute.groupby(["symbol", "trade_date"]):
|
minute_lookup[(symbol, trade_date)] = group.sort_values("trade_time").reset_index(drop=True)
|
|
daily_lookup = {}
|
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())
|
daily_lookup = {(r["symbol"], r["trade_date"]): r.to_dict() for _, r in daily.iterrows()}
|
return minute_lookup, daily_lookup
|
|
|
def safe_float(value, default=math.nan) -> float:
|
try:
|
if pd.isna(value):
|
return default
|
return float(value)
|
except Exception:
|
return default
|
|
|
def minutes_between(start: str, end: str) -> float:
|
a = datetime.strptime(start, "%H:%M:%S")
|
b = datetime.strptime(end, "%H:%M:%S")
|
return (b - a).total_seconds() / 60
|
|
|
def candidate_row(row, d: str, t: str, signal_type: str, action: str, reason: str, price: float, gain: float) -> dict:
|
return {
|
"sell_signal_id": f"SELL-CAND-{row.lot_id}",
|
"lot_id": row.lot_id,
|
"open_order_id": row.open_order_id,
|
"case_id": row.case_id,
|
"candidate_id": row.candidate_id,
|
"external_buy_decision_id": row.external_decision_id,
|
"symbol": row.symbol,
|
"entry_trade_date": row.entry_trade_date,
|
"entry_price": row.entry_price,
|
"position_pct": row.position_pct,
|
"observation_trade_date": d,
|
"candidate_time": t,
|
"signal_type": signal_type,
|
"code_suggested_action": action,
|
"code_suggested_reason_cn": reason,
|
"action_price": "" if math.isnan(price) else f"{price:.4f}",
|
"gain_pct": "" if math.isnan(gain) else f"{gain:.8f}",
|
"review_input_chart_path": "",
|
"review_input_chart_sha256": "",
|
}
|
|
|
def make_candidate(row, trade_dates: list[str], minute_lookup: dict[tuple[str, str], pd.DataFrame]) -> dict:
|
entry = safe_float(row.entry_price)
|
first3: tuple[str, str] | None = None
|
fast_watch = False
|
above8 = False
|
dates = window_dates(trade_dates, row.entry_trade_date)
|
if not dates:
|
return candidate_row(row, "", "", "SELL_REVIEW_DATA_GAP_HELD", "REVIEW_HELD", "交易日观察窗口缺失,保留待人工复核。", entry, 0.0)
|
last_seen = None
|
for d in dates:
|
day = minute_lookup.get((row.symbol, d), pd.DataFrame())
|
if day.empty:
|
return candidate_row(row, d, "", "SELL_REVIEW_DATA_GAP_HELD", "REVIEW_HELD", f"{d} 分钟线缺失,保留待人工复核。", entry, 0.0)
|
for r in day.itertuples(index=False):
|
price = safe_float(r.close_price)
|
high_gain = safe_float(r.high_price) / entry - 1
|
low_gain = safe_float(r.low_price) / entry - 1
|
close_gain = price / entry - 1
|
last_seen = (d, r.trade_time, price, close_gain)
|
if low_gain <= STOP_LOSS:
|
return candidate_row(row, d, r.trade_time, "SELL_STOP_LOSS_5", "SELL", "总价跌破 -5% 止损线,代码建议卖出;最终动作需要外部人工确认。", price, close_gain)
|
if first3 is None and high_gain >= GAIN_3:
|
first3 = (d, r.trade_time)
|
continue
|
if first3 is None:
|
continue
|
fast = d == first3[0] and minutes_between(first3[1], r.trade_time) <= 10
|
if not fast_watch and high_gain >= GAIN_5 and fast:
|
fast_watch = True
|
continue
|
if not fast_watch and d == first3[0] and minutes_between(first3[1], r.trade_time) > 10 and GAIN_3 <= close_gain < GAIN_5:
|
return candidate_row(row, d, r.trade_time, "SELL_TREND_3_TO_5_GRADUAL", "SELL", "上涨进入 3%-5% 区间但不是快速冲过 5%,代码建议趋势止盈卖出;最终动作需要外部人工确认。", price, close_gain)
|
if fast_watch and not above8 and high_gain >= GAIN_8:
|
above8 = True
|
continue
|
if fast_watch and not above8 and low_gain < GAIN_5 and close_gain >= GAIN_3:
|
return candidate_row(row, d, r.trade_time, "SELL_TREND_PULLBACK_3_TO_5", "SELL", "快速冲过 5% 后回落到 3%-5% 区间,代码建议卖出;最终动作需要外部人工确认。", price, close_gain)
|
if above8 and last_seen:
|
d, t, price, gain = last_seen
|
return candidate_row(row, d, t, "TREND_ABOVE_8_HOLD", "HOLD_ABOVE_8", "快速冲过 5% 后曾超过 8%,代码建议强势持有观察;不卖理由需要图上展示并由外部人工确认。", price, gain)
|
if fast_watch and last_seen:
|
d, t, price, gain = last_seen
|
return candidate_row(row, d, t, "TREND_FAST_BREAKOUT_5_WATCH", "HOLD_WATCH", "快速冲过 5%,代码建议观察不卖;不卖理由需要图上展示并由外部人工确认。", price, gain)
|
if last_seen:
|
d, t, price, gain = last_seen
|
return candidate_row(row, d, t, "SELL_WINDOW_END_REVIEW_HELD", "REVIEW_HELD", "观察窗口结束仍未出现明确卖点,保留待人工复核。", price, gain)
|
return candidate_row(row, "", "", "SELL_REVIEW_DATA_GAP_HELD", "REVIEW_HELD", "无有效分钟观察数据,保留待人工复核。", entry, 0.0)
|
|
|
def wrap(text: str, n: int) -> list[str]:
|
return [text[i : i + n] for i in range(0, len(text), n)] or [""]
|
|
|
def draw_chart(signal: dict, minute_lookup: dict[tuple[str, str], pd.DataFrame], daily_lookup: dict[tuple[str, str], dict]) -> None:
|
d = signal["observation_trade_date"]
|
day = minute_lookup.get((signal["symbol"], d), pd.DataFrame())
|
out = ROOT / "charts" / "sell_rolling_review" / signal["case_id"] / f"{signal['sell_signal_id']}.png"
|
out.parent.mkdir(parents=True, exist_ok=True)
|
img = Image.new("RGB", (1500, 900), "#fbfaf6")
|
draw = ImageDraw.Draw(img)
|
draw.text((40, 24), f"严格版卖点/趋势候选:{signal['case_id']} {signal['symbol']} {d}", fill="#111111", font=FONT_28)
|
left, top, right, bottom = 70, 110, 1020, 620
|
draw.rectangle((left, top, right, bottom), outline="#cccccc")
|
|
prices = [safe_float(v) for v in day["close_price"].tolist()] if not day.empty else []
|
entry = safe_float(signal["entry_price"])
|
ma5 = safe_float(daily_lookup.get((signal["symbol"], d), {}).get("ma5_close"))
|
levels = [entry, entry * 1.03, entry * 1.05, entry * 1.08]
|
if not math.isnan(ma5):
|
levels.append(ma5)
|
all_prices = prices + levels
|
lo, hi = min(all_prices), max(all_prices)
|
pad = max((hi - lo) * 0.08, 0.01)
|
lo -= pad
|
hi += pad
|
|
def x_at(i: int) -> float:
|
if not prices or len(prices) == 1:
|
return left
|
return left + i * (right - left) / (len(prices) - 1)
|
|
def y_at(p: float) -> float:
|
return bottom - (p - lo) * (bottom - top) / (hi - lo)
|
|
colors = [
|
(entry, "#111111", "买入价"),
|
(entry * 1.03, "#2ca02c", "3%"),
|
(entry * 1.05, "#ff7f0e", "5%"),
|
(entry * 1.08, "#9467bd", "8%"),
|
]
|
if not math.isnan(ma5):
|
colors.append((ma5, "#8c564b", "日线MA5"))
|
for price, color, label in colors:
|
y = y_at(price)
|
draw.line((left, y, right, y), fill=color, width=2)
|
draw.text((right + 8, y - 10), f"{label} {price:.2f}", fill=color, font=FONT_18)
|
|
if prices:
|
pts = [(x_at(i), y_at(p)) for i, p in enumerate(prices)]
|
draw.line(pts, fill="#1f77b4", width=2)
|
if signal["candidate_time"] in set(day["trade_time"].tolist()):
|
idx = day.index[day["trade_time"].eq(signal["candidate_time"])][0]
|
x, y = x_at(int(idx)), y_at(safe_float(day.loc[idx, "close_price"]))
|
draw.ellipse((x - 6, y - 6, x + 6, y + 6), fill="#d62728")
|
draw.line((x, top, x, bottom), fill="#d62728", width=2)
|
|
side_x = 1060
|
draw.text((side_x, 110), "外部人工裁决待填", fill="#111111", font=FONT_22)
|
info = [
|
f"代码建议:{signal['code_suggested_action']}",
|
f"信号:{signal['signal_type']}",
|
f"时间:{signal['observation_trade_date']} {signal['candidate_time']}",
|
f"收益候选:{signal['gain_pct']}",
|
"理由:",
|
]
|
y = 150
|
for line in info:
|
draw.text((side_x, y), line, fill="#111111", font=FONT_18)
|
y += 30
|
for part in wrap(signal["code_suggested_reason_cn"], 18):
|
draw.text((side_x, y), part, fill="#111111", font=FONT_18)
|
y += 28
|
draw.text((40, 825), "audit_view:本图只用于外部人工/AI人工复核,不是最终卖点裁决;不卖也必须在后续图上写清楚理由。", fill="#666666", font=FONT_18)
|
img.save(out)
|
rel = out.relative_to(ROOT).as_posix()
|
signal["review_input_chart_path"] = rel
|
signal["review_input_chart_sha256"] = sha256_file(out)
|
|
|
def build_manifest() -> pd.DataFrame:
|
rows = []
|
for path in sorted(ROOT.rglob("*")):
|
if path.is_file() and path.name not in {"manifest.csv", "manifest.json"}:
|
rows.append({"path": path.relative_to(ROOT).as_posix(), "size": path.stat().st_size, "sha256": sha256_file(path)})
|
return pd.DataFrame(rows)
|
|
|
def main() -> None:
|
lots = pd.read_csv(ROOT / "strict_note_buy_lot_ledger.csv", encoding="utf-8-sig")
|
orders = pd.read_csv(ROOT / "strict_note_buy_order_ledger.csv", encoding="utf-8-sig")
|
lots["entry_trade_date"] = lots["entry_trade_date"].map(normalize_date)
|
lots = lots.merge(orders[["order_id", "evidence_image_path", "decision_reason_cn"]], left_on="open_order_id", right_on="order_id", how="left")
|
if len(lots) != 424:
|
raise RuntimeError(f"expected 424 strict BUY lots, got {len(lots)}")
|
|
trade_dates = fetch_trade_calendar()
|
minute_lookup, daily_lookup = fetch_market(lots, trade_dates)
|
signals = []
|
for row in lots.itertuples(index=False):
|
sig = make_candidate(row, trade_dates, minute_lookup)
|
draw_chart(sig, minute_lookup, daily_lookup)
|
signals.append(sig)
|
signal_df = pd.DataFrame(signals)
|
signal_df.to_csv(ROOT / "strict_note_sell_rolling_review_candidate_ledger.csv", index=False, encoding="utf-8-sig")
|
|
template = signal_df.copy()
|
template["external_decision_id"] = [f"EXT-SELL-STRICT-NOTE-{i:06d}" for i in range(1, len(template) + 1)]
|
for col in [
|
"human_decision_action",
|
"human_decision_reason_cn",
|
"decision_operator",
|
"decision_time",
|
"decision_source",
|
"accept_code_suggestion_flag",
|
"reviewer_notes",
|
]:
|
template[col] = ""
|
template.to_csv(ROOT / "manual_sell_rolling_decision_external_template.csv", index=False, encoding="utf-8-sig")
|
|
chart_df = signal_df[["sell_signal_id", "case_id", "symbol", "review_input_chart_path", "review_input_chart_sha256"]].copy()
|
chart_df["exists"] = chart_df["review_input_chart_path"].map(lambda p: (ROOT / p).exists())
|
chart_df.to_csv(ROOT / "sell_rolling_chart_evidence_audit.csv", index=False, encoding="utf-8-sig")
|
|
counts = Counter(signal_df["code_suggested_action"])
|
generated_at = now_iso()
|
checks = [
|
("STRICT_BUY_LOT_SCOPE_IS_424", len(lots) == 424, f"lots={len(lots)}"),
|
("CANDIDATE_PER_BUY_LOT", len(signal_df) == len(lots), f"signals={len(signal_df)}"),
|
("TEMPLATE_FIELDS_BLANK", template["human_decision_action"].astype(str).str.strip().eq("").all(), "human fields blank"),
|
("CHARTS_EXIST", bool(chart_df["exists"].all()), f"charts={len(chart_df)}, missing={int((~chart_df['exists']).sum())}"),
|
("NO_STRICT_PERFORMANCE_READOUT", True, "prep package only; no return/success/win-rate generated"),
|
]
|
self_items = pd.DataFrame([{"item": k, "status": "PASS" if ok else "FAIL", "detail": detail} for k, ok, detail in checks])
|
self_items.to_csv(ROOT / "sell_rolling_review_prep_self_check_items.csv", index=False, encoding="utf-8-sig")
|
status = "PASS_FOR_SELL_ROLLING_MANUAL_REVIEW_PREP_READY" if self_items["status"].eq("PASS").all() else "FAIL"
|
(ROOT / "sell_rolling_review_prep_self_check.json").write_text(
|
json.dumps(
|
{
|
"run_id": RUN_ID,
|
"generated_at": generated_at,
|
"stage": status,
|
"pass_count": int(self_items["status"].eq("PASS").sum()),
|
"fail_count": int(self_items["status"].eq("FAIL").sum()),
|
},
|
ensure_ascii=False,
|
indent=2,
|
),
|
encoding="utf-8",
|
)
|
summary = {
|
"run_id": RUN_ID,
|
"generated_at": generated_at,
|
"stage": status,
|
"strict_buy_lots_input": int(len(lots)),
|
"sell_rolling_review_candidates": int(len(signal_df)),
|
"code_suggested_action_counts": dict(counts),
|
"boundary": "Prep only: final sell/hold/rolling actions require external manual decision source and execution review.",
|
}
|
(ROOT / "sell_rolling_review_prep_summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
(ROOT / "sell_rolling_review_prep_summary.md").write_text(
|
"# Strict Note Sell/Rolling Manual Review Prep\n\n"
|
f"- generated_at: {generated_at}\n"
|
f"- strict BUY lots input: {len(lots)}\n"
|
f"- review candidates: {len(signal_df)}\n"
|
f"- code suggested actions: {dict(counts)}\n\n"
|
"Boundary: this is a manual-review prep package only. It does not generate final SELL orders, rolling BUY orders, return, success rate, win rate, drawdown, or strategy-effectiveness conclusions.\n",
|
encoding="utf-8",
|
)
|
manifest = build_manifest()
|
manifest.to_csv(ROOT / "manifest.csv", index=False, encoding="utf-8-sig")
|
(ROOT / "manifest.json").write_text(
|
json.dumps({"run_id": RUN_ID, "generated_at": generated_at, "file_count": int(len(manifest)), "files": manifest.to_dict("records")}, ensure_ascii=False, indent=2),
|
encoding="utf-8",
|
)
|
print(json.dumps(summary, ensure_ascii=False))
|
|
|
if __name__ == "__main__":
|
main()
|