持仓同步改为后台执行,添加进度条实时反馈
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,124 @@
|
||||
"""Data collector — fetch OHLCV from akshare and upsert into daily_bars."""
|
||||
import threading
|
||||
from datetime import date
|
||||
from app.database import SessionLocal
|
||||
from app.models import DailyBar, Contract
|
||||
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]:
|
||||
"""Fetch daily OHLCV for a single contract from akshare.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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 app.database import get_db
|
||||
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"])
|
||||
|
||||
@@ -147,10 +150,15 @@ def sync_single(contract_code: str):
|
||||
|
||||
@router.post("/sync-positions")
|
||||
def sync_positions(request: Request):
|
||||
results = sync_position_rankings()
|
||||
total = sum(results.values())
|
||||
print(f"[sync] Position rankings: {total} rows across {len(results)} contracts")
|
||||
return RedirectResponse(f"/admin/?tab=sync&pos_synced={total}", status_code=303)
|
||||
started = start_sync_positions_background()
|
||||
if not started:
|
||||
return JSONResponse({"error": "同步正在进行中,请等待完成"})
|
||||
return JSONResponse({"started": True})
|
||||
|
||||
|
||||
@router.get("/sync-status")
|
||||
def sync_status():
|
||||
return JSONResponse(get_sync_progress())
|
||||
|
||||
|
||||
@router.post("/sync/product/{product_id}")
|
||||
|
||||
@@ -34,18 +34,24 @@
|
||||
<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>
|
||||
<form method="post" action="/admin/sync-positions" style="display:inline;">
|
||||
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;background:var(--success);">同步持仓</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 %}
|
||||
{% if request.query_params.get('pos_synced') %}
|
||||
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 持仓 {{ request.query_params.pos_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 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 %}
|
||||
{% for p in products %}
|
||||
<div style="margin-bottom:16px;border:1px solid var(--border);border-radius:10px;overflow:hidden;">
|
||||
@@ -196,6 +202,64 @@
|
||||
</div>
|
||||
|
||||
<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) {
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
|
||||
|
||||
Reference in New Issue
Block a user