修复数据采集和清理的bug,新增持仓排名功能,日线数据分页

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:01:41 +08:00
parent 5453ce1dcf
commit cd7f6668af
9 changed files with 309 additions and 60 deletions
+118 -4
View File
@@ -9,6 +9,7 @@ def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> li
"""Fetch daily OHLCV for a single contract from akshare. """Fetch daily OHLCV for a single contract from akshare.
Returns list of {date, open, close, high, low} dicts. Returns list of {date, open, close, high, low} dicts.
Only returns bars on or after start_date when provided.
""" """
import akshare as ak import akshare as ak
@@ -22,6 +23,8 @@ def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> li
print(f"[collector] No data returned for {contract_code}") print(f"[collector] No data returned for {contract_code}")
return [] return []
filter_date = date.fromisoformat(start_date) if start_date else None
bars = [] bars = []
for _, row in df.iterrows(): for _, row in df.iterrows():
try: try:
@@ -31,6 +34,8 @@ def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> li
else: else:
d = date.fromisoformat(str(val)[:10]) d = date.fromisoformat(str(val)[:10])
if filter_date and d < filter_date:
continue
bars.append({ bars.append({
"date": d, "date": d,
@@ -93,6 +98,7 @@ def _sync_one(db, contract_code: str) -> int:
return 0 return 0
inserted = 0 inserted = 0
min_date = None
for bar in bars: for bar in bars:
existing = ( existing = (
db.query(DailyBar) db.query(DailyBar)
@@ -112,22 +118,130 @@ def _sync_one(db, contract_code: str) -> int:
low=bar["low"], low=bar["low"],
)) ))
inserted += 1 inserted += 1
if min_date is None or bar["date"] < min_date:
min_date = bar["date"]
if inserted > 0: if inserted > 0:
db.flush() db.flush()
_recompute_amp(db, contract_code) _recompute_amp(db, contract_code, from_date=min_date)
return inserted return inserted
def _recompute_amp(db, contract_code: str): def fetch_position_rankings(contract_code: str, trade_date: str) -> list[dict]:
"""Recompute amp_5d for all bars of a contract.""" """Fetch top-20 position rankings for a contract on a given date from akshare.
Calls the API 3 times (volume, long, short) and returns a unified list of dicts:
{data_type, rank, institution, value, change}
"""
import akshare as ak
results = []
for sym, dtype in [("成交量", "volume"), ("多单持仓", "long"), ("空单持仓", "short")]:
try:
df = ak.futures_hold_pos_sina(symbol=sym, contract=contract_code.upper(), date=trade_date)
except Exception as e:
print(f"[collector] position akshare error for {contract_code} {dtype}: {e}")
continue
if df is None or df.empty:
continue
for _, row in df.iterrows():
try:
results.append({
"data_type": dtype,
"rank": int(row["名次"]),
"institution": str(row["会员简称"]),
"value": int(row.iloc[2]),
"change": int(row["比上交易增减"]),
})
except (KeyError, ValueError, TypeError) as e:
print(f"[collector] position skip row: {e}")
continue
return results
def sync_position_rankings() -> dict:
"""Sync position rankings for all active contracts. Returns {contract_code: new_rows}."""
db = SessionLocal()
results = {}
try:
active_contracts = (
db.query(Contract).filter(Contract.is_active == True).all()
)
for c in active_contracts:
count = _sync_positions_for_contract(db, c.code)
results[c.code] = count
db.commit()
finally:
db.close()
return results
def _sync_positions_for_contract(db, contract_code: str) -> int:
"""Sync position rankings for all dates that have bars but no position data."""
from app.models import PositionRanking
code = contract_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()
]
inserted = 0
for d in bar_dates:
if d in existing_dates:
continue
date_str = d.strftime("%Y%m%d")
rankings = fetch_position_rankings(code, date_str)
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()
return inserted
def _recompute_amp(db, contract_code: str, from_date: date | None = None):
"""Recompute amp_5d for bars of a contract from from_date onwards."""
bars = ( bars = (
db.query(DailyBar) db.query(DailyBar)
.filter(DailyBar.contract == contract_code) .filter(DailyBar.contract == contract_code)
.order_by(DailyBar.date) .order_by(DailyBar.date)
.all() .all()
) )
start_idx = 0
if from_date:
for i, bar in enumerate(bars):
if bar.date >= from_date:
start_idx = i
break
for i, bar in enumerate(bars): for i, bar in enumerate(bars):
if i >= 5: if i >= 5 and i >= start_idx:
bar.amp_5d = compute_amp_5d([b.diff for b in bars[i - 5 : i]]) bar.amp_5d = compute_amp_5d([b.diff for b in bars[i - 5 : i]])
+9 -9
View File
@@ -51,18 +51,18 @@ class DailyBar(Base):
return self.high - self.low return self.high - self.low
class PositionSnapshot(Base): class PositionRanking(Base):
__tablename__ = "position_snapshots" __tablename__ = "position_rankings"
__table_args__ = (UniqueConstraint("contract_code", "institution", "direction", "date"),) __table_args__ = (UniqueConstraint("contract_code", "institution", "data_type", "date"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
contract_code: Mapped[str] = mapped_column(String(10), index=True, default="FG") contract_code: Mapped[str] = mapped_column(String(10), index=True)
institution: Mapped[str] = mapped_column(String(20), index=True) institution: Mapped[str] = mapped_column(String(30), index=True)
direction: Mapped[str] = mapped_column(String(10)) data_type: Mapped[str] = mapped_column(String(10), index=True)
date: Mapped[date] = mapped_column(Date, index=True) date: Mapped[date] = mapped_column(Date, index=True)
position: Mapped[int] = mapped_column(Integer) rank: Mapped[int] = mapped_column(Integer)
delta: Mapped[int] = mapped_column(Integer, default=0) value: Mapped[int] = mapped_column(Integer)
avg_cost: Mapped[float] = mapped_column(Float) change: Mapped[int] = mapped_column(Integer)
class User(Base): class User(Base):
+12 -3
View File
@@ -2,8 +2,8 @@ from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
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, PositionSnapshot from app.models import Product, Contract, DailyBar, PositionRanking
from app.collector import sync_active_contracts, sync_one_contract from app.collector import sync_active_contracts, sync_one_contract, sync_position_rankings
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -113,7 +113,7 @@ def delete_contract(contract_id: int, db: Session = Depends(get_db)):
c = db.query(Contract).filter(Contract.id == contract_id).first() c = db.query(Contract).filter(Contract.id == contract_id).first()
if c: if c:
db.query(DailyBar).filter(DailyBar.contract == c.code).delete() db.query(DailyBar).filter(DailyBar.contract == c.code).delete()
db.query(PositionSnapshot).filter(PositionSnapshot.contract_code == c.code).delete() db.query(PositionRanking).filter(PositionRanking.contract_code == c.code).delete()
db.delete(c) db.delete(c)
db.commit() db.commit()
return RedirectResponse("/admin/?tab=contract", status_code=303) return RedirectResponse("/admin/?tab=contract", status_code=303)
@@ -125,6 +125,7 @@ def delete_product(product_id: int, db: Session = Depends(get_db)):
if p: if p:
for c in p.contracts: for c in p.contracts:
db.query(DailyBar).filter(DailyBar.contract == c.code).delete() db.query(DailyBar).filter(DailyBar.contract == c.code).delete()
db.query(PositionRanking).filter(PositionRanking.contract_code == c.code).delete()
db.delete(p) db.delete(p)
db.commit() db.commit()
return RedirectResponse("/admin/?tab=product", status_code=303) return RedirectResponse("/admin/?tab=product", status_code=303)
@@ -144,6 +145,14 @@ def sync_single(contract_code: str):
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")
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)
@router.post("/sync/product/{product_id}") @router.post("/sync/product/{product_id}")
def sync_product(product_id: int, request: Request, db: Session = Depends(get_db)): def sync_product(product_id: int, request: Request, db: Session = Depends(get_db)):
contracts = db.query(Contract).filter( contracts = db.query(Contract).filter(
-10
View File
@@ -9,16 +9,6 @@ router = APIRouter(prefix="/auth", tags=["auth"])
SESSION_COOKIE = "ft_session" SESSION_COOKIE = "ft_session"
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
user_id = request.cookies.get(SESSION_COOKIE)
if not user_id:
return None
try:
return db.query(User).filter(User.id == int(user_id)).first()
except (ValueError, TypeError):
return None
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
def login_page(request: Request): def login_page(request: Request):
template = request.app.state.templates.get_template("login.html") template = request.app.state.templates.get_template("login.html")
+67 -4
View File
@@ -1,13 +1,15 @@
import math
from datetime import date, timedelta from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
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 DailyBar, Contract from app.models import DailyBar, Contract, PositionRanking
router = APIRouter(prefix="/contracts", tags=["contracts"]) router = APIRouter(prefix="/contracts", tags=["contracts"])
WEEKDAY_ZH = {0: "周一", 1: "周二", 2: "周三", 3: "周四", 4: "周五", 5: "周六", 6: "周日"} WEEKDAY_ZH = {0: "周一", 1: "周二", 2: "周三", 3: "周四", 4: "周五", 5: "周六", 6: "周日"}
PAGE_SIZE = 7
def get_active_contracts(db: Session) -> list[str]: def get_active_contracts(db: Session) -> list[str]:
@@ -47,7 +49,13 @@ def contract_index(request: Request, db: Session = Depends(get_db)):
@router.get("/{contract}", response_class=HTMLResponse) @router.get("/{contract}", response_class=HTMLResponse)
def contract_detail(request: Request, contract: str, db: Session = Depends(get_db)): def contract_detail(
request: Request,
contract: str,
pos_date: str | None = None,
page: int = Query(1, ge=1),
db: Session = Depends(get_db),
):
active_contracts = get_active_contracts(db) active_contracts = get_active_contracts(db)
bars = ( bars = (
db.query(DailyBar) db.query(DailyBar)
@@ -56,9 +64,17 @@ def contract_detail(request: Request, contract: str, db: Session = Depends(get_d
.all() .all()
) )
total = len(bars)
total_pages = max(1, math.ceil(total / PAGE_SIZE))
page = min(page, total_pages)
start = (page - 1) * PAGE_SIZE
page_bars = bars[start:start + PAGE_SIZE]
rows = [] rows = []
for bar in bars: for i, bar in enumerate(page_bars):
global_idx = start + i
rows.append({ rows.append({
"global_idx": global_idx,
"date": bar.date.strftime("%Y/%-m/%-d"), "date": bar.date.strftime("%Y/%-m/%-d"),
"weekday": WEEKDAY_ZH.get(bar.date.weekday(), ""), "weekday": WEEKDAY_ZH.get(bar.date.weekday(), ""),
"open": int(bar.open) if bar.open else "-", "open": int(bar.open) if bar.open else "-",
@@ -72,6 +88,45 @@ def contract_detail(request: Request, contract: str, db: Session = Depends(get_d
latest = bars[0] if bars else None latest = bars[0] if bars else None
# Dates that have position data (for date picker)
pos_dates = [
r[0] for r in
db.query(PositionRanking.date)
.filter(PositionRanking.contract_code == contract.upper())
.distinct()
.order_by(PositionRanking.date.desc())
.all()
]
# Determine which date to show position rankings for
if pos_date:
try:
selected_date = date.fromisoformat(pos_date)
except ValueError:
selected_date = pos_dates[0] if pos_dates else None
else:
selected_date = pos_dates[0] if pos_dates else None
# Position rankings for the selected date
pos_data = {"volume": [], "long": [], "short": []}
if selected_date:
rankings = (
db.query(PositionRanking)
.filter(
PositionRanking.contract_code == contract.upper(),
PositionRanking.date == selected_date,
)
.order_by(PositionRanking.data_type, PositionRanking.rank)
.all()
)
for r in rankings:
pos_data[r.data_type].append({
"rank": r.rank,
"institution": r.institution,
"value": r.value,
"change": r.change,
})
# Predict next trading day amplitude: mean of latest 5 diffs # Predict next trading day amplitude: mean of latest 5 diffs
# Compute next trading date # Compute next trading date
next_date = latest.date + timedelta(days=1) if latest else None next_date = latest.date + timedelta(days=1) if latest else None
@@ -91,9 +146,17 @@ def contract_detail(request: Request, contract: str, db: Session = Depends(get_d
contracts=active_contracts, contracts=active_contracts,
rows=rows, rows=rows,
latest=latest, latest=latest,
pos_dates=pos_dates,
selected_pos_date=selected_date,
pos_data=pos_data,
next_amp=next_amp, next_amp=next_amp,
next_date=next_date.strftime("%Y/%-m/%-d") if next_date else None, next_date=next_date.strftime("%Y/%-m/%-d") if next_date else None,
next_weekday=WEEKDAY_ZH.get(next_date.weekday(), "") if next_date else "", next_weekday=WEEKDAY_ZH.get(next_date.weekday(), "") if next_date else "",
row_count=len(rows), row_count=len(rows),
total_rows=total,
page=page,
total_pages=total_pages,
pos_date=pos_date or "",
WEEKDAY_ZH=WEEKDAY_ZH,
) )
) )
+8 -2
View File
@@ -32,10 +32,16 @@
<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:20px;padding:14px 18px;background:var(--surface);border:1px solid var(--border);border-radius:10px;">
<span style="font-size:0.85rem;color:var(--sub);">全部合约</span> <span style="font-size:0.85rem;color:var(--sub);">全部合约</span>
<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 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> </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 %}
{% if request.query_params.get('pos_synced') %}
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 持仓 {{ request.query_params.pos_synced }} 条</span>
{% endif %} {% 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>
+14
View File
@@ -177,6 +177,18 @@
.amp-cell { cursor: pointer; color: var(--accent); font-weight: 600; } .amp-cell { cursor: pointer; color: var(--accent); font-weight: 600; }
.amp-cell:hover { text-decoration: underline; } .amp-cell:hover { text-decoration: underline; }
input[type="date"]::-webkit-calendar-picker-indicator {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23475669' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
cursor: pointer;
}
[data-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
filter: none;
}
[data-theme="dark"] input[type="date"] {
color-scheme: dark;
}
</style> </style>
</head> </head>
<body> <body>
@@ -224,9 +236,11 @@ themeBtn.addEventListener('click', toggleTheme);
function setTheme(dark) { function setTheme(dark) {
if (dark) { if (dark) {
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.style.colorScheme = 'dark';
themeBtn.textContent = '☀️ 亮色模式'; themeBtn.textContent = '☀️ 亮色模式';
} else { } else {
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
document.documentElement.style.colorScheme = 'light';
themeBtn.textContent = '🌙 暗色模式'; themeBtn.textContent = '🌙 暗色模式';
} }
try { localStorage.setItem('ft-theme', dark ? 'dark' : 'light'); } catch(e) {} try { localStorage.setItem('ft-theme', dark ? 'dark' : 'light'); } catch(e) {}
+79 -28
View File
@@ -10,15 +10,13 @@
<p style="font-size:0.78rem;color:var(--sub);margin:0;"> <p style="font-size:0.78rem;color:var(--sub);margin:0;">
振幅 = 近 5 日 (最高−最低) 均值取整 · 点击振幅值查看计算过程 振幅 = 近 5 日 (最高−最低) 均值取整 · 点击振幅值查看计算过程
</p> </p>
{% if rows|length > 7 %} <span style="font-size:0.78rem;color:var(--sub);">共 {{ total_rows }} 条</span>
<button id="toggle-all" class="btn" style="font-size:0.78rem;padding:4px 14px;background:var(--surface);color:var(--accent);border:1px solid var(--accent);border-radius:5px;cursor:pointer;" onclick="toggleAll()">显示全部 ({{ rows|length }}条)</button>
{% endif %}
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table id="bars-table"> <table id="bars-table">
<tr><th>日期</th><th>星期</th><th>开盘</th><th>收盘</th><th>最高</th><th>最低</th><th>波幅</th><th>5日均振幅</th></tr> <tr><th>日期</th><th>星期</th><th>开盘</th><th>收盘</th><th>最高</th><th>最低</th><th>波幅</th><th>5日均振幅</th></tr>
{% if next_date %} {% if page == 1 and next_date %}
<tr style="background:var(--accent-light);font-weight:500;"> <tr style="background:var(--accent-light);font-weight:500;">
<td>{{ next_date }}</td> <td>{{ next_date }}</td>
<td>{{ next_weekday }}</td> <td>{{ next_weekday }}</td>
@@ -31,7 +29,7 @@
</tr> </tr>
{% endif %} {% endif %}
{% for row in rows %} {% for row in rows %}
<tr data-index="{{ loop.index0 }}" data-diff="{{ row.diff }}" data-date="{{ row.date }}" data-weekday="{{ row.weekday }}"{% if loop.index0 >= 7 %} class="extra-row" style="display:none;"{% endif %}> <tr data-index="{{ row.global_idx }}" data-diff="{{ row.diff }}" data-date="{{ row.date }}" data-weekday="{{ row.weekday }}">
<td>{{ row.date }}</td> <td>{{ row.date }}</td>
<td>{{ row.weekday }}</td> <td>{{ row.weekday }}</td>
<td>{{ row.open }}</td> <td>{{ row.open }}</td>
@@ -51,48 +49,101 @@
</table> </table>
</div> </div>
<script> {% if total_pages > 1 %}
<div style="display:flex;align-items:center;justify-content:center;gap:6px;margin-bottom:24px;">
{% if page > 1 %}
<a class="btn" href="?page={{ page - 1 }}{% if pos_date %}&pos_date={{ pos_date }}{% endif %}" style="font-size:0.82rem;padding:5px 14px;background:var(--surface);color:var(--fg);border:1px solid var(--border);border-radius:5px;text-decoration:none;">← 上一页</a>
{% endif %}
var allShown = false; {% for p in range(1, total_pages + 1) %}
function toggleAll() { {% if p == page %}
allShown = !allShown; <span style="font-size:0.82rem;padding:5px 12px;background:var(--accent);color:#fff;border-radius:5px;font-weight:600;">{{ p }}</span>
var btn = document.getElementById('toggle-all'); {% elif p <= 3 or p > total_pages - 3 or (p >= page - 1 and p <= page + 1) %}
var rows = document.querySelectorAll('.extra-row'); <a class="btn" href="?page={{ p }}{% if pos_date %}&pos_date={{ pos_date }}{% endif %}" style="font-size:0.82rem;padding:5px 12px;background:var(--surface);color:var(--fg);border:1px solid var(--border);border-radius:5px;text-decoration:none;">{{ p }}</a>
rows.forEach(function(r) { r.style.display = allShown ? '' : 'none'; }); {% elif p == 4 or p == total_pages - 3 %}
btn.textContent = allShown ? '收起' : '显示全部 ({{ rows|length }}条)'; <span style="color:var(--sub);padding:5px 4px;"></span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a class="btn" href="?page={{ page + 1 }}{% if pos_date %}&pos_date={{ pos_date }}{% endif %}" style="font-size:0.82rem;padding:5px 14px;background:var(--surface);color:var(--fg);border:1px solid var(--border);border-radius:5px;text-decoration:none;">下一页 →</a>
{% endif %}
</div>
{% endif %}
{% if pos_dates %}
<div class="section-title" style="margin-top:28px;display:flex;align-items:center;gap:12px;">
<span>持仓排名 · 前20</span>
<input type="date" id="pos-date-select" value="{{ selected_pos_date }}" min="{{ pos_dates[-1] }}" max="{{ pos_dates[0] }}" onchange="changePosDate(this.value)" style="font-size:0.82rem;padding:4px 10px;border:1px solid var(--border);border-radius:5px;background:var(--surface);color:var(--fg);cursor:pointer;">
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:24px;">
{% set types = [('volume', '成交量'), ('long', '多单持仓'), ('short', '空单持仓')] %}
{% for dtype, dlabel in types %}
<div class="table-wrap" style="margin-bottom:0;">
<table style="font-size:0.82rem;">
<tr><th colspan="4" style="font-size:0.8rem;color:var(--accent);">{{ dlabel }}</th></tr>
<tr><th>#</th><th>会员</th><th>持仓</th><th>增减</th></tr>
{% for row in pos_data[dtype] %}
<tr>
<td style="color:var(--sub);">{{ row.rank }}</td>
<td style="text-align:left;">{{ row.institution }}</td>
<td>{{ row.value }}</td>
<td style="color:{% if row.change > 0 %}var(--success-fg){% elif row.change < 0 %}var(--danger-fg){% else %}var(--sub){% endif %};">
{% if row.change > 0 %}+{% endif %}{{ row.change }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endfor %}
</div>
{% endif %}
<script>
function changePosDate(d) {
var url = new URL(window.location);
url.searchParams.set('pos_date', d);
window.location = url;
} }
document.querySelectorAll('.amp-cell').forEach(function(cell) { document.querySelectorAll('.amp-cell').forEach(function(cell) {
cell.addEventListener('click', function() { cell.addEventListener('click', function() {
var row = cell.parentElement.parentElement; // Next-day prediction row
var tbl = document.getElementById('bars-table');
var dataRows = tbl.querySelectorAll('tr[data-index]');
// Next-day row: use latest 5 data rows
if (cell.dataset.next) { if (cell.dataset.next) {
var dataRows = document.querySelectorAll('#bars-table tr[data-index]');
if (dataRows.length < 5) return; if (dataRows.length < 5) return;
showDrawer(cell, '{{ contract }}', dataRows, 0, 4); var rows = Array.from(dataRows).slice(0, 5);
showDrawer(cell, '{{ contract }}', rows, true);
return; return;
} }
var idx = parseInt(row.dataset.index); // Collect 5 older rows via DOM navigation (next siblings in the table)
// Need 5 older rows (higher index, since newest first) var row = cell.parentElement.parentElement;
if (idx + 5 >= dataRows.length) return; var olderRows = [];
showDrawer(cell, '{{ contract }}', dataRows, idx + 1, idx + 5); var cursor = row;
for (var j = 0; j < 5; j++) {
cursor = cursor.nextElementSibling;
if (!cursor || !cursor.dataset.index) return;
olderRows.push(cursor);
}
showDrawer(cell, '{{ contract }}', olderRows, false);
}); });
}); });
function showDrawer(cell, product, dataRows, fromIdx, toIdx) { function showDrawer(cell, product, calcRows, isNext) {
document.getElementById('drawer-title').textContent = product; document.getElementById('drawer-title').textContent = product;
var label = cell.dataset.next ? '次日预测' : '目标'; var label = isNext ? '次日预测' : '目标';
document.getElementById('drawer-date').textContent = label + ' 振幅 ' + cell.textContent.trim(); document.getElementById('drawer-date').textContent = label + ' 振幅 ' + cell.textContent.trim();
var html = '<tr><td>日期</td><td>最高−最低</td></tr>'; var html = '<tr><td>日期</td><td>最高−最低</td></tr>';
var sum = 0; var sum = 0;
for (var j = fromIdx; j <= toIdx; j++) { calcRows.forEach(function(r) {
var r = dataRows[j];
html += '<tr><td>' + r.dataset.date + ' ' + r.dataset.weekday + '</td><td>' + r.dataset.diff + '</td></tr>'; html += '<tr><td>' + r.dataset.date + ' ' + r.dataset.weekday + '</td><td>' + r.dataset.diff + '</td></tr>';
sum += parseInt(r.dataset.diff); sum += parseInt(r.dataset.diff);
} });
document.getElementById('drawer-table').innerHTML = html; document.getElementById('drawer-table').innerHTML = html;
document.getElementById('drawer-result').innerHTML = '合计 <b>' + sum + '</b> &divide; 5 = <b>' + (sum / 5).toFixed(1) + '</b><br>四舍五入 &rarr; <b>' + Math.round(sum / 5) + '</b>'; document.getElementById('drawer-result').innerHTML = '合计 <b>' + sum + '</b> &divide; 5 = <b>' + (sum / 5).toFixed(1) + '</b><br>四舍五入 &rarr; <b>' + Math.round(sum / 5) + '</b>';
+2
View File
@@ -81,9 +81,11 @@ themeBtn.addEventListener('click', toggleTheme);
function setTheme(dark) { function setTheme(dark) {
if (dark) { if (dark) {
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.style.colorScheme = 'dark';
themeBtn.textContent = '☀️ 亮色模式'; themeBtn.textContent = '☀️ 亮色模式';
} else { } else {
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
document.documentElement.style.colorScheme = 'light';
themeBtn.textContent = '🌙 暗色模式'; themeBtn.textContent = '🌙 暗色模式';
} }
try { localStorage.setItem('ft-theme', dark ? 'dark' : 'light'); } catch(e) {} try { localStorage.setItem('ft-theme', dark ? 'dark' : 'light'); } catch(e) {}