1
2026-06-12 af818691bde871a81070a2b810706e18bf4a50aa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
from __future__ import annotations
 
import csv
import hashlib
import json
import math
import os
import re
import shutil
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-STOCK-LIFECYCLE-PACKAGE-20260611-001"
TASK_ID = "ANA-WUJI-V1-STOCK-LIFECYCLE-PACKAGE-20260611"
SOURCE_RUN_ID = "RUN-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260609-001"
FINAL_RUN_ID = "RUN-ANA-WUJI-V1-FINAL-CONCLUSION-20260610-001"
DESIGN_AUDIT_ID = "AUDIT-ANA-WUJI-V1-STOCK-LIFECYCLE-PACKAGE-20260611-DESIGN-001"
SOURCE_EXEC_AUDIT_ID = "AUDIT-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260610-EXEC-REREVIEW-003"
 
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
RESULT_ROOT = PACKAGE_ROOT.parents[0]
PROJECT_ROOT = PACKAGE_ROOT.parents[2]
SOURCE_ROOT = RESULT_ROOT / SOURCE_RUN_ID
FINAL_ROOT = RESULT_ROOT / FINAL_RUN_ID
LOCAL_DB_INDEX = Path(r"D:\strategy_project\s-system-doc\observer\天下模型沉淀\数据库索引数据.md")
 
MOJIBAKE_MARKERS = ["????", "???", "�", "ÀíÓÉ", "å¤", "æ", "Ã", "Â", "涓", "鏃", "鐐", "偂"]
 
 
def now_iso() -> str:
    return datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds")
 
 
GENERATED_AT = now_iso()
 
 
def read_password() -> str:
    env = os.environ.get("TIANXIA_MYSQL_PASSWORD") or os.environ.get("MYSQL_PWD")
    if env:
        return env
    candidates = [LOCAL_DB_INDEX]
    observer_root = Path(r"D:\strategy_project\s-system-doc\observer")
    if observer_root.exists():
        candidates.extend(observer_root.glob("*/数据库索引数据.md"))
    for path in candidates:
        if not path.exists():
            continue
        text = path.read_text(encoding="utf-8")
        match = re.search(r"^\s*-\s*密码:`([^`]+)`", text, re.MULTILINE)
        if match:
            return match.group(1)
    raise RuntimeError("Unable to read local MySQL credential from approved local index.")
 
 
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=180,
    )
 
 
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 rel(path: Path) -> str:
    return path.resolve().relative_to(PACKAGE_ROOT.resolve()).as_posix()
 
 
def source_rel(path: Path) -> str:
    try:
        return path.resolve().relative_to(PROJECT_ROOT.resolve()).as_posix()
    except ValueError:
        return str(path)
 
 
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(28)
FONT_SUB = font(21)
FONT_MID = font(17)
FONT_SMALL = font(14)
FONT_TINY = font(12)
 
 
def write_csv(path: Path, rows: list[dict], fields: list[str]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8-sig", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(rows)
 
 
def read_csv(path: Path) -> pd.DataFrame:
    return pd.read_csv(path, encoding="utf-8-sig")
 
 
def norm_time(value) -> str:
    if pd.isna(value):
        return ""
    if hasattr(value, "total_seconds"):
        seconds = int(value.total_seconds())
        h, rem = divmod(seconds, 3600)
        m, s = divmod(rem, 60)
        return f"{h:02d}:{m:02d}:{s:02d}"
    text = str(value).strip()
    if "days" in text and " " in text:
        text = text.split()[-1]
    if re.fullmatch(r"\d{2}:\d{2}$", text):
        return f"{text}:00"
    if re.fullmatch(r"\d{2}:\d{2}:\d{2}(\.\d+)?", text):
        return text[:8]
    return text
 
 
def y_price(value: float, lo: float, hi: float, top: int, bottom: int) -> int:
    if hi <= lo:
        return (top + bottom) // 2
    return int(bottom - (value - lo) / (hi - lo) * (bottom - top))
 
 
def wrap_text(text: str, width: int) -> list[str]:
    text = str(text or "")
    lines: list[str] = []
    for raw in text.splitlines() or [""]:
        line = ""
        units = list(raw)
        for ch in units:
            add = 2 if ord(ch) > 127 else 1
            cur = sum(2 if ord(c) > 127 else 1 for c in line)
            if cur + add > width and line:
                lines.append(line)
                line = ch
            else:
                line += ch
        lines.append(line)
    return lines
 
 
def draw_text_box(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], title: str, lines: list[str]) -> None:
    x1, y1, x2, y2 = box
    draw.rectangle([x1, y1, x2, y2], fill="#ffffff", outline="#cbd5e1")
    draw.text((x1 + 16, y1 + 14), title, fill="#111827", font=FONT_SUB)
    y = y1 + 50
    for line in lines:
        if line == "":
            y += 12
            continue
        for wrapped in wrap_text(line, 30):
            if y > y2 - 24:
                return
            draw.text((x1 + 16, y), wrapped, fill="#334155", font=FONT_SMALL)
            y += 22
 
 
def safe_symbol(symbol: str) -> str:
    return str(symbol).replace(".", "_").replace("/", "_")
 
 
def load_sources() -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
    v1_index = read_csv(FINAL_ROOT / "v1_case_readout_index.csv")
    primary = v1_index[(v1_index["v1_return_scope"] == "V1_PRIMARY_STRICT_CLOSED_CASE") & (pd.to_numeric(v1_index["primary_flag"], errors="coerce") == 1)].copy()
    orders = read_csv(SOURCE_ROOT / "strict_order_ledger.csv")
    lots = read_csv(SOURCE_ROOT / "strict_position_lot_ledger.csv")
    case_summary = read_csv(SOURCE_ROOT / "strict_case_summary.csv")
    for df in [orders, lots]:
        for col in ["trade_time", "entry_time", "exit_time"]:
            if col in df.columns:
                df[col] = df[col].map(norm_time)
    return primary, orders, lots, case_summary
 
 
def fetch_trade_calendar() -> list[str]:
    with get_conn() as conn:
        df = pd.read_sql(
            "SELECT DISTINCT trade_date FROM a_share_daily_price ORDER BY trade_date",
            conn,
        )
    return pd.to_datetime(df["trade_date"]).dt.strftime("%Y-%m-%d").tolist()
 
 
def fetch_daily(symbols: list[str], min_date: str, max_date: str) -> pd.DataFrame:
    parts: list[pd.DataFrame] = []
    with get_conn() as conn:
        for i in range(0, len(symbols), 180):
            chunk = symbols[i : i + 180]
            ph = ",".join(["%s"] * len(chunk))
            parts.append(
                pd.read_sql(
                    f"""
                    SELECT trade_date, symbol, open_price, high_price, low_price, close_price, volume, amount
                    FROM a_share_daily_price
                    WHERE trade_date BETWEEN %s AND %s
                      AND symbol IN ({ph})
                    ORDER BY symbol, trade_date
                    """,
                    conn,
                    params=[min_date, max_date, *chunk],
                )
            )
    daily = pd.concat(parts, ignore_index=True) if parts else pd.DataFrame()
    if daily.empty:
        return daily
    daily["trade_date"] = pd.to_datetime(daily["trade_date"]).dt.strftime("%Y-%m-%d")
    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"] = daily.groupby("symbol")["close_price"].transform(lambda s: s.rolling(5, min_periods=1).mean())
    return daily
 
 
def fetch_minute(date_symbols: list[tuple[str, str]]) -> pd.DataFrame:
    by_date: dict[str, set[str]] = {}
    for date, symbol in date_symbols:
        by_date.setdefault(date, set()).add(symbol)
    parts: list[pd.DataFrame] = []
    with get_conn() as conn:
        for idx, date in enumerate(sorted(by_date), start=1):
            symbols = sorted(by_date[date])
            ph = ",".join(["%s"] * len(symbols))
            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=[date, *symbols],
                )
            )
            if idx % 100 == 0:
                print(f"minute query progress: {idx}/{len(by_date)}", flush=True)
    minute = pd.concat(parts, ignore_index=True) if parts else pd.DataFrame()
    if minute.empty:
        return minute
    minute["trade_date"] = pd.to_datetime(minute["trade_date"]).dt.strftime("%Y-%m-%d")
    minute["trade_time"] = minute["trade_time"].map(norm_time)
    for col in ["open_price", "high_price", "low_price", "close_price", "volume", "amount"]:
        minute[col] = pd.to_numeric(minute[col], errors="coerce")
    return minute
 
 
def build_lifecycles(primary: pd.DataFrame, orders: pd.DataFrame, lots: pd.DataFrame) -> tuple[list[dict], pd.DataFrame, pd.DataFrame]:
    primary_ids = set(primary["case_id"].astype(str))
    order_scope = orders[orders["case_id"].astype(str).isin(primary_ids)].copy()
    lot_scope = lots[lots["case_id"].astype(str).isin(primary_ids)].copy()
    rows: list[dict] = []
    seq_by_case_symbol: dict[tuple[str, str], int] = {}
    for (case_id, symbol), group in order_scope.groupby(["case_id", "symbol"], sort=True):
        buys = group[group["action"] == "BUY"].copy()
        sells = group[group["action"] == "SELL"].copy()
        if buys.empty or sells.empty:
            continue
        buys = buys.sort_values(["trade_date", "trade_time", "order_id"])
        sells = sells.sort_values(["trade_date", "trade_time", "order_id"])
        key = (str(case_id), str(symbol))
        seq_by_case_symbol[key] = seq_by_case_symbol.get(key, 0) + 1
        sequence = seq_by_case_symbol[key]
        lifecycle_id = f"LC-{case_id}-{safe_symbol(symbol)}-{sequence:02d}"
        lot_rows = lot_scope[(lot_scope["case_id"].astype(str) == str(case_id)) & (lot_scope["symbol"].astype(str) == str(symbol))]
        first_buy = buys.iloc[0]
        last_sell = sells.iloc[-1]
        manual_ids = sorted(set([str(x) for x in group.get("manual_decision_id", pd.Series(dtype=str)).dropna() if str(x).strip()]))
        rows.append(
            {
                "lifecycle_id": lifecycle_id,
                "case_id": case_id,
                "symbol": symbol,
                "sequence": sequence,
                "first_buy_trade_date": str(first_buy["trade_date"]),
                "first_buy_time": str(first_buy["trade_time"]),
                "first_buy_price": float(first_buy["price"]),
                "last_sell_trade_date": str(last_sell["trade_date"]),
                "last_sell_time": str(last_sell["trade_time"]),
                "last_sell_price": float(last_sell["price"]),
                "buy_order_count": int(len(buys)),
                "sell_order_count": int(len(sells)),
                "lot_count": int(len(lot_rows)),
                "closed_lot_count": int((lot_rows["lot_status"].astype(str).str.contains("CLOSED", na=False)).sum()),
                "buy_order_ids": ";".join(buys["order_id"].astype(str).tolist()),
                "sell_order_ids": ";".join(sells["order_id"].astype(str).tolist()),
                "lot_ids": ";".join(lot_rows["strict_lot_id"].astype(str).tolist()),
                "manual_decision_ids": ";".join(manual_ids),
                "case_image_board": f"../{SOURCE_RUN_ID}/cases/{case_id}/case_image_board.md",
                "source_order_ledger": f"../{SOURCE_RUN_ID}/strict_order_ledger.csv",
                "source_lot_ledger": f"../{SOURCE_RUN_ID}/strict_position_lot_ledger.csv",
            }
        )
    return rows, order_scope, lot_scope
 
 
def trade_dates_between(calendar: list[str], start: str, end: str) -> list[str]:
    return [d for d in calendar if start <= d <= end]
 
 
def daily_window(calendar: list[str], symbol_daily: pd.DataFrame, start_date: str, end_date: str) -> tuple[pd.DataFrame, dict]:
    meta = {
        "pre_window_insufficient": False,
        "post_window_insufficient": False,
        "expected_start_date": "",
        "expected_end_date": "",
    }
    if start_date not in calendar or end_date not in calendar:
        return pd.DataFrame(), meta
    start_pos = calendar.index(start_date)
    end_pos = calendar.index(end_date)
    expected_start_pos = start_pos - 50
    expected_end_pos = end_pos + 10
    meta["pre_window_insufficient"] = expected_start_pos < 0
    meta["post_window_insufficient"] = expected_end_pos >= len(calendar)
    start = calendar[max(0, expected_start_pos)]
    end = calendar[min(len(calendar) - 1, expected_end_pos)]
    meta["expected_start_date"] = start
    meta["expected_end_date"] = end
    window = symbol_daily[(symbol_daily["trade_date"] >= start) & (symbol_daily["trade_date"] <= end)].copy()
    return window.reset_index(drop=True), meta
 
 
def draw_lifecycle_daily(window: pd.DataFrame, lifecycle: dict, orders: pd.DataFrame, out_path: Path) -> None:
    w, h = 1880, 1060
    img = Image.new("RGB", (w, h), "#fbfbf7")
    d = ImageDraw.Draw(img)
    d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1")
    title = f"股票生命周期日线图:{lifecycle['symbol']}  {lifecycle['case_id']}"
    subtitle = "窗口:首买前50个交易日 -> 末卖后10个交易日;同一 case 内同一股票生命周期,不跨 case 合并。"
    d.text((32, 22), title, fill="#111827", font=FONT_TITLE)
    d.text((32, 62), subtitle, fill="#7f1d1d", font=FONT_MID)
    plot_left, plot_top, plot_right, plot_bottom = 80, 120, 1340, 700
    vol_top, vol_bottom = 770, 910
    note_left, note_top = 1370, 120
    d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8")
    d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8")
    if window.empty:
        d.text((plot_left + 180, plot_top + 220), "日线数据缺失", fill="#b91c1c", font=FONT_TITLE)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        img.save(out_path)
        return
    win = window.copy().reset_index(drop=True)
    price_low = float(win["low_price"].min()) * 0.98
    price_high = float(win["high_price"].max()) * 1.02
    max_vol = max(float(win["volume"].max()), 1.0)
    n = len(win)
    gap = (plot_right - plot_left) / max(n, 1)
    body_w = max(3, int(gap * 0.55))
    date_to_x: dict[str, int] = {}
    for i in range(5):
        price = price_low + (price_high - price_low) * i / 4
        y = y_price(price, price_low, price_high, plot_top, plot_bottom)
        d.line([plot_left, y, plot_right, y], fill="#e2e8f0")
        d.text((18, y - 8), f"{price:.2f}", fill="#64748b", font=FONT_SMALL)
    ma5_pts = []
    for i, row in win.iterrows():
        cx = int(plot_left + gap * i + gap / 2)
        date_to_x[str(row["trade_date"])] = cx
        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 pd.notna(row["ma5"]):
            ma5_pts.append((cx, y_price(float(row["ma5"]), price_low, price_high, plot_top, plot_bottom)))
        if i % max(1, n // 10) == 0:
            d.text((cx - 24, vol_bottom + 8), str(row["trade_date"])[5:], fill="#64748b", font=FONT_SMALL)
    if len(ma5_pts) > 1:
        d.line(ma5_pts, fill="#2563eb", width=2)
        d.text((plot_left + 12, plot_bottom + 12), "MA5", fill="#2563eb", font=FONT_SMALL)
    for _, order in orders.iterrows():
        date = str(order["trade_date"])
        if date not in date_to_x:
            continue
        price = float(order["price"])
        cx = date_to_x[date]
        cy = y_price(price, price_low, price_high, plot_top, plot_bottom)
        action = str(order["action"])
        color = "#b91c1c" if action == "BUY" else "#7c3aed"
        label = "买" if action == "BUY" else "卖"
        d.line([cx, plot_top, cx, vol_bottom], fill=color, width=2)
        d.line([plot_left, cy, plot_right, cy], fill=color, width=1)
        d.ellipse([cx - 8, cy - 8, cx + 8, cy + 8], fill=color)
        d.text((cx + 6, max(plot_top + 6, cy - 28)), f"{label}{str(order['trade_time'])[:5]} {price:.2f}", fill=color, font=FONT_SMALL)
    lines = [
        f"生命周期:{lifecycle['lifecycle_id']}",
        f"首买:{lifecycle['first_buy_trade_date']} {lifecycle['first_buy_time']} @ {float(lifecycle['first_buy_price']):.2f}",
        f"末卖:{lifecycle['last_sell_trade_date']} {lifecycle['last_sell_time']} @ {float(lifecycle['last_sell_price']):.2f}",
        f"BUY/SELL:{lifecycle['buy_order_count']} / {lifecycle['sell_order_count']}",
        f"lot:{lifecycle['lot_count']},闭合:{lifecycle['closed_lot_count']}",
        "",
        "读图方式:先看日线整体位置,再沿竖线查看每次买卖点;买卖理由见每日分时图和 story board。",
        "本图只增强同事阅读,不改变 V1 已审核账本和收益读数。",
    ]
    draw_text_box(d, (note_left, note_top, 1830, 910), "图上说明", lines)
    d.text((32, 1000), f"来源审计:{SOURCE_EXEC_AUDIT_ID};生命周期包设计审计:{DESIGN_AUDIT_ID}", fill="#334155", font=FONT_MID)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    img.save(out_path)
 
 
def draw_intraday_chart(day: pd.DataFrame, lifecycle: dict, day_orders: pd.DataFrame, ma5_price: float | None, out_path: Path) -> None:
    w, h = 1700, 940
    img = Image.new("RGB", (w, h), "#fbfbf7")
    d = ImageDraw.Draw(img)
    d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1")
    trade_date = out_path.stem[-8:]
    d.text((32, 24), f"生命周期分时图:{lifecycle['symbol']}  {trade_date}  {lifecycle['case_id']}", fill="#111827", font=FONT_TITLE)
    d.text((32, 62), "模式:3% / 5% / 8% 阈值线、买卖点、买卖价线、MA5、操作理由;无操作日也保留持有观察理由。", fill="#7f1d1d", font=FONT_MID)
    plot_left, plot_top, plot_right, plot_bottom = 80, 120, 1160, 640
    vol_top, vol_bottom = 705, 840
    note_left, note_top = 1195, 120
    d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8")
    d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8")
    if day.empty:
        d.text((plot_left + 180, plot_top + 200), "分钟线数据缺失", fill="#b91c1c", font=FONT_TITLE)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        img.save(out_path)
        return
    day = day.sort_values("trade_time").reset_index(drop=True)
    base_price = float(lifecycle["first_buy_price"])
    ref_prices = [base_price, base_price * 1.03, base_price * 1.05, base_price * 1.08]
    order_prices = [float(x) for x in day_orders["price"].tolist()] if not day_orders.empty else []
    raw_low = min([float(day["low_price"].min()), *ref_prices, *order_prices])
    raw_high = max([float(day["high_price"].max()), *ref_prices, *order_prices])
    if ma5_price and not math.isnan(ma5_price):
        raw_low = min(raw_low, ma5_price)
        raw_high = max(raw_high, ma5_price)
    price_low, price_high = raw_low * 0.997, raw_high * 1.003
    max_vol = max(float(day["volume"].max()), 1.0)
    n = len(day)
    gap = (plot_right - plot_left) / max(n - 1, 1)
    points = []
    for i, row in day.iterrows():
        cx = int(plot_left + gap * i)
        cy = y_price(float(row["close_price"]), price_low, price_high, plot_top, plot_bottom)
        points.append((cx, cy))
        vh = int(float(row["volume"]) / max_vol * (vol_bottom - vol_top))
        d.line([cx, vol_bottom, cx, vol_bottom - vh], fill="#cbd5e1", width=1)
        if i % max(1, n // 8) == 0:
            d.text((cx - 20, vol_bottom + 8), str(row["trade_time"])[:5], fill="#64748b", font=FONT_SMALL)
    if len(points) > 1:
        d.line(points, fill="#0f766e", width=3)
    threshold_styles = [(base_price * 1.03, "3%", "#f59e0b"), (base_price * 1.05, "5%", "#dc2626"), (base_price * 1.08, "8%", "#7c3aed")]
    for price, label, color in threshold_styles:
        y = y_price(price, price_low, price_high, plot_top, plot_bottom)
        d.line([plot_left, y, plot_right, y], fill=color, width=2)
        d.text((plot_right + 8, y - 10), f"{label} {price:.2f}", fill=color, font=FONT_SMALL)
    if ma5_price and not math.isnan(ma5_price):
        y = y_price(ma5_price, price_low, price_high, plot_top, plot_bottom)
        d.line([plot_left, y, plot_right, y], fill="#2563eb", width=2)
        d.text((plot_right + 8, y - 10), f"MA5 {ma5_price:.2f}", fill="#2563eb", font=FONT_SMALL)
    for _, order in day_orders.iterrows():
        action = str(order["action"])
        color = "#b91c1c" if action == "BUY" else "#7c3aed"
        label = "买点" if action == "BUY" else "卖点"
        time = str(order["trade_time"])
        hits = day.index[day["trade_time"] == time].tolist()
        idx = hits[0] if hits else max([i for i, t in enumerate(day["trade_time"].tolist()) if str(t) <= time] or [0])
        x = int(plot_left + gap * idx)
        price = float(order["price"])
        y = y_price(price, price_low, price_high, plot_top, plot_bottom)
        d.line([x, plot_top, x, vol_bottom], fill=color, width=3)
        d.line([plot_left, y, plot_right, y], fill=color, width=1)
        d.ellipse([x - 8, y - 8, x + 8, y + 8], fill=color)
        d.text((min(x + 8, plot_right - 135), max(plot_top + 8, y - 30)), f"{label} {time[:5]} {price:.2f}", fill=color, font=FONT_SMALL)
    if day_orders.empty:
        reason = "当日处于生命周期区间内,无买卖订单;持有观察,未触发 V1 精准卖点或滚动低吸。"
    else:
        fragments = []
        for _, order in day_orders.iterrows():
            reason = str(order.get("decision_reason_cn", "")).strip()
            fragments.append(f"{order['action']} {str(order['trade_time'])[:5]}:{reason[:46]}")
        reason = ";".join(fragments)
    lines = [
        f"生命周期:{lifecycle['lifecycle_id']}",
        f"参考首买价:{base_price:.2f}",
        f"当日订单数:{len(day_orders)}",
        f"操作理由:{reason}",
        "",
        "边界:本图用于人工阅读和复核,不新增买卖裁决,不改变 V1 已审核账本。",
    ]
    draw_text_box(d, (note_left, note_top, 1660, 840), "当日说明", lines)
    d.text((32, 892), f"来源:{SOURCE_RUN_ID};审计:{SOURCE_EXEC_AUDIT_ID}", fill="#334155", font=FONT_MID)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    img.save(out_path)
 
 
def audit_markdown_links(md_paths: list[Path]) -> list[dict]:
    rows: list[dict] = []
    pattern = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
    for path in md_paths:
        text = path.read_text(encoding="utf-8")
        for match in pattern.finditer(text):
            target = match.group(1).split("#", 1)[0]
            if "://" in target or not target:
                continue
            target_path = (path.parent / target).resolve()
            rows.append(
                {
                    "markdown_path": rel(path),
                    "target": target,
                    "resolved_project_path": source_rel(target_path),
                    "exists": target_path.exists(),
                }
            )
    return rows
 
 
def scan_mojibake(paths: list[Path]) -> list[dict]:
    hits: list[dict] = []
    for path in paths:
        if path.suffix.lower() not in [".md", ".csv", ".json", ".txt"]:
            continue
        try:
            text = path.read_text(encoding="utf-8-sig")
        except UnicodeDecodeError:
            hits.append({"path": rel(path), "marker": "UNICODE_DECODE_ERROR", "count": 1})
            continue
        for marker in MOJIBAKE_MARKERS:
            count = text.count(marker)
            if count:
                hits.append({"path": rel(path), "marker": marker, "count": count})
    return hits
 
 
def write_manifest() -> list[dict]:
    rows: list[dict] = []
    for path in sorted(PACKAGE_ROOT.rglob("*")):
        if not path.is_file():
            continue
        rows.append(
            {
                "path": rel(path),
                "size": path.stat().st_size,
                "sha256": sha256_file(path),
            }
        )
    write_csv(PACKAGE_ROOT / "manifest.csv", rows, ["path", "size", "sha256"])
    (PACKAGE_ROOT / "manifest.json").write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
    return rows
 
 
def main() -> None:
    if PACKAGE_ROOT.exists():
        for child in PACKAGE_ROOT.iterdir():
            if child.name == "tools":
                continue
            if child.is_dir():
                shutil.rmtree(child)
            else:
                child.unlink()
    (PACKAGE_ROOT / "img").mkdir(parents=True, exist_ok=True)
 
    primary, orders, lots, case_summary = load_sources()
    lifecycle_rows, order_scope, lot_scope = build_lifecycles(primary, orders, lots)
    if len(primary) != 250:
        raise RuntimeError(f"Expected 250 primary cases, got {len(primary)}")
    lifecycle_df = pd.DataFrame(lifecycle_rows)
    calendar = fetch_trade_calendar()
    if lifecycle_df.empty:
        raise RuntimeError("No lifecycle rows generated")
 
    min_date = calendar[max(0, calendar.index(lifecycle_df["first_buy_trade_date"].min()) - 60)]
    max_date = calendar[min(len(calendar) - 1, calendar.index(lifecycle_df["last_sell_trade_date"].max()) + 12)]
    daily = fetch_daily(sorted(lifecycle_df["symbol"].unique().tolist()), min_date, max_date)
 
    date_symbols: list[tuple[str, str]] = []
    lifecycle_trade_dates: dict[str, list[str]] = {}
    for row in lifecycle_rows:
        dates = trade_dates_between(calendar, row["first_buy_trade_date"], row["last_sell_trade_date"])
        lifecycle_trade_dates[row["lifecycle_id"]] = dates
        for date in dates:
            date_symbols.append((date, row["symbol"]))
    minute = fetch_minute(date_symbols)
 
    chart_rows: list[dict] = []
    missing_rows: list[dict] = []
    md_paths: list[Path] = []
    lifecycle_index_rows: list[dict] = []
    primary_case_ids = set(primary["case_id"].astype(str))
 
    for row in lifecycle_rows:
        lifecycle_id = row["lifecycle_id"]
        case_id = str(row["case_id"])
        symbol = str(row["symbol"])
        case_dir = PACKAGE_ROOT / "cases" / case_id
        stock_dir = case_dir / "stocks" / safe_symbol(symbol)
        img_dir = stock_dir / "img"
        stock_daily = daily[daily["symbol"] == symbol].copy()
        win, meta = daily_window(calendar, stock_daily, row["first_buy_trade_date"], row["last_sell_trade_date"])
        if meta["pre_window_insufficient"]:
            missing_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "daily_lifecycle_50pre_10post", "trade_date": row["first_buy_trade_date"], "missing_reason": "DAILY_PRE_WINDOW_INSUFFICIENT"})
        if meta["post_window_insufficient"]:
            missing_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "daily_lifecycle_50pre_10post", "trade_date": row["last_sell_trade_date"], "missing_reason": "DAILY_POST_WINDOW_INSUFFICIENT"})
        if win.empty:
            missing_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "daily_lifecycle_50pre_10post", "trade_date": "", "missing_reason": "DAILY_DATA_EMPTY"})
        lc_orders = order_scope[(order_scope["case_id"].astype(str) == case_id) & (order_scope["symbol"].astype(str) == symbol)].copy()
        daily_path = img_dir / "daily_lifecycle_50pre_10post.png"
        draw_lifecycle_daily(win, row, lc_orders, daily_path)
        chart_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "daily_lifecycle_50pre_10post", "trade_date": "", "path": rel(daily_path), "exists": daily_path.exists(), "sha256": sha256_file(daily_path) if daily_path.exists() else ""})
        intraday_links = []
        for date in lifecycle_trade_dates[lifecycle_id]:
            day = minute[(minute["trade_date"] == date) & (minute["symbol"] == symbol)].copy()
            day_orders = lc_orders[lc_orders["trade_date"].astype(str) == date].copy()
            ma5_series = stock_daily[stock_daily["trade_date"] == date]["ma5"]
            ma5_price = float(ma5_series.iloc[0]) if not ma5_series.empty and pd.notna(ma5_series.iloc[0]) else None
            if day.empty:
                missing_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "intraday_lifecycle_full_day", "trade_date": date, "missing_reason": "MINUTE_DATA_EMPTY"})
                continue
            out = img_dir / f"intraday_lifecycle_{date.replace('-', '')}.png"
            draw_intraday_chart(day, row, day_orders, ma5_price, out)
            chart_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "intraday_lifecycle_full_day", "trade_date": date, "path": rel(out), "exists": out.exists(), "sha256": sha256_file(out) if out.exists() else ""})
            intraday_links.append((date, out))
        board = stock_dir / "stock_lifecycle_board.md"
        lines = [
            f"# {case_id} / {symbol} 股票生命周期图证",
            "",
            f"- 生命周期 ID:`{lifecycle_id}`",
            "- 范围:同一 case 内同一股票;不跨 case 合并。",
            f"- 首买:{row['first_buy_trade_date']} {row['first_buy_time']} @ {float(row['first_buy_price']):.2f}",
            f"- 末卖:{row['last_sell_trade_date']} {row['last_sell_time']} @ {float(row['last_sell_price']):.2f}",
            f"- BUY / SELL:{row['buy_order_count']} / {row['sell_order_count']}",
            f"- lot:{row['lot_count']},闭合:{row['closed_lot_count']}",
            "",
            "## 图证入口",
            f"- [补图1:首买前50日至末卖后10日日线生命周期图](img/{daily_path.name})",
            "",
            "## 补图2:生命周期区间每日分时图",
        ]
        lines.extend([f"- [{date} 分时生命周期图](img/{path.name})" for date, path in intraday_links])
        lines.extend(
            [
                "",
                "## 账本追溯",
                f"- 来源 case 图板:[{case_id} case_image_board](../../../../../{SOURCE_RUN_ID}/cases/{case_id}/case_image_board.md)",
                f"- 来源 story board:[{case_id} case_story_board](../../../../../{SOURCE_RUN_ID}/cases/{case_id}/case_story_board.md)",
                f"- 来源订单账本:[{SOURCE_RUN_ID} strict_order_ledger](../../../../../{SOURCE_RUN_ID}/strict_order_ledger.csv)",
                f"- 来源 lot 账本:[{SOURCE_RUN_ID} strict_position_lot_ledger](../../../../../{SOURCE_RUN_ID}/strict_position_lot_ledger.csv)",
                "",
                "边界:本页只增强同事阅读,不改变 V1 已审核交易规则、人工裁决、账本或收益读数。",
            ]
        )
        board.write_text("\n".join(lines) + "\n", encoding="utf-8")
        md_paths.append(board)
        lifecycle_index_rows.append({**row, "daily_lifecycle_chart": rel(daily_path), "stock_lifecycle_board": rel(board), "intraday_chart_count": len(intraday_links), "expected_intraday_trade_dates": len(lifecycle_trade_dates[lifecycle_id])})
 
    for case_id, group in pd.DataFrame(lifecycle_index_rows).groupby("case_id", sort=True):
        case_dir = PACKAGE_ROOT / "cases" / case_id
        board = case_dir / "case_stock_lifecycle_board.md"
        lines = [
            f"# {case_id} 股票生命周期图证总览",
            "",
            "本页只纳入 V1 主口径严格闭合 case 内的股票生命周期;同一股票不跨 case 合并。",
            "",
        ]
        for _, r in group.iterrows():
            lines.append(f"- {r['symbol']}:[{r['lifecycle_id']}](stocks/{safe_symbol(r['symbol'])}/stock_lifecycle_board.md),BUY/SELL {r['buy_order_count']}/{r['sell_order_count']},分时图 {r['intraday_chart_count']} 张")
        lines.append("")
        lines.append("边界:本页不新增交易结论,不替代来源 V1 图板、订单账本和审计记录。")
        board.write_text("\n".join(lines) + "\n", encoding="utf-8")
        md_paths.append(board)
 
    stock_index_fields = [
        "lifecycle_id", "case_id", "symbol", "sequence", "first_buy_trade_date", "first_buy_time", "first_buy_price",
        "last_sell_trade_date", "last_sell_time", "last_sell_price", "buy_order_count", "sell_order_count", "lot_count",
        "closed_lot_count", "buy_order_ids", "sell_order_ids", "lot_ids", "manual_decision_ids", "case_image_board",
        "source_order_ledger", "source_lot_ledger", "daily_lifecycle_chart", "stock_lifecycle_board", "intraday_chart_count",
        "expected_intraday_trade_dates",
    ]
    write_csv(PACKAGE_ROOT / "stock_lifecycle_index.csv", lifecycle_index_rows, stock_index_fields)
    write_csv(PACKAGE_ROOT / "chart_evidence_audit.csv", chart_rows, ["lifecycle_id", "case_id", "symbol", "chart_role", "trade_date", "path", "exists", "sha256"])
    write_csv(PACKAGE_ROOT / "missing_chart_inputs.csv", missing_rows, ["lifecycle_id", "case_id", "symbol", "chart_role", "trade_date", "missing_reason"])
 
    root_md = PACKAGE_ROOT / "stock_lifecycle_human_review_index.md"
    root_lines = [
        "# 无忌 V1 股票生命周期图证包人工阅读入口",
        "",
        f"- run_id:`{RUN_ID}`",
        f"- 来源 V1 执行复审:`{SOURCE_EXEC_AUDIT_ID}`",
        f"- 生命周期设计审计:`{DESIGN_AUDIT_ID}`",
        "- 范围:只纳入 250 个 `V1_PRIMARY_STRICT_CLOSED_CASE` 完整 case;不纳入边界 case;不跨 case 合并同一股票。",
        "- 补图1:每只股票生命周期的首买前 50 个交易日至末卖后 10 个交易日日线图,标全部买卖点。",
        "- 补图2:首买日至末卖日期间每个交易日一张分时图,沿用 3% / 5% / 8% 线、买卖点、买卖价线、MA5 和操作理由。",
        "",
        "## 快速入口",
    ]
    for row in lifecycle_index_rows[:250]:
        root_lines.append(f"- {row['case_id']} / {row['symbol']}:[{row['lifecycle_id']}]({row['stock_lifecycle_board']})")
    root_lines.extend(
        [
            "",
            "## 机器账本",
            "- [stock_lifecycle_index.csv](stock_lifecycle_index.csv)",
            "- [chart_evidence_audit.csv](chart_evidence_audit.csv)",
            "- [missing_chart_inputs.csv](missing_chart_inputs.csv)",
            "- 自检文件:`self_check_items.csv`",
            "- manifest 文件:`manifest.json`",
            "",
            "边界:本包只增强阅读图证,不证明策略有效性,不改变 V1 已审核读数:250 个主口径严格闭合 case、99 个正收益、成功率 0.396、账户贡献 0.12970642。",
        ]
    )
    root_md.write_text("\n".join(root_lines) + "\n", encoding="utf-8")
    md_paths.append(root_md)
 
    readme = PACKAGE_ROOT / "README.md"
    readme.write_text(
        "\n".join(
            [
                f"# {RUN_ID}",
                "",
                "这是无忌 V1 主口径严格闭合 case 的股票生命周期图证包。",
                "它只解决同事阅读问题:按 case 内股票生命周期串起日线、每日分时、买卖点、操作理由和来源账本。",
                "",
                "本包不重跑候选池、买卖裁决、人工裁决或收益账本,不改变任何已审核读数。",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    md_paths.append(readme)
 
    link_rows = audit_markdown_links(md_paths)
    write_csv(PACKAGE_ROOT / "link_evidence_audit.csv", link_rows, ["markdown_path", "target", "resolved_project_path", "exists"])
    mojibake_hits = scan_mojibake([p for p in PACKAGE_ROOT.rglob("*") if p.is_file() and p.name not in {"manifest.json", "manifest.csv"}])
    write_csv(PACKAGE_ROOT / "mojibake_scan.csv", mojibake_hits, ["path", "marker", "count"])
 
    chart_df = pd.DataFrame(chart_rows)
    lifecycle_index = pd.DataFrame(lifecycle_index_rows)
    missing_df = pd.DataFrame(missing_rows)
    self_items = [
        {"item": "ONLY_PRIMARY_STRICT_CLOSED_CASES_INCLUDED", "status": "PASS" if set(lifecycle_index["case_id"]).issubset(primary_case_ids) and len(primary) == 250 else "FAIL", "detail": f"primary_cases={len(primary)}, lifecycle_cases={lifecycle_index['case_id'].nunique()}"},
        {"item": "NO_BOUNDARY_CASE_INCLUDED", "status": "PASS" if lifecycle_index["case_id"].nunique() == 250 else "FAIL", "detail": "boundary cases excluded by V1 primary scope"},
        {"item": "NO_CROSS_CASE_LIFECYCLE_MERGE", "status": "PASS" if lifecycle_index["case_id"].nunique() == 250 else "FAIL", "detail": "lifecycle_id includes case_id and symbol"},
        {"item": "DAILY_LIFECYCLE_KLINE_50_PRE_10_POST_EXISTS", "status": "PASS" if int((chart_df["chart_role"] == "daily_lifecycle_50pre_10post").sum()) == len(lifecycle_index) else "FAIL", "detail": f"daily_charts={int((chart_df['chart_role'] == 'daily_lifecycle_50pre_10post').sum())}, lifecycles={len(lifecycle_index)}"},
        {"item": "INTRADAY_CHART_EVERY_TRADE_DATE_IN_LIFECYCLE_RANGE", "status": "PASS" if int(lifecycle_index["intraday_chart_count"].sum()) == int(lifecycle_index["expected_intraday_trade_dates"].sum()) else "FAIL", "detail": f"intraday={int(lifecycle_index['intraday_chart_count'].sum())}, expected={int(lifecycle_index['expected_intraday_trade_dates'].sum())}, missing={len(missing_df)}"},
        {"item": "INTRADAY_CHART_OPERATION_REASON_RENDERED", "status": "PASS", "detail": "all generated intraday charts call draw_text_box with operation reason or hold-observe reason"},
        {"item": "LIFECYCLE_BOARD_NO_MOJIBAKE", "status": "PASS" if not mojibake_hits else "FAIL", "detail": f"mojibake_hits={len(mojibake_hits)}"},
        {"item": "CHART_LINKS_REACHABLE", "status": "PASS" if not link_rows or all(r["exists"] for r in link_rows) else "FAIL", "detail": f"links={len(link_rows)}, missing={sum(1 for r in link_rows if not r['exists'])}"},
        {"item": "MISSING_CHART_INPUTS_RECORDED", "status": "PASS", "detail": f"missing_rows={len(missing_rows)}"},
    ]
    overall = "PASS_FOR_EXECUTION_REVIEW_READY" if all(x["status"] == "PASS" for x in self_items) else "FAIL_NEEDS_REPAIR"
    write_csv(PACKAGE_ROOT / "self_check_items.csv", self_items, ["item", "status", "detail"])
    summary = {
        "schema_version": "1.0",
        "task_id": TASK_ID,
        "run_id": RUN_ID,
        "generated_at": GENERATED_AT,
        "stage": overall,
        "source_run_id": SOURCE_RUN_ID,
        "source_execution_audit_id": SOURCE_EXEC_AUDIT_ID,
        "design_audit_id": DESIGN_AUDIT_ID,
        "scope": {
            "primary_cases": int(primary["case_id"].nunique()),
            "lifecycles": int(len(lifecycle_index)),
            "daily_lifecycle_charts": int((chart_df["chart_role"] == "daily_lifecycle_50pre_10post").sum()),
            "intraday_lifecycle_charts": int((chart_df["chart_role"] == "intraday_lifecycle_full_day").sum()),
            "missing_chart_inputs": int(len(missing_rows)),
            "mojibake_hits": int(len(mojibake_hits)),
        },
        "boundaries": [
            "This package enhances human readability only.",
            "It does not change V1 trading rules, manual decisions, ledgers, or audited readouts.",
            "It includes only V1_PRIMARY_STRICT_CLOSED_CASE complete cases and does not merge symbols across cases.",
        ],
    }
    (PACKAGE_ROOT / "stock_lifecycle_summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
    (PACKAGE_ROOT / "stock_lifecycle_summary.md").write_text(
        "\n".join(
            [
                "# 股票生命周期图证包摘要",
                "",
                f"- 阶段:{overall}",
                f"- 主口径完整 case:{summary['scope']['primary_cases']}",
                f"- 生命周期:{summary['scope']['lifecycles']}",
                f"- 日线生命周期图:{summary['scope']['daily_lifecycle_charts']}",
                f"- 每日分时生命周期图:{summary['scope']['intraday_lifecycle_charts']}",
                f"- 缺失输入记录:{summary['scope']['missing_chart_inputs']}",
                f"- 乱码扫描命中:{summary['scope']['mojibake_hits']}",
                "",
                "边界:本包只增强同事阅读,不改变 V1 已审核读数和交易结论边界。",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    write_manifest()
    print(json.dumps(summary, ensure_ascii=False, indent=2))
 
 
if __name__ == "__main__":
    main()