修复数据采集和清理的bug,新增持仓排名功能,日线数据分页
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+118
-4
@@ -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]])
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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> ÷ 5 = <b>' + (sum / 5).toFixed(1) + '</b><br>四舍五入 → <b>' + Math.round(sum / 5) + '</b>';
|
document.getElementById('drawer-result').innerHTML = '合计 <b>' + sum + '</b> ÷ 5 = <b>' + (sum / 5).toFixed(1) + '</b><br>四舍五入 → <b>' + Math.round(sum / 5) + '</b>';
|
||||||
|
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user