移除全部合约同步按钮,改为单合约后台同步带进度条
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+79
-71
@@ -5,119 +5,127 @@ 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 = {
|
_progress = {"running": False, "label": "", "done": 0, "total": 0, "finished": False}
|
||||||
"running": False,
|
|
||||||
"contract": "",
|
|
||||||
"contract_done": 0,
|
|
||||||
"contract_total": 0,
|
|
||||||
"date_done": 0,
|
|
||||||
"date_total": 0,
|
|
||||||
"rows": 0,
|
|
||||||
"finished": False,
|
|
||||||
}
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_sync_progress() -> dict:
|
def get_progress() -> dict:
|
||||||
with _lock:
|
with _lock:
|
||||||
return dict(_sync_progress)
|
return dict(_progress)
|
||||||
|
|
||||||
|
|
||||||
def start_sync_positions_background():
|
def _start_bg(target, label):
|
||||||
"""Start sync_position_rankings in a background thread. Returns immediately."""
|
|
||||||
with _lock:
|
with _lock:
|
||||||
if _sync_progress["running"]:
|
if _progress["running"]:
|
||||||
return False
|
return False
|
||||||
_sync_progress.update({
|
_progress.update(running=True, finished=False, label=label, done=0, total=0)
|
||||||
"running": True,
|
|
||||||
"finished": False,
|
|
||||||
"contract": "",
|
|
||||||
"contract_done": 0,
|
|
||||||
"contract_total": 0,
|
|
||||||
"date_done": 0,
|
|
||||||
"date_total": 0,
|
|
||||||
"rows": 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
try:
|
try:
|
||||||
_do_sync_with_progress()
|
target()
|
||||||
finally:
|
finally:
|
||||||
with _lock:
|
with _lock:
|
||||||
_sync_progress["running"] = False
|
_progress["running"] = False
|
||||||
_sync_progress["finished"] = True
|
_progress["finished"] = True
|
||||||
|
_progress["done"] = _progress["total"]
|
||||||
|
|
||||||
threading.Thread(target=_run, daemon=True).start()
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _do_sync_with_progress():
|
def sync_contract_bars_bg(contract_code: str) -> bool:
|
||||||
from app.models import PositionRanking
|
"""Sync bars for one contract in background. Returns True if started."""
|
||||||
|
code = contract_code.upper()
|
||||||
|
|
||||||
db = SessionLocal()
|
def _run():
|
||||||
try:
|
db = SessionLocal()
|
||||||
active_contracts = (
|
try:
|
||||||
db.query(Contract).filter(Contract.is_active == True).all()
|
latest = (
|
||||||
)
|
db.query(DailyBar.date)
|
||||||
with _lock:
|
.filter(DailyBar.contract == code)
|
||||||
_sync_progress["contract_total"] = len(active_contracts)
|
.order_by(DailyBar.date.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
start_date = latest[0].isoformat() if latest else None
|
||||||
|
bars = fetch_contract_bars(code, start_date)
|
||||||
|
|
||||||
for idx, c in enumerate(active_contracts):
|
|
||||||
with _lock:
|
with _lock:
|
||||||
_sync_progress.update({
|
_progress["total"] = len(bars)
|
||||||
"contract": c.code,
|
|
||||||
"contract_done": idx,
|
|
||||||
"date_done": 0,
|
|
||||||
"date_total": 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
code = c.code.upper()
|
inserted = 0
|
||||||
|
min_date = None
|
||||||
|
for i, bar in enumerate(bars):
|
||||||
|
existing = (
|
||||||
|
db.query(DailyBar)
|
||||||
|
.filter(DailyBar.contract == code, DailyBar.date == bar["date"])
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
db.add(DailyBar(
|
||||||
|
contract=code, date=bar["date"],
|
||||||
|
open=bar["open"], close=bar["close"],
|
||||||
|
high=bar["high"], low=bar["low"],
|
||||||
|
))
|
||||||
|
inserted += 1
|
||||||
|
if min_date is None or bar["date"] < min_date:
|
||||||
|
min_date = bar["date"]
|
||||||
|
with _lock:
|
||||||
|
_progress["done"] = i + 1
|
||||||
|
|
||||||
|
if inserted > 0:
|
||||||
|
db.flush()
|
||||||
|
_recompute_amp(db, code, from_date=min_date)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return _start_bg(_run, f"同步行情 {code}")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_positions_bg(contract_code: str) -> bool:
|
||||||
|
"""Sync position rankings for one contract in background. Returns True if started."""
|
||||||
|
from app.models import PositionRanking
|
||||||
|
code = contract_code.upper()
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
existing_dates = {
|
existing_dates = {
|
||||||
r[0] for r in
|
r[0] for r in
|
||||||
db.query(PositionRanking.date)
|
db.query(PositionRanking.date)
|
||||||
.filter(PositionRanking.contract_code == code)
|
.filter(PositionRanking.contract_code == code)
|
||||||
.distinct()
|
.distinct().all()
|
||||||
.all()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bar_dates = [
|
bar_dates = [
|
||||||
r[0] for r in
|
r[0] for r in
|
||||||
db.query(DailyBar.date)
|
db.query(DailyBar.date)
|
||||||
.filter(DailyBar.contract == code)
|
.filter(DailyBar.contract == code)
|
||||||
.order_by(DailyBar.date)
|
.order_by(DailyBar.date).all()
|
||||||
.all()
|
|
||||||
]
|
]
|
||||||
|
|
||||||
missing = [d for d in bar_dates if d not in existing_dates]
|
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):
|
with _lock:
|
||||||
date_str = d.strftime("%Y%m%d")
|
_progress["total"] = len(missing)
|
||||||
rankings = fetch_position_rankings(code, date_str)
|
|
||||||
inserted = 0
|
inserted = 0
|
||||||
|
for i, d in enumerate(missing):
|
||||||
|
rankings = fetch_position_rankings(code, d.strftime("%Y%m%d"))
|
||||||
for r in rankings:
|
for r in rankings:
|
||||||
db.add(PositionRanking(
|
db.add(PositionRanking(
|
||||||
contract_code=code,
|
contract_code=code, institution=r["institution"],
|
||||||
institution=r["institution"],
|
data_type=r["data_type"], date=d,
|
||||||
data_type=r["data_type"],
|
rank=r["rank"], value=r["value"], change=r["change"],
|
||||||
date=d,
|
|
||||||
rank=r["rank"],
|
|
||||||
value=r["value"],
|
|
||||||
change=r["change"],
|
|
||||||
))
|
))
|
||||||
inserted += 1
|
inserted += 1
|
||||||
db.flush()
|
db.flush()
|
||||||
with _lock:
|
with _lock:
|
||||||
_sync_progress["date_done"] = date_idx + 1
|
_progress["done"] = i + 1
|
||||||
_sync_progress["rows"] += inserted
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
with _lock:
|
finally:
|
||||||
_sync_progress["contract_done"] = len(active_contracts)
|
db.close()
|
||||||
finally:
|
|
||||||
db.close()
|
return _start_bg(_run, f"同步持仓 {code}")
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
|
|||||||
+12
-17
@@ -4,8 +4,7 @@ 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 (
|
from app.collector import (
|
||||||
sync_active_contracts, sync_one_contract, sync_position_rankings,
|
sync_one_contract, sync_contract_bars_bg, sync_positions_bg, get_progress,
|
||||||
start_sync_positions_background, get_sync_progress,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
@@ -134,31 +133,27 @@ def delete_product(product_id: int, db: Session = Depends(get_db)):
|
|||||||
return RedirectResponse("/admin/?tab=product", status_code=303)
|
return RedirectResponse("/admin/?tab=product", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync")
|
|
||||||
def sync_all(request: Request):
|
|
||||||
results = sync_active_contracts()
|
|
||||||
total = sum(results.values())
|
|
||||||
print(f"[sync] Synced {total} bars across {len(results)} contracts: {results}")
|
|
||||||
return RedirectResponse(f"/admin/?tab=sync&synced={total}", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync/{contract_code}")
|
@router.post("/sync/{contract_code}")
|
||||||
def sync_single(contract_code: str):
|
def sync_single(contract_code: str):
|
||||||
count = sync_one_contract(contract_code.upper())
|
count = sync_one_contract(contract_code.upper())
|
||||||
return RedirectResponse(f"/admin/?tab=sync&synced={count}", status_code=303)
|
return RedirectResponse(f"/admin/?tab=sync&synced={count}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync-positions")
|
@router.post("/sync/{contract_code}/bg")
|
||||||
def sync_positions(request: Request):
|
def sync_single_bg(contract_code: str):
|
||||||
started = start_sync_positions_background()
|
ok = sync_contract_bars_bg(contract_code.upper())
|
||||||
if not started:
|
return JSONResponse({"ok": ok})
|
||||||
return JSONResponse({"error": "同步正在进行中,请等待完成"})
|
|
||||||
return JSONResponse({"started": True})
|
|
||||||
|
@router.post("/sync-positions/{contract_code}/bg")
|
||||||
|
def sync_positions_bg_ep(contract_code: str):
|
||||||
|
ok = sync_positions_bg(contract_code.upper())
|
||||||
|
return JSONResponse({"ok": ok})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sync-status")
|
@router.get("/sync-status")
|
||||||
def sync_status():
|
def sync_status():
|
||||||
return JSONResponse(get_sync_progress())
|
return JSONResponse(get_progress())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync/product/{product_id}")
|
@router.post("/sync/product/{product_id}")
|
||||||
|
|||||||
@@ -29,26 +29,17 @@
|
|||||||
|
|
||||||
{# ═══════════════════ Tab: 数据同步 ═══════════════════ #}
|
{# ═══════════════════ Tab: 数据同步 ═══════════════════ #}
|
||||||
<div class="tab-panel{% if tab == 'sync' %} active{% endif %}" id="tab-sync">
|
<div class="tab-panel{% if tab == 'sync' %} active{% endif %}" id="tab-sync">
|
||||||
<div style="display:flex;align-items:center;gap:12px;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:12px;margin-bottom:14px;">
|
||||||
<span style="font-size:0.85rem;color:var(--sub);">全部合约</span>
|
<span style="font-size:0.78rem;color:var(--sub);">共 {{ total_contracts }} 个合约</span>
|
||||||
<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>
|
|
||||||
</form>
|
|
||||||
<button id="btn-sync-pos" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;background:var(--success);" onclick="startPosSync()">同步持仓</button>
|
|
||||||
{% if request.query_params.get('synced') %}
|
|
||||||
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 行情 {{ request.query_params.synced }} 条</span>
|
|
||||||
{% endif %}
|
|
||||||
<span id="sync-pos-msg" style="font-size:0.82rem;color:var(--sub);"></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 id="sync-progress" style="display:none;margin-bottom:18px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:8px;">
|
||||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
|
||||||
<span id="pos-progress-label" style="font-size:0.82rem;color:var(--fg);">正在同步...</span>
|
<span id="sync-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>
|
<span id="sync-progress-pct" style="font-size:0.8rem;color:var(--sub);margin-left:auto;"></span>
|
||||||
</div>
|
</div>
|
||||||
<div style="width:100%;height:6px;background:var(--border);border-radius:3px;overflow:hidden;">
|
<div style="width:100%;height:5px;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 id="sync-progress-bar" style="width:0%;height:100%;background:var(--accent);border-radius:3px;transition:width .3s;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,7 +54,7 @@
|
|||||||
<span style="font-size:0.78rem;color:var(--sub);">{{ p.exchange }}</span>
|
<span style="font-size:0.78rem;color:var(--sub);">{{ p.exchange }}</span>
|
||||||
<span style="font-size:0.78rem;color:var(--sub);margin-left:8px;">{{ p.contracts|length }} 合约</span>
|
<span style="font-size:0.78rem;color:var(--sub);margin-left:8px;">{{ p.contracts|length }} 合约</span>
|
||||||
<form method="post" action="/admin/sync/product/{{ p.id }}" style="display:inline;margin-left:auto;" onclick="event.stopPropagation()">
|
<form method="post" action="/admin/sync/product/{{ p.id }}" style="display:inline;margin-left:auto;" onclick="event.stopPropagation()">
|
||||||
<button type="submit" class="btn" style="font-size:0.78rem;padding:4px 14px;background:var(--accent);color:#fff;border-radius:5px;">同步该品种</button>
|
<button type="submit" class="btn" style="font-size:0.78rem;padding:4px 14px;background:var(--accent);color:#fff;border-radius:5px;">同步行情</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="product-body" style="display:none;">
|
<div class="product-body" style="display:none;">
|
||||||
@@ -78,9 +69,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td style="text-align:center;padding:6px 14px;">
|
<td style="text-align:center;padding:6px 14px;">
|
||||||
<a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;font-size:0.82rem;">查看</a>
|
<a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;font-size:0.82rem;">查看</a>
|
||||||
<form method="post" action="/admin/sync/{{ c.code }}" style="display:inline;margin-left:8px;">
|
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.82rem;margin-left:4px;" onclick="syncBars('{{ c.code }}')">↻ 行情</button>
|
||||||
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.82rem;">↻ 同步</button>
|
<button style="background:none;border:none;color:var(--success);cursor:pointer;font-size:0.82rem;" onclick="syncPositions('{{ c.code }}')">↻ 持仓</button>
|
||||||
</form>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -202,64 +192,53 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var posSyncTimer = null;
|
var syncTimer = null;
|
||||||
|
|
||||||
function startPosSync() {
|
function showProgress() {
|
||||||
var btn = document.getElementById('btn-sync-pos');
|
document.getElementById('sync-progress').style.display = 'block';
|
||||||
btn.disabled = true;
|
document.getElementById('sync-progress-bar').style.width = '0%';
|
||||||
btn.textContent = '发起中...';
|
document.getElementById('sync-progress-pct').textContent = '';
|
||||||
document.getElementById('sync-pos-msg').textContent = '';
|
document.getElementById('sync-progress-label').textContent = '正在同步...';
|
||||||
|
}
|
||||||
|
|
||||||
fetch('/admin/sync-positions', { method: 'POST' })
|
function hideProgress() {
|
||||||
.then(function(r) { return r.json(); })
|
clearInterval(syncTimer);
|
||||||
.then(function(data) {
|
document.getElementById('sync-progress').style.display = 'none';
|
||||||
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() {
|
function pollProgress() {
|
||||||
fetch('/admin/sync-status')
|
fetch('/admin/sync-status')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
var done = data.contract_done;
|
var pct = data.total > 0 ? Math.round(data.done / data.total * 100) : 0;
|
||||||
var total = data.contract_total;
|
document.getElementById('sync-progress-bar').style.width = pct + '%';
|
||||||
var dDone = data.date_done;
|
document.getElementById('sync-progress-pct').textContent = pct + '%';
|
||||||
var dTotal = data.date_total;
|
document.getElementById('sync-progress-label').textContent = data.label;
|
||||||
var pct = 0;
|
|
||||||
|
|
||||||
if (data.finished) {
|
if (data.finished) {
|
||||||
pct = 100;
|
clearInterval(syncTimer);
|
||||||
clearInterval(posSyncTimer);
|
document.getElementById('sync-progress-bar').style.width = '100%';
|
||||||
document.getElementById('pos-progress-pct').textContent = '100%';
|
document.getElementById('sync-progress-pct').textContent = '100%';
|
||||||
document.getElementById('pos-progress-label').textContent =
|
document.getElementById('sync-progress-label').textContent = data.label + ' 完成';
|
||||||
'完成:' + total + ' 个合约,共同步 ' + data.rows + ' 条持仓数据';
|
document.getElementById('sync-progress-pct').textContent = '✓';
|
||||||
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 startSync(url) {
|
||||||
|
if (syncTimer) { clearInterval(syncTimer); }
|
||||||
|
showProgress();
|
||||||
|
fetch(url, { method: 'POST' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.ok) { hideProgress(); return; }
|
||||||
|
syncTimer = setInterval(pollProgress, 600);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBars(code) { startSync('/admin/sync/' + code + '/bg'); }
|
||||||
|
function syncPositions(code) { startSync('/admin/sync-positions/' + code + '/bg'); }
|
||||||
|
|
||||||
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'); });
|
||||||
|
|||||||
Reference in New Issue
Block a user