From 5211d5a6eda7512122d6252272f0f44314b037a7 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sun, 16 Aug 2026 22:45:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(reconcile+ci):=20=E5=8F=8C=E8=BD=A8?= =?UTF-8?q?=E9=85=8D=E5=AF=B9=E6=8C=89instance=5Fid=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E9=85=8D=E5=AF=B9+=E9=83=A8=E7=BD=B2API=E7=AD=89=E5=BE=85?= =?UTF-8?q?=E5=B8=BD10=E2=86=9220=E5=88=86=E9=92=9F=E2=80=94=E2=80=94?= =?UTF-8?q?=E2=91=A0=E9=85=8D=E5=AF=B9v2:v1=E6=8C=89=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E5=90=8Ddict=E6=94=B6=E6=95=9B,=E5=90=8C=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E5=A4=9Alive(live#10/#11=E9=83=BD=E6=98=AFchannel=5Ftest)?= =?UTF-8?q?=E5=90=8E=E8=80=85=E8=A6=86=E7=9B=96=E5=89=8D=E8=80=85=E2=86=92?= =?UTF-8?q?live#10=E6=BC=8F=E9=85=8Dlive#11=E5=8F=8C=E9=85=8D,15:10?= =?UTF-8?q?=E6=97=A5=E7=BB=88=E5=AF=B9=E8=B4=A6=E9=85=8D=E9=94=99=E5=AF=B9?= =?UTF-8?q?(VPS=208=E5=AF=B9=E8=88=B0=E9=98=9F=E5=AE=9E=E6=B5=8B:live#11?= =?UTF-8?q?=E2=86=94shadow#44/45=E4=B8=A4=E8=A1=8C,live#10=E6=97=A0);?= =?UTF-8?q?=E4=BF=AE=E4=B8=BAinstance=5Fid+=E5=91=A8=E6=9C=9F=E7=B2=BE?= =?UTF-8?q?=E7=A1=AE=E4=BC=98=E5=85=88=E2=86=92=E5=90=8C=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E9=80=80=E8=80=8C=E6=B1=82=E5=85=B6=E6=AC=A1=E2=86=92=E6=97=A0?= =?UTF-8?q?instance=E5=9B=9E=E9=80=80=E7=AD=96=E7=95=A5=E5=90=8D=E5=94=AF?= =?UTF-8?q?=E4=B8=80/=E5=91=A8=E6=9C=9F=E4=B8=80=E8=87=B4,=E5=AE=81?= =?UTF-8?q?=E7=BC=BA=E6=AF=8B=E9=94=99;+2=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95(=E5=A4=8D=E5=88=BBVPS=E5=AE=9E=E5=86=B5/=E5=90=8C?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E4=B8=8D=E5=90=8C=E5=91=A8=E6=9C=9F)?= =?UTF-8?q?=E2=91=A1vps-deploy=20API=E7=9B=91=E5=90=AC=E7=AD=89=E5=BE=8560?= =?UTF-8?q?=E2=86=92120=E6=AC=A1(run681=E5=AE=9E=E5=BD=95:19=E5=B8=B8?= =?UTF-8?q?=E9=A9=BB=E8=BF=9B=E7=A8=8B=E8=B4=9F=E8=BD=BD=E4=B8=8BAPI?= =?UTF-8?q?=E5=90=AF=E5=8A=A8>10=E5=88=86=E9=92=9F,workflow=2010=E5=88=86?= =?UTF-8?q?=E9=92=9F=E5=B8=BD=E8=AF=AF=E6=9D=80=E6=94=BE=E5=BC=83,API?= =?UTF-8?q?=E6=9C=80=E7=BB=88=E6=AD=A3=E5=B8=B8=E8=B5=B7=E6=9C=8D=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=BD=86=E8=B7=B3=E8=BF=87=E4=BA=86=E5=B8=B8=E9=A9=BB?= =?UTF-8?q?=E9=87=8D=E5=90=AF/tag/=E5=85=B3issue=E4=B8=89=E6=AD=A5);938?= =?UTF-8?q?=E7=BB=BF=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/vps-deploy.yml | 8 ++-- sanguo_trader/shadow/reconcile_report.py | 44 ++++++++++++++----- tests/trader/test_shadow_reconcile_report.py | 45 ++++++++++++++++++++ 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/.gitea/workflows/vps-deploy.yml b/.gitea/workflows/vps-deploy.yml index 2d1e89a..34c42ce 100644 --- a/.gitea/workflows/vps-deploy.yml +++ b/.gitea/workflows/vps-deploy.yml @@ -84,13 +84,15 @@ jobs: done if [ "$PORT_FREED" != "1" ]; then echo "❌ 端口 8000 未释放, 强杀后重启会 10048, 终止"; exit 1; fi ssh 49.232.102.198 'schtasks /run /tn sanguo-api' - # 等新实例监听 + /docs 可用(VPS 启动需数分钟, 上限 10 分钟) - for i in $(seq 1 60); do + # 等新实例监听 + /docs 可用(上限 20 分钟:2026-08-16 run681 实录 19 个常驻进程 + # 负载下 API 启动超 10 分钟,最终正常起服——10 分钟帽误杀,workflow 放弃时 + # API 其实在路上,且跳过了常驻重启/tag/关issue 三步) + for i in $(seq 1 120); do if ssh 49.232.102.198 'netstat -ano -p tcp' | tr -d '\r' | grep -q ':8000.*LISTEN'; then echo "API listening after ~$((i*10))s"; break fi sleep 10 - if [ "$i" = "60" ]; then echo "API 10 分钟未监听 8000"; exit 1; fi + if [ "$i" = "120" ]; then echo "API 20 分钟未监听 8000"; exit 1; fi done for i in $(seq 1 12); do ssh 49.232.102.198 'powershell -NoProfile -Command "(Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8000/docs).StatusCode"' 2>/dev/null | grep -q 200 && { echo "API /docs 200"; exit 0; } diff --git a/sanguo_trader/shadow/reconcile_report.py b/sanguo_trader/shadow/reconcile_report.py index 1353cf0..095d9d0 100644 --- a/sanguo_trader/shadow/reconcile_report.py +++ b/sanguo_trader/shadow/reconcile_report.py @@ -203,22 +203,46 @@ def build_reconcile_report(db: str, live_account_id: int, shadow_account_id: int def find_dual_track_pairs(db: str) -> List[Dict[str, Any]]: - """自动配对:运行中影子账户(mode=shadow)按策略名匹配 live_accounts.strategy_class。""" + """自动配对(2026-08-16 v2):优先 instance_id+周期精确配对,无 instance 回退策略名。 + + v1 按策略名建 dict 收敛——同策略多 live 账户(live#10/#11 都是 channel_test) + 后者覆盖前者 → live#10 漏配、live#11 被配两次,15:10 日终对账配错对 + (VPS 8对舰队实测)。同实例的 live↔shadow 才是真双轨。 + """ pairs: List[Dict[str, Any]] = [] with sqlite3.connect(db) as conn: - lives = {r[0]: r[1] for r in conn.execute( - "SELECT strategy_class, id FROM live_accounts WHERE status='running'")} - for aid, strategies_json in conn.execute( - "SELECT id, strategies FROM paper_accounts " + conn.row_factory = sqlite3.Row + lives = [dict(r) for r in conn.execute( + "SELECT id, strategy_class, instance_id, interval " + "FROM live_accounts WHERE status='running'")] + for r in conn.execute( + "SELECT id, strategies, instance_id, interval FROM paper_accounts " "WHERE mode='shadow' AND status='running'"): + sh = dict(r) try: - name = (json.loads(strategies_json or "[]") or [{}])[0].get("name", "") + name = (json.loads(sh.get("strategies") or "[]") or [{}])[0].get("name", "") except (json.JSONDecodeError, IndexError): continue - live_id = lives.get(name) - if live_id: - pairs.append({"live_account_id": live_id, - "shadow_account_id": aid, "strategy": name}) + cand = None + if sh.get("instance_id"): + # 同实例同周期 → 精确双轨;退而求其次同实例 + cand = next((l for l in lives + if l.get("instance_id") == sh["instance_id"] + and l.get("interval") == sh.get("interval")), None) + if cand is None: + cand = next((l for l in lives + if l.get("instance_id") == sh["instance_id"]), None) + if cand is None: + same = [l for l in lives if l["strategy_class"] == name] + if len(same) == 1: + cand = same[0] + elif len(same) > 1: + # 同策略多 live 无 instance 可辨 → 周期一致才配,宁缺毋错 + cand = next((l for l in same + if l.get("interval") == sh.get("interval")), None) + if cand: + pairs.append({"live_account_id": cand["id"], + "shadow_account_id": sh["id"], "strategy": name}) return pairs diff --git a/tests/trader/test_shadow_reconcile_report.py b/tests/trader/test_shadow_reconcile_report.py index 261cd1d..950c2ad 100644 --- a/tests/trader/test_shadow_reconcile_report.py +++ b/tests/trader/test_shadow_reconcile_report.py @@ -255,3 +255,48 @@ class TestDailyReconcileHook: # 同日再触发 → 跳过 _maybe_daily_reconcile(db, done, now=datetime(2026, 8, 15, 16, 0)) assert done == {D} + + +class TestFindPairsByInstance: + """2026-08-16 v2 配对:同策略多账户按 instance_id 精确配(不再策略名 dict 收敛)。""" + + def test_same_strategy_multiple_lives_pair_by_instance(self, db): + """VPS 实况复刻:live#10/#11 同为 channel_test 不同实例,影子各归各。""" + with sqlite3.connect(db) as conn: + conn.executemany( + "INSERT INTO live_accounts (id,name,account,vt_symbol,strategy_class," + "strategy_name,status,instance_id,interval) VALUES (?,?,?,?,?,?,?,?,?)", + [(10, "live10", "66639661", "all", "channel_test", "s", "running", 4, "15m"), + (11, "live11", "66639661", "all", "channel_test", "s", "running", 3, "d")], + ) + conn.executemany( + "INSERT INTO paper_accounts (id,name,strategy_type,mode,status,symbols," + "strategies,instance_id,interval) VALUES (?,?,?,?,?,?,?,?,?)", + [(44, "sh44", "portfolio", "shadow", "running", '["all"]', + json.dumps([{"name": "channel_test", "params": {}}]), 4, "15m"), + (45, "sh45", "portfolio", "shadow", "running", '["all"]', + json.dumps([{"name": "channel_test", "params": {}}]), 3, "d")], + ) + pairs = {(p["live_account_id"], p["shadow_account_id"]): p["strategy"] + for p in find_dual_track_pairs(db)} + # v1 缺陷:策略名 dict 收敛 → 44/45 都配给 live#11;v2 按 instance 各归各 + assert pairs == {(10, 44): "channel_test", (11, 45): "channel_test"} + + def test_instance_prefers_same_interval(self, db): + """同实例不同周期(15m vs d):影子配同周期的 live。""" + with sqlite3.connect(db) as conn: + conn.executemany( + "INSERT INTO live_accounts (id,name,account,vt_symbol,strategy_class," + "strategy_name,status,instance_id,interval) VALUES (?,?,?,?,?,?,?,?,?)", + [(20, "liveA", "66639661", "all", "all_weather", "s", "running", 1, "15m"), + (21, "liveB", "66639661", "all", "all_weather", "s", "running", 1, "d")], + ) + conn.execute( + "INSERT INTO paper_accounts (id,name,strategy_type,mode,status,symbols," + "strategies,instance_id,interval) VALUES (?,?,?,?,?,?,?,?,?)", + (60, "sh60", "portfolio", "shadow", "running", '["all"]', + json.dumps([{"name": "all_weather", "params": {}}]), 1, "d"), + ) + pairs = find_dual_track_pairs(db) + assert pairs == [{"live_account_id": 21, "shadow_account_id": 60, + "strategy": "all_weather"}]