c291f331fc
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
215 lines
6.7 KiB
Python
215 lines
6.7 KiB
Python
import math
|
|
from datetime import date, timedelta
|
|
from fastapi import APIRouter, Depends, Request, Query
|
|
from fastapi.responses import HTMLResponse, PlainTextResponse
|
|
from sqlalchemy.orm import Session
|
|
from app.database import get_db
|
|
from app.models import DailyBar, Contract, PositionRanking
|
|
from app.prompt_builder import build_prompt
|
|
|
|
router = APIRouter(prefix="/contracts", tags=["contracts"])
|
|
|
|
WEEKDAY_ZH = {0: "周一", 1: "周二", 2: "周三", 3: "周四", 4: "周五", 5: "周六", 6: "周日"}
|
|
PAGE_SIZE = 7
|
|
|
|
|
|
def get_active_contracts(db: Session) -> list[str]:
|
|
contracts = (
|
|
db.query(Contract.code)
|
|
.filter(Contract.is_active == True)
|
|
.order_by(Contract.code)
|
|
.all()
|
|
)
|
|
return [c[0] for c in contracts]
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
def contract_index(request: Request, db: Session = Depends(get_db)):
|
|
from app.models import Product
|
|
|
|
# Build product → contracts mapping, ordered by product code
|
|
products = (
|
|
db.query(Product)
|
|
.order_by(Product.code)
|
|
.all()
|
|
)
|
|
|
|
product_groups = []
|
|
all_codes = []
|
|
for prod in products:
|
|
codes = [
|
|
c.code for c in prod.contracts if c.is_active
|
|
]
|
|
if codes:
|
|
product_groups.append({
|
|
"code": prod.code,
|
|
"name": prod.name,
|
|
"exchange": prod.exchange,
|
|
"contracts": codes,
|
|
})
|
|
all_codes.extend(codes)
|
|
|
|
latest = (
|
|
db.query(DailyBar)
|
|
.filter(DailyBar.contract.in_(all_codes))
|
|
.order_by(DailyBar.date.desc())
|
|
.all()
|
|
)
|
|
contract_bars = {}
|
|
for bar in latest:
|
|
if bar.contract not in contract_bars:
|
|
contract_bars[bar.contract] = bar
|
|
|
|
# Next-day amplitude: mean of latest 5 diffs per contract
|
|
contract_next_amp = {}
|
|
for code in all_codes:
|
|
diffs = [
|
|
r[0] for r in
|
|
db.query(DailyBar.high - DailyBar.low)
|
|
.filter(DailyBar.contract == code)
|
|
.order_by(DailyBar.date.desc())
|
|
.limit(5)
|
|
.all()
|
|
]
|
|
if len(diffs) >= 5:
|
|
contract_next_amp[code] = round(sum(diffs) / 5)
|
|
|
|
template = request.app.state.templates.get_template("index.html")
|
|
return HTMLResponse(
|
|
template.render(
|
|
request=request,
|
|
active_nav="contracts",
|
|
product_groups=product_groups,
|
|
contract_bars=contract_bars,
|
|
contract_next_amp=contract_next_amp,
|
|
)
|
|
)
|
|
|
|
|
|
@router.get("/{contract}", response_class=HTMLResponse)
|
|
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)
|
|
bars = (
|
|
db.query(DailyBar)
|
|
.filter(DailyBar.contract == contract.upper())
|
|
.order_by(DailyBar.date.desc())
|
|
.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 = []
|
|
for i, bar in enumerate(page_bars):
|
|
global_idx = start + i
|
|
rows.append({
|
|
"global_idx": global_idx,
|
|
"date": bar.date.strftime("%Y/%-m/%-d"),
|
|
"weekday": WEEKDAY_ZH.get(bar.date.weekday(), ""),
|
|
"open": int(bar.open) if bar.open else "-",
|
|
"close": int(bar.close) if bar.close else "-",
|
|
"high": int(bar.high) if bar.high else "-",
|
|
"low": int(bar.low) if bar.low else "-",
|
|
"diff": int(bar.diff) if bar.diff else 0,
|
|
"amp_5d": int(bar.amp_5d) if bar.amp_5d is not None else None,
|
|
"has_amp": bar.amp_5d is not 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,
|
|
})
|
|
|
|
pos_totals = {
|
|
"long": sum(r.change for r in rankings if r.data_type == "long"),
|
|
"short": sum(r.change for r in rankings if r.data_type == "short"),
|
|
}
|
|
else:
|
|
pos_totals = {"long": 0, "short": 0}
|
|
|
|
# Predict next trading day amplitude: mean of latest 5 diffs
|
|
# Compute next trading date
|
|
next_date = latest.date + timedelta(days=1) if latest else None
|
|
if next_date and next_date.weekday() >= 5:
|
|
next_date += timedelta(days=7 - next_date.weekday())
|
|
|
|
# Predict next trading day amplitude
|
|
next_amp = None
|
|
if len(bars) >= 5:
|
|
next_amp = round(sum(b.diff for b in bars[:5]) / 5)
|
|
template = request.app.state.templates.get_template("contract.html")
|
|
return HTMLResponse(
|
|
template.render(
|
|
request=request,
|
|
active_nav="contracts",
|
|
contract=contract.upper(),
|
|
contracts=active_contracts,
|
|
rows=rows,
|
|
latest=latest,
|
|
pos_dates=pos_dates,
|
|
selected_pos_date=selected_date,
|
|
pos_data=pos_data,
|
|
pos_totals=pos_totals,
|
|
next_amp=next_amp,
|
|
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 "",
|
|
row_count=len(rows),
|
|
total_rows=total,
|
|
page=page,
|
|
total_pages=total_pages,
|
|
pos_date=pos_date or "",
|
|
WEEKDAY_ZH=WEEKDAY_ZH,
|
|
)
|
|
)
|
|
|
|
|
|
@router.get("/{contract}/analysis-prompt", response_class=PlainTextResponse)
|
|
def contract_analysis_prompt(contract: str):
|
|
"""返回合约的持仓分析 prompt 文本。"""
|
|
return build_prompt(contract.upper())
|