持仓同步改为后台执行,添加进度条实时反馈

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:10:17 +08:00
parent cd7f6668af
commit b74dccd805
3 changed files with 199 additions and 12 deletions
+115
View File
@@ -1,9 +1,124 @@
"""Data collector — fetch OHLCV from akshare and upsert into daily_bars.""" """Data collector — fetch OHLCV from akshare and upsert into daily_bars."""
import threading
from datetime import date from datetime import date
from app.database import SessionLocal from app.database import SessionLocal
from app.models import DailyBar, Contract from app.models import DailyBar, Contract
from app.engine.lock_strategy import compute_amp_5d from app.engine.lock_strategy import compute_amp_5d
_sync_progress = {
"running": False,
"contract": "",
"contract_done": 0,
"contract_total": 0,
"date_done": 0,
"date_total": 0,
"rows": 0,
"finished": False,
}
_lock = threading.Lock()
def get_sync_progress() -> dict:
with _lock:
return dict(_sync_progress)
def start_sync_positions_background():
"""Start sync_position_rankings in a background thread. Returns immediately."""
with _lock:
if _sync_progress["running"]:
return False
_sync_progress.update({
"running": True,
"finished": False,
"contract": "",
"contract_done": 0,
"contract_total": 0,
"date_done": 0,
"date_total": 0,
"rows": 0,
})
def _run():
try:
_do_sync_with_progress()
finally:
with _lock:
_sync_progress["running"] = False
_sync_progress["finished"] = True
threading.Thread(target=_run, daemon=True).start()
return True
def _do_sync_with_progress():
from app.models import PositionRanking
db = SessionLocal()
try:
active_contracts = (
db.query(Contract).filter(Contract.is_active == True).all()
)
with _lock:
_sync_progress["contract_total"] = len(active_contracts)
for idx, c in enumerate(active_contracts):
with _lock:
_sync_progress.update({
"contract": c.code,
"contract_done": idx,
"date_done": 0,
"date_total": 0,
})
code = c.code.upper()
existing_dates = {
r[0] for r in
db.query(PositionRanking.date)
.filter(PositionRanking.contract_code == code)
.distinct()
.all()
}
bar_dates = [
r[0] for r in
db.query(DailyBar.date)
.filter(DailyBar.contract == code)
.order_by(DailyBar.date)
.all()
]
missing = [d for d in bar_dates if d not in existing_dates]
with _lock:
_sync_progress["date_total"] = len(missing)
for date_idx, d in enumerate(missing):
date_str = d.strftime("%Y%m%d")
rankings = fetch_position_rankings(code, date_str)
inserted = 0
for r in rankings:
db.add(PositionRanking(
contract_code=code,
institution=r["institution"],
data_type=r["data_type"],
date=d,
rank=r["rank"],
value=r["value"],
change=r["change"],
))
inserted += 1
db.flush()
with _lock:
_sync_progress["date_done"] = date_idx + 1
_sync_progress["rows"] += inserted
db.commit()
with _lock:
_sync_progress["contract_done"] = len(active_contracts)
finally:
db.close()
def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> list[dict]: def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> list[dict]:
"""Fetch daily OHLCV for a single contract from akshare. """Fetch daily OHLCV for a single contract from akshare.
+14 -6
View File
@@ -1,9 +1,12 @@
from fastapi import APIRouter, Depends, Form, Request from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import Product, Contract, DailyBar, PositionRanking from app.models import Product, Contract, DailyBar, PositionRanking
from app.collector import sync_active_contracts, sync_one_contract, sync_position_rankings from app.collector import (
sync_active_contracts, sync_one_contract, sync_position_rankings,
start_sync_positions_background, get_sync_progress,
)
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -147,10 +150,15 @@ def sync_single(contract_code: str):
@router.post("/sync-positions") @router.post("/sync-positions")
def sync_positions(request: Request): def sync_positions(request: Request):
results = sync_position_rankings() started = start_sync_positions_background()
total = sum(results.values()) if not started:
print(f"[sync] Position rankings: {total} rows across {len(results)} contracts") return JSONResponse({"error": "同步正在进行中,请等待完成"})
return RedirectResponse(f"/admin/?tab=sync&pos_synced={total}", status_code=303) return JSONResponse({"started": True})
@router.get("/sync-status")
def sync_status():
return JSONResponse(get_sync_progress())
@router.post("/sync/product/{product_id}") @router.post("/sync/product/{product_id}")
+70 -6
View File
@@ -34,18 +34,24 @@
<form method="post" action="/admin/sync" style="display:inline;"> <form method="post" action="/admin/sync" style="display:inline;">
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;">同步行情</button> <button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;">同步行情</button>
</form> </form>
<form method="post" action="/admin/sync-positions" style="display:inline;"> <button id="btn-sync-pos" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;background:var(--success);" onclick="startPosSync()">同步持仓</button>
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;background:var(--success);">同步持仓</button>
</form>
{% if request.query_params.get('synced') %} {% if request.query_params.get('synced') %}
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 行情 {{ request.query_params.synced }} 条</span> <span style="font-size:0.82rem;color:var(--success-fg);">✓ 行情 {{ request.query_params.synced }} 条</span>
{% endif %} {% endif %}
{% if request.query_params.get('pos_synced') %} <span id="sync-pos-msg" style="font-size:0.82rem;color:var(--sub);"></span>
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 持仓 {{ request.query_params.pos_synced }} 条</span>
{% endif %}
<span style="font-size:0.78rem;color:var(--sub);margin-left:auto;">{{ total_contracts }} 个合约</span> <span style="font-size:0.78rem;color:var(--sub);margin-left:auto;">{{ total_contracts }} 个合约</span>
</div> </div>
<div id="pos-progress" style="display:none;margin-bottom:20px;padding:14px 18px;background:var(--surface);border:1px solid var(--border);border-radius:10px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span id="pos-progress-label" style="font-size:0.82rem;color:var(--fg);">正在同步...</span>
<span id="pos-progress-pct" style="font-size:0.82rem;color:var(--sub);margin-left:auto;"></span>
</div>
<div style="width:100%;height:6px;background:var(--border);border-radius:3px;overflow:hidden;">
<div id="pos-progress-bar" style="width:0%;height:100%;background:var(--accent);border-radius:3px;transition:width .3s;"></div>
</div>
</div>
{% if products %} {% if products %}
{% for p in products %} {% for p in products %}
<div style="margin-bottom:16px;border:1px solid var(--border);border-radius:10px;overflow:hidden;"> <div style="margin-bottom:16px;border:1px solid var(--border);border-radius:10px;overflow:hidden;">
@@ -196,6 +202,64 @@
</div> </div>
<script> <script>
var posSyncTimer = null;
function startPosSync() {
var btn = document.getElementById('btn-sync-pos');
btn.disabled = true;
btn.textContent = '发起中...';
document.getElementById('sync-pos-msg').textContent = '';
fetch('/admin/sync-positions', { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
document.getElementById('sync-pos-msg').textContent = '⚠ ' + data.error;
btn.disabled = false;
btn.textContent = '同步持仓';
return;
}
document.getElementById('pos-progress').style.display = 'block';
btn.textContent = '同步中...';
posSyncTimer = setInterval(pollProgress, 800);
});
}
function pollProgress() {
fetch('/admin/sync-status')
.then(function(r) { return r.json(); })
.then(function(data) {
var done = data.contract_done;
var total = data.contract_total;
var dDone = data.date_done;
var dTotal = data.date_total;
var pct = 0;
if (data.finished) {
pct = 100;
clearInterval(posSyncTimer);
document.getElementById('pos-progress-pct').textContent = '100%';
document.getElementById('pos-progress-label').textContent =
'完成:' + total + ' 个合约,共同步 ' + data.rows + ' 条持仓数据';
document.getElementById('sync-pos-msg').textContent = '✓ 已同步 ' + data.rows + ' 条';
var btn = document.getElementById('btn-sync-pos');
btn.disabled = false;
btn.textContent = '同步持仓';
} else if (total > 0) {
// Weight: contracts account for most of the bar, dates for fine-grained within current contract
pct = Math.round((done + (dTotal > 0 ? dDone / dTotal : 0)) / total * 100);
document.getElementById('pos-progress-pct').textContent = pct + '%';
var label = '合约 ' + (done + 1) + '/' + total + '' + data.contract;
if (dTotal > 0) {
label += '' + dDone + '/' + dTotal + ' 天)';
}
document.getElementById('pos-progress-label').textContent = label;
}
document.getElementById('pos-progress-bar').style.width = pct + '%';
});
}
function switchTab(name) { function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); }); document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); }); document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });