fix(reconcile+ci): 双轨配对按instance_id精确配对+部署API等待帽10→20分钟——①配对v2:v1按策略名dict收敛,同策略多live(live#10/#11都是channel_test)后者覆盖前者→live#10漏配live#11双配,15:10日终对账配错对(VPS 8对舰队实测:live#11↔shadow#44/45两行,live#10无);修为instance_id+周期精确优先→同实例退而求其次→无instance回退策略名唯一/周期一致,宁缺毋错;+2回归测试(复刻VPS实况/同实例不同周期)②vps-deploy API监听等待60→120次(run681实录:19常驻进程负载下API启动>10分钟,workflow 10分钟帽误杀放弃,API最终正常起服——但跳过了常驻重启/tag/关issue三步);938绿 [vps]
CI/CD / test (push) Successful in 9s
CI/CD / nas-deploy (push) Successful in 24s
CI/CD / nas-verify (push) Successful in 8s

This commit is contained in:
2026-08-16 22:45:42 +08:00
parent 6ecdaab959
commit 5211d5a6ed
3 changed files with 84 additions and 13 deletions
+5 -3
View File
@@ -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; }
+34 -10
View File
@@ -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
@@ -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"}]