diff --git a/ft-app/app/main.py b/ft-app/app/main.py
index ee0896a..b88e732 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
+from app.routers import contracts, admin, auth, positions, trades, option_trades
TEMPLATES_DIR = Path(__file__).parent / "templates"
@@ -58,6 +58,7 @@ app.include_router(contracts.router)
app.include_router(admin.router)
app.include_router(positions.router)
app.include_router(trades.router)
+app.include_router(option_trades.router)
@app.get("/")
diff --git a/ft-app/app/models.py b/ft-app/app/models.py
index 683e7e4..7052cfb 100644
--- a/ft-app/app/models.py
+++ b/ft-app/app/models.py
@@ -146,4 +146,29 @@ class Trade(Base):
result = (self.close_price - self.open_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
else:
result = (self.open_price - self.close_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
+ return round(result, 2)
+
+
+class OptionTrade(Base):
+ __tablename__ = "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)
+ option_type: Mapped[str] = mapped_column(String(4))
+ strike_price: Mapped[float] = mapped_column(Float)
+ open_date: Mapped[date] = mapped_column(Date)
+ open_price: Mapped[float] = mapped_column(Float)
+ open_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0)
+ close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
+ close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
+ 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.close_price is None:
+ return None
+ mul = 20 # glass futures point value
+ 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
diff --git a/ft-app/app/routers/option_trades.py b/ft-app/app/routers/option_trades.py
new file mode 100644
index 0000000..3bfc158
--- /dev/null
+++ b/ft-app/app/routers/option_trades.py
@@ -0,0 +1,108 @@
+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 OptionTrade, Contract, Product
+
+router = APIRouter(prefix="/options", tags=["options"])
+
+OPTION_TYPES = [("C", "C 看涨"), ("P", "P 看跌")]
+
+
+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 option_page(request: Request, db: Session = Depends(get_db)):
+ view = request.query_params.get("view", "")
+ open_trades = (
+ db.query(OptionTrade).filter(OptionTrade.status == "open")
+ .order_by(OptionTrade.open_date.desc()).all()
+ )
+ closed_trades = (
+ db.query(OptionTrade).filter(OptionTrade.status == "closed")
+ .order_by(OptionTrade.close_date.desc()).limit(50).all()
+ )
+
+ template = request.app.state.templates.get_template("options.html")
+ return HTMLResponse(
+ template.render(
+ request=request,
+ active_nav="options",
+ product_contracts=get_product_contracts(db),
+ option_types=OPTION_TYPES,
+ open_trades=open_trades,
+ closed_trades=closed_trades,
+ today=today_str(),
+ )
+ )
+
+
+@router.post("/open")
+def open_trade(
+ request: Request,
+ contract_code: str = Form(...),
+ option_type: str = Form(...),
+ strike_price: float = Form(...),
+ open_date: str = Form(...),
+ open_price: float = Form(...),
+ open_fee: float = Form(0.0),
+ db: Session = Depends(get_db),
+):
+ code = contract_code.upper()
+ contract = db.query(Contract).filter(Contract.code == code).first()
+ product_code = contract.product.code if contract else code[:2]
+
+ t = OptionTrade(
+ product_code=product_code,
+ contract_code=code,
+ option_type=option_type,
+ strike_price=strike_price,
+ open_date=date.fromisoformat(open_date),
+ open_price=open_price,
+ open_fee=open_fee,
+ status="open",
+ )
+ db.add(t)
+ db.commit()
+ return RedirectResponse("/options/", status_code=303)
+
+
+@router.post("/{trade_id}/close")
+def close_trade(
+ request: Request,
+ trade_id: int,
+ close_date: str = Form(...),
+ close_price: float = Form(...),
+ close_fee: float = Form(0.0),
+ db: Session = Depends(get_db),
+):
+ t = db.query(OptionTrade).filter(OptionTrade.id == trade_id).first()
+ if t and t.status == "open":
+ t.close_date = date.fromisoformat(close_date)
+ t.close_price = close_price
+ t.close_fee = close_fee
+ t.status = "closed"
+ db.commit()
+ return RedirectResponse("/options/", status_code=303)
+
+
+@router.post("/{trade_id}/delete")
+def delete_trade(trade_id: int, db: Session = Depends(get_db)):
+ t = db.query(OptionTrade).filter(OptionTrade.id == trade_id).first()
+ if t:
+ db.delete(t)
+ db.commit()
+ return RedirectResponse("/options/", status_code=303)
diff --git a/ft-app/app/templates/base.html b/ft-app/app/templates/base.html
index 99767f5..3616294 100644
--- a/ft-app/app/templates/base.html
+++ b/ft-app/app/templates/base.html
@@ -209,6 +209,9 @@
📝 交易记录
+
+ 📊 期权交易
+
⚙️ 系统管理
diff --git a/ft-app/app/templates/options.html b/ft-app/app/templates/options.html
new file mode 100644
index 0000000..2cd85eb
--- /dev/null
+++ b/ft-app/app/templates/options.html
@@ -0,0 +1,236 @@
+{% 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 %}
+
+ | {{ t.product_code }} |
+ {{ t.contract_code.replace(t.product_code, '', 1) }} |
+
+ {% if t.option_type == 'C' %}
+ C 看涨
+ {% else %}
+ P 看跌
+ {% endif %}
+ |
+ {{ t.strike_price }} |
+ {{ t.open_price }} |
+ {{ t.open_date }} |
+ {{ t.open_fee or 0 }} |
+
+
+
+ |
+
+ {% endfor %}
+
+
+{% else %}
+
暂无持仓
+{% endif %}
+
+{# ── 平仓弹窗 ── #}
+
+
+
+
+{% else %}
+
+{# ═══════════════ 已平仓 ═══════════════ #}
+
+{% if closed_trades %}
+
已平仓 · {{ closed_trades|length }} 笔
+
+
+ | 品种 | 合约 | 类型 | 行权价 | 开仓 | 平仓 | 权利金 | 平仓价 | 开仓费 | 平仓费 | 盈亏 | 操作 |
+ {% for t in closed_trades %}
+
+ | {{ t.product_code }} |
+ {{ t.contract_code.replace(t.product_code, '', 1) }} |
+
+ {% if t.option_type == 'C' %}
+ C 看涨
+ {% else %}
+ P 看跌
+ {% endif %}
+ |
+ {{ t.strike_price }} |
+ {{ t.open_date }} |
+ {{ t.close_date }} |
+ {{ t.open_price }} |
+ {{ t.close_price }} |
+ {{ t.open_fee or 0 }} |
+ {{ t.close_fee or 0 }} |
+
+ {% set p = t.pnl %}
+ {% if p is not none %}
+
+ {% if p > 0 %}+{% endif %}{{ p }}
+
+ {% endif %}
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+
+{% 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.open_fee or 0) %}
+ {% set ns.total_close_fee = ns.total_close_fee + (t.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 %}