diff --git a/ft-app/app/main.py b/ft-app/app/main.py index b88e732..920c843 100644 --- a/ft-app/app/main.py +++ b/ft-app/app/main.py @@ -7,7 +7,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.database import engine, Base, SessionLocal from app.models import User from app.seed import seed -from app.routers import contracts, admin, auth, positions, trades, option_trades +from app.routers import contracts, admin, auth, positions, trades, option_trades, dual_options TEMPLATES_DIR = Path(__file__).parent / "templates" @@ -59,6 +59,7 @@ app.include_router(admin.router) app.include_router(positions.router) app.include_router(trades.router) app.include_router(option_trades.router) +app.include_router(dual_options.router) @app.get("/") diff --git a/ft-app/app/models.py b/ft-app/app/models.py index 92b0492..3808733 100644 --- a/ft-app/app/models.py +++ b/ft-app/app/models.py @@ -179,4 +179,54 @@ class OptionTrade(Base): result = (self.open_price - self.close_price) * mul - (self.open_fee or 0) - (self.close_fee or 0) else: result = (self.close_price - self.open_price) * mul - (self.open_fee or 0) - (self.close_fee or 0) - return round(result, 2) \ No newline at end of file + return round(result, 2) + + +class DualOptionTrade(Base): + __tablename__ = "dual_option_trades" + + id: Mapped[int] = mapped_column(primary_key=True) + product_code: Mapped[str] = mapped_column(String(10)) + contract_code: Mapped[str] = mapped_column(String(10), index=True) + strike_price: Mapped[float] = mapped_column(Float) + call_open_price: Mapped[float] = mapped_column(Float) + put_open_price: Mapped[float] = mapped_column(Float) + call_open_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0) + put_open_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0) + open_date: Mapped[date] = mapped_column(Date) + point_value: Mapped[int] = mapped_column(Integer, default=20) + close_date: Mapped[date | None] = mapped_column(Date, nullable=True) + call_close_price: Mapped[float | None] = mapped_column(Float, nullable=True) + put_close_price: Mapped[float | None] = mapped_column(Float, nullable=True) + call_close_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0) + put_close_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0) + status: Mapped[str] = mapped_column(String(10), default="open") + + @property + def pnl(self) -> float | None: + if self.call_close_price is None or self.put_close_price is None: + return None + mul = self.point_value + call_pnl = (self.call_close_price - self.call_open_price) * mul - (self.call_open_fee or 0) - (self.call_close_fee or 0) + put_pnl = (self.put_close_price - self.put_open_price) * mul - (self.put_open_fee or 0) - (self.put_close_fee or 0) + return round(call_pnl + put_pnl, 2) + + @property + def total_premium(self) -> float: + return self.call_open_price + self.put_open_price + + @property + def total_open_fee(self) -> float: + return (self.call_open_fee or 0) + (self.put_open_fee or 0) + + @property + def total_cost(self) -> float: + return self.total_premium * self.point_value + self.total_open_fee + + @property + def breakeven_upper(self) -> float: + return self.strike_price + self.total_premium + + @property + def breakeven_lower(self) -> float: + return self.strike_price - self.total_premium \ No newline at end of file diff --git a/ft-app/app/routers/dual_options.py b/ft-app/app/routers/dual_options.py new file mode 100644 index 0000000..073b4ee --- /dev/null +++ b/ft-app/app/routers/dual_options.py @@ -0,0 +1,163 @@ +from datetime import date +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session +from app.database import get_db +from app.models import DualOptionTrade, Contract, Product + +router = APIRouter(prefix="/dual-options", tags=["dual_options"]) + + +def get_product_contracts(db: Session) -> dict: + result: dict[str, list[str]] = {} + products = db.query(Product).order_by(Product.code).all() + for p in products: + codes = [c.code for c in p.contracts if c.is_active] + if codes: + result[p.code] = codes + return result + + +def today_str() -> str: + return date.today().isoformat() + + +@router.get("/", response_class=HTMLResponse) +def dual_option_page(request: Request, db: Session = Depends(get_db)): + view = request.query_params.get("view", "") + open_trades = ( + db.query(DualOptionTrade).filter(DualOptionTrade.status == "open") + .order_by(DualOptionTrade.open_date.desc()).all() + ) + closed_trades = ( + db.query(DualOptionTrade).filter(DualOptionTrade.status == "closed") + .order_by(DualOptionTrade.close_date.desc()).limit(50).all() + ) + + template = request.app.state.templates.get_template("dual_options.html") + return HTMLResponse( + template.render( + request=request, + active_nav="dual_options", + product_contracts=get_product_contracts(db), + open_trades=open_trades, + closed_trades=closed_trades, + today=today_str(), + weekdays=["周一","周二","周三","周四","周五","周六","周日"], + ) + ) + + +@router.post("/open") +def open_trade( + request: Request, + contract_code: str = Form(...), + strike_price: float = Form(...), + call_open_price: float = Form(...), + put_open_price: float = Form(...), + call_open_fee: float = Form(0.0), + put_open_fee: float = Form(0.0), + open_date: str = Form(...), + db: Session = Depends(get_db), +): + code = contract_code.upper() + contract = db.query(Contract).filter(Contract.code == code).first() + if contract: + product = contract.product + else: + product = db.query(Product).filter(Product.code == code[:2]).first() + product_code = product.code if product else code[:2] + point_value = product.point_value if product else 20 + + t = DualOptionTrade( + product_code=product_code, + contract_code=code, + strike_price=strike_price, + call_open_price=call_open_price, + put_open_price=put_open_price, + call_open_fee=call_open_fee, + put_open_fee=put_open_fee, + open_date=date.fromisoformat(open_date), + point_value=point_value, + status="open", + ) + db.add(t) + db.commit() + return RedirectResponse("/dual-options/", status_code=303) + + +@router.post("/{trade_id}/close") +def close_trade( + request: Request, + trade_id: int, + close_date: str = Form(...), + call_close_price: float = Form(...), + put_close_price: float = Form(...), + call_close_fee: float = Form(0.0), + put_close_fee: float = Form(0.0), + db: Session = Depends(get_db), +): + t = db.query(DualOptionTrade).filter(DualOptionTrade.id == trade_id).first() + if t and t.status == "open": + t.close_date = date.fromisoformat(close_date) + t.call_close_price = call_close_price + t.put_close_price = put_close_price + t.call_close_fee = call_close_fee + t.put_close_fee = put_close_fee + t.status = "closed" + db.commit() + return RedirectResponse("/dual-options/", status_code=303) + + +@router.post("/{trade_id}/edit") +def edit_trade( + request: Request, + trade_id: int, + contract_code: str = Form(...), + strike_price: float = Form(...), + call_open_price: float = Form(...), + put_open_price: float = Form(...), + call_open_fee: float = Form(0.0), + put_open_fee: float = Form(0.0), + open_date: str = Form(...), + close_date: str = Form(""), + call_close_price: str = Form(""), + put_close_price: str = Form(""), + call_close_fee: str = Form(""), + put_close_fee: str = Form(""), + db: Session = Depends(get_db), +): + t = db.query(DualOptionTrade).filter(DualOptionTrade.id == trade_id).first() + if t: + code = contract_code.upper() + contract = db.query(Contract).filter(Contract.code == code).first() + if contract: + product = contract.product + else: + product = db.query(Product).filter(Product.code == code[:2]).first() + t.product_code = product.code if product else code[:2] + t.point_value = product.point_value if product else 20 + t.contract_code = code + t.strike_price = strike_price + t.call_open_price = call_open_price + t.put_open_price = put_open_price + t.call_open_fee = call_open_fee + t.put_open_fee = put_open_fee + t.open_date = date.fromisoformat(open_date) + if close_date and call_close_price and put_close_price: + t.close_date = date.fromisoformat(close_date) + t.call_close_price = float(call_close_price) + t.put_close_price = float(put_close_price) + t.call_close_fee = float(call_close_fee) if call_close_fee else 0.0 + t.put_close_fee = float(put_close_fee) if put_close_fee else 0.0 + db.commit() + return RedirectResponse("/dual-options/", status_code=303) + + +@router.post("/{trade_id}/delete") +def delete_trade(trade_id: int, db: Session = Depends(get_db)): + t = db.query(DualOptionTrade).filter(DualOptionTrade.id == trade_id).first() + if t: + db.delete(t) + db.commit() + return RedirectResponse("/dual-options/", status_code=303) diff --git a/ft-app/app/templates/base.html b/ft-app/app/templates/base.html index 60c0ea7..401c481 100644 --- a/ft-app/app/templates/base.html +++ b/ft-app/app/templates/base.html @@ -212,6 +212,9 @@ 📊 期权交易 + + 🎯 期权双买 + ⚙️ 系统管理 diff --git a/ft-app/app/templates/dual_options.html b/ft-app/app/templates/dual_options.html new file mode 100644 index 0000000..f6c8404 --- /dev/null +++ b/ft-app/app/templates/dual_options.html @@ -0,0 +1,419 @@ +{% extends "base.html" %} +{% block title %}期权双买交易{% endblock %} +{% block heading %}期权双买交易{% endblock %} +{% block breadcrumb %}跨式策略{% endblock %} +{% block content %} + +{% set view = request.query_params.get('view', '') %} + + + +
+ 📋 持仓 + 📊 已平仓 +
+ +{% if view != 'closed' %} +
+ +{# ── 新建双买开仓 ── #} +
新建双买开仓(同时买入看涨 + 看跌)
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +{# ── 持仓列表 ── #} +
持仓中 · {{ open_trades|length }} 笔
+ +{% if open_trades %} +
+ + + {% for t in open_trades %} + + + + + + + + + + + + + {% endfor %} +
品种合约行权价开仓日期看涨开仓价看跌开仓价C手续费P手续费总成本操作
{{ t.product_code }}{{ t.contract_code.replace(t.product_code, '', 1) }}{{ t.strike_price }}{{ t.open_date }} {{ weekdays[t.open_date.weekday()] }}{{ t.call_open_price }}{{ t.put_open_price }}{{ t.call_open_fee or 0 }}{{ t.put_open_fee or 0 }}{{ "%.0f"|format(t.total_cost) }} + + +
+ +
+
+
+{% else %} +
暂无持仓
+{% endif %} + +{# ── 平仓弹窗 ── #} + + + +
+{% else %} +
+{# ═══════════════ 已平仓 ═══════════════ #} + +{% if closed_trades %} +
已平仓 · {{ closed_trades|length }} 笔
+
+ + + {% for t in closed_trades %} + + + + + + + + + + + + + + {% endfor %} +
品种合约行权价开仓日平仓日持仓看涨(开/平)看跌(开/平)总成本盈亏操作
{{ t.product_code }}{{ t.contract_code.replace(t.product_code, '', 1) }}{{ t.strike_price }}{{ t.open_date }} {{ weekdays[t.open_date.weekday()] }}{{ t.close_date }} {{ weekdays[t.close_date.weekday()] }}{{ (t.close_date - t.open_date).days }}天{{ t.call_open_price }} / {{ t.call_close_price }}{{ t.put_open_price }} / {{ t.put_close_price }}{{ "%.0f"|format(t.total_cost) }} + {% set p = t.pnl %} + {% if p is not none %} + + {% if p > 0 %}+{% endif %}{{ p }} + + + {% endif %} + + +
+ +
+
+
+ +{% set ns = namespace(total_pnl=0, total_open_fee=0, total_close_fee=0) %} +{% for t in closed_trades %} + {% set ns.total_pnl = ns.total_pnl + (t.pnl or 0) %} + {% set ns.total_open_fee = ns.total_open_fee + (t.call_open_fee or 0) + (t.put_open_fee or 0) %} + {% set ns.total_close_fee = ns.total_close_fee + (t.call_close_fee or 0) + (t.put_close_fee or 0) %} +{% endfor %} +
+ 盈亏合计 {% if ns.total_pnl > 0 %}+{% endif %}{{ '%.2f'|format(ns.total_pnl) }} + | + 开仓手续费 {{ '%.2f'|format(ns.total_open_fee) }} + | + 平仓手续费 {{ '%.2f'|format(ns.total_close_fee) }} + | + 手续费合计 {{ '%.2f'|format(ns.total_open_fee + ns.total_close_fee) }} +
+{% else %} +
暂无已平仓记录
+{% endif %} + +
+{% endif %} + +{# ── 盈亏分析抽屉 ── #} + + + +{% if view != 'closed' %} + +{% endif %} + +{# ── 编辑弹窗 ── #} + + + +{# ── 通用确认弹窗 ── #} + + + + + +{% endblock %} diff --git a/ft-app/app/templates/options.html b/ft-app/app/templates/options.html index d9e03e0..5c9d064 100644 --- a/ft-app/app/templates/options.html +++ b/ft-app/app/templates/options.html @@ -69,7 +69,7 @@
- +
@@ -91,7 +91,7 @@ {% if open_trades %}
- + {% for t in open_trades %} @@ -164,7 +164,7 @@
已平仓 · {{ closed_trades|length }} 笔
品种合约类型买卖行权价权利金开仓日期手续费操作
品种合约类型买卖行权价开仓价开仓日期手续费操作
{{ t.product_code }}
- + {% for t in closed_trades %} @@ -295,7 +295,7 @@ function hideCloseForm() {
- +
品种合约类型买卖行权价开仓平仓持仓权利金平仓价开仓费平仓费盈亏操作
品种合约类型买卖行权价开仓平仓持仓开仓价平仓价开仓费平仓费盈亏操作
{{ t.product_code }}