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())