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"}]