Skip to content

Commit aaa08b7

Browse files
committed
feat(quick-trade): use authoritative exchange fill history
1 parent 2583df6 commit aaa08b7

10 files changed

Lines changed: 1264 additions & 9 deletions

‎backend_api_python/app/routes/alpaca.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,26 @@ def get_orders():
410410
return jsonify({"success": False, "error": str(e)}), 500
411411

412412

413+
@alpaca_blp.route('/fills', methods=['GET'])
414+
@login_required
415+
def get_fills():
416+
"""Get authoritative Alpaca fill activities for the selected account."""
417+
try:
418+
client, err = _require_connected_client()
419+
if err is not None:
420+
return err
421+
symbol = str(request.args.get("symbol") or "").strip()
422+
limit = int(request.args.get("limit") or 100)
423+
return jsonify({
424+
"success": True,
425+
"data": client.get_fill_history(symbol=symbol, limit=limit),
426+
"source": "exchange",
427+
})
428+
except Exception as e:
429+
logger.error(f"Get Alpaca fills failed: {e}")
430+
return jsonify({"success": False, "error": str(e)}), 500
431+
432+
413433
# ==================== Trading ====================
414434

415435
@alpaca_blp.route('/order', methods=['POST'])

‎backend_api_python/app/routes/quick_trade.py‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,7 +1084,7 @@ def _compute_position() -> List[Dict[str, Any]]:
10841084
request.args.get("api_family", ""),
10851085
),
10861086
_compute_position,
1087-
ttl_sec=8,
1087+
ttl_sec=2,
10881088
stale_ttl_sec=90,
10891089
timeout_sec=10,
10901090
namespace="quick_trade_position",
@@ -1876,7 +1876,43 @@ def get_history():
18761876
"created_at": to_utc_iso(r.get("created_at")) or str(r.get("created_at") or ""),
18771877
})
18781878

1879-
return jsonify({"code": 1, "msg": "success", "data": {"trades": trades}})
1879+
exchange_history = {"supported": False, "trades": []}
1880+
if credential_id > 0 and symbol and market_type in ("spot", "swap"):
1881+
try:
1882+
from app.services.quick_trade.trade_history import exchange_trade_history
1883+
1884+
config = build_exchange_config(credential_id, user_id, {"market_type": market_type})
1885+
client = create_exchange_client(config, market_type=market_type)
1886+
exchange_history = exchange_trade_history(
1887+
client,
1888+
symbol,
1889+
market_type=market_type,
1890+
limit=limit,
1891+
offset=offset,
1892+
)
1893+
except Exception as exc:
1894+
logger.info("Native exchange trade history unavailable: %s", exc)
1895+
1896+
if exchange_history.get("supported"):
1897+
active_statuses = {
1898+
"new", "open", "pending", "submitted", "partially_filled",
1899+
"partially-filled", "accepted", "cancel_pending",
1900+
}
1901+
pending_orders = [
1902+
trade for trade in trades
1903+
if str(trade.get("status") or "").strip().lower() in active_statuses
1904+
]
1905+
trades = pending_orders + list(exchange_history.get("trades") or [])
1906+
trades.sort(key=lambda trade: str(trade.get("created_at") or ""), reverse=True)
1907+
1908+
return jsonify({
1909+
"code": 1,
1910+
"msg": "success",
1911+
"data": {
1912+
"trades": trades,
1913+
"source": "exchange" if exchange_history.get("supported") else "local_fallback",
1914+
},
1915+
})
18801916
except Exception as e:
18811917
logger.error(f"get_history failed: {e}")
18821918
return jsonify({"code": 0, "msg": str(e)}), 500

‎backend_api_python/app/services/alpaca_trading/client.py‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ def get_account_activities(
602602
after: str = "",
603603
until: str = "",
604604
date: str = "",
605+
direction: str = "asc",
605606
page_size: int = 100,
606607
max_pages: int = 20,
607608
) -> List[Dict[str, Any]]:
@@ -616,7 +617,7 @@ def get_account_activities(
616617
url = f"{host.rstrip('/')}/v2/account/activities"
617618
params: Dict[str, Any] = {
618619
"activity_types": types,
619-
"direction": "asc",
620+
"direction": "desc" if str(direction or "").strip().lower() == "desc" else "asc",
620621
"page_size": max(1, min(int(page_size or 100), 100)),
621622
}
622623
if date:
@@ -648,6 +649,73 @@ def get_account_activities(
648649
params["page_token"] = token
649650
return output
650651

652+
def get_fill_history(
653+
self,
654+
*,
655+
symbol: str = "",
656+
limit: int = 100,
657+
max_pages: int = 20,
658+
) -> List[Dict[str, Any]]:
659+
"""Return account-owned Alpaca executions, one row per fill activity."""
660+
requested_limit = max(1, min(int(limit or 100), 500))
661+
requested_symbol = str(symbol or "").replace("/", "").replace("-", "").upper()
662+
activities = self.get_account_activities(
663+
activity_types=["FILL"],
664+
direction="desc",
665+
page_size=100,
666+
max_pages=max_pages,
667+
)
668+
output: List[Dict[str, Any]] = []
669+
for activity in activities:
670+
row_symbol = str(activity.get("symbol") or "").replace("/", "").replace("-", "").upper()
671+
if requested_symbol and row_symbol != requested_symbol:
672+
continue
673+
quantity = abs(_num(activity.get("qty") or activity.get("quantity")))
674+
price = _num(activity.get("price") or activity.get("avg_price"))
675+
activity_id = str(
676+
activity.get("id")
677+
or activity.get("activity_id")
678+
or activity.get("execution_id")
679+
or ""
680+
).strip()
681+
side = str(activity.get("side") or "").strip().lower()
682+
if not activity_id or not row_symbol or quantity <= 0 or price <= 0 or side not in {"buy", "sell"}:
683+
continue
684+
executed_at = str(
685+
activity.get("transaction_time")
686+
or activity.get("executed_at")
687+
or activity.get("at")
688+
or ""
689+
)
690+
output.append({
691+
"id": f"exchange-alpaca-{activity_id}",
692+
"exchange_fill_id": activity_id,
693+
"exchange_order_id": str(activity.get("order_id") or ""),
694+
"client_order_id": str(activity.get("client_order_id") or ""),
695+
"symbol": row_symbol,
696+
"side": side,
697+
"order_type": "fill",
698+
"status": "filled",
699+
"market_type": "spot",
700+
"amount": quantity * price,
701+
"filled_amount": quantity,
702+
"avg_fill_price": price,
703+
"commission": 0.0,
704+
"commission_ccy": "USD",
705+
"commission_quote": None,
706+
"fees_by_ccy": {},
707+
"fee_status": "pending",
708+
"realized_pnl": None,
709+
"source": "exchange",
710+
"filled_at": executed_at,
711+
"created_at": executed_at,
712+
"activity_type": str(activity.get("activity_type") or "FILL"),
713+
"fill_type": str(activity.get("type") or "fill"),
714+
})
715+
if len(output) >= requested_limit:
716+
break
717+
return output
718+
651719
# ==================== Query Methods ====================
652720

653721
def get_account_summary(self) -> Dict[str, Any]:

‎backend_api_python/app/services/quick_trade/position_activity.py‎

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from math import isfinite
66

77
from app.services.live_trading.binance import BinanceFuturesClient
8+
from app.services.live_trading.binance_spot import BinanceSpotClient
89
from app.services.live_trading.bitget import BitgetMixClient
910
from app.services.live_trading.bitget_classic import BitgetClassicMixClient
1011
from app.services.live_trading.bybit import BybitClient
@@ -121,11 +122,7 @@ def binance_position_history(client, symbol, limit=50):
121122
return {"supported": False, "positions": []}
122123
native_symbol = to_binance_futures_symbol(symbol)
123124
try:
124-
rows = client._signed_request(
125-
"GET",
126-
"/fapi/v1/userTrades",
127-
params={"symbol": native_symbol, "limit": 1000},
128-
)
125+
rows = client.get_user_trades(symbol=symbol, limit=1000)
129126
except Exception as exc:
130127
logger.info("Binance native trade history unavailable: %s", exc)
131128
return {"supported": False, "positions": []}
@@ -221,6 +218,99 @@ def close(side, quantity, price, fee, gross_pnl, timestamp, row_id):
221218
return {"supported": True, "positions": result[: max(1, int(limit or 50))]}
222219

223220

221+
def binance_trade_history(client, symbol, market_type="swap", limit=50):
222+
"""Return exchange-owned Binance fills for the selected instrument."""
223+
market = str(market_type or "swap").strip().lower()
224+
if isinstance(client, BinanceFuturesClient) and market == "swap":
225+
reader = client.get_user_trades
226+
elif isinstance(client, BinanceSpotClient) and market == "spot":
227+
reader = client.get_my_trades
228+
else:
229+
return {"supported": False, "trades": []}
230+
231+
native_symbol = to_binance_futures_symbol(symbol)
232+
try:
233+
rows = reader(symbol=symbol, limit=max(1, min(int(limit or 50), 1000)))
234+
except Exception as exc:
235+
logger.info("Binance native trade history unavailable: %s", exc)
236+
return {"supported": False, "trades": []}
237+
238+
net_quantity = 0.0
239+
normalized = []
240+
ordered = sorted(
241+
(row for row in (rows or []) if isinstance(row, dict)),
242+
key=lambda row: (_number(row.get("time")), _number(row.get("id") or row.get("tradeId"))),
243+
)
244+
for row in ordered:
245+
if str(row.get("symbol") or "").upper() != native_symbol:
246+
continue
247+
quantity = abs(_number(row.get("qty") or row.get("quantity")))
248+
price = _number(row.get("price"))
249+
if quantity <= 0 or price <= 0:
250+
continue
251+
side = str(row.get("side") or ("BUY" if row.get("isBuyer") else "SELL")).strip().lower()
252+
if side not in {"buy", "sell"}:
253+
continue
254+
position_side = str(row.get("positionSide") or "BOTH").strip().upper()
255+
realized_value = row.get("realizedPnl")
256+
realized_pnl = None if realized_value in (None, "") else _number(realized_value)
257+
is_close = False
258+
close_side = ""
259+
if market == "swap":
260+
if position_side == "LONG":
261+
is_close = side == "sell"
262+
close_side = "long" if is_close else ""
263+
elif position_side == "SHORT":
264+
is_close = side == "buy"
265+
close_side = "short" if is_close else ""
266+
else:
267+
delta = quantity if side == "buy" else -quantity
268+
if net_quantity > 1e-15 and delta < 0:
269+
is_close = True
270+
close_side = "long"
271+
elif net_quantity < -1e-15 and delta > 0:
272+
is_close = True
273+
close_side = "short"
274+
net_quantity += delta
275+
if abs(net_quantity) <= 1e-15:
276+
net_quantity = 0.0
277+
if realized_pnl is not None and abs(realized_pnl) > 1e-15:
278+
is_close = True
279+
close_side = close_side or ("short" if side == "buy" else "long")
280+
281+
commission_ccy = str(row.get("commissionAsset") or "").upper()
282+
commission = abs(_number(row.get("commission")))
283+
quote_ccy = str(symbol or "").split("/", 1)[-1].split(":", 1)[0].upper()
284+
trade_id = str(row.get("id") or row.get("tradeId") or f"{row.get('orderId')}:{row.get('time')}")
285+
normalized.append({
286+
"id": f"exchange-{trade_id}",
287+
"symbol": symbol,
288+
"side": side,
289+
"order_type": "fill",
290+
"amount": quantity * price,
291+
"price": 0.0,
292+
"leverage": 0,
293+
"market_type": market,
294+
"status": "filled",
295+
"exchange_order_id": str(row.get("orderId") or ""),
296+
"client_order_id": str(row.get("clientOrderId") or ""),
297+
"filled_amount": quantity,
298+
"avg_fill_price": price,
299+
"commission": commission,
300+
"commission_ccy": commission_ccy,
301+
"commission_quote": commission if not commission_ccy or commission_ccy == quote_ccy else None,
302+
"fee_status": "confirmed" if commission > 0 else "actual_zero",
303+
"realized_pnl": realized_pnl if is_close else None,
304+
"source": "exchange",
305+
"is_close": is_close,
306+
"close_side": close_side,
307+
"created_at": utc_time(row.get("time")),
308+
})
309+
310+
normalized.sort(key=lambda row: str(row.get("created_at") or ""), reverse=True)
311+
return {"supported": True, "trades": normalized[: max(1, int(limit or 50))]}
312+
313+
224314
def bybit_position_history(client, symbol, limit=50):
225315
"""Return exchange-owned closed position cycles for Bybit linear contracts."""
226316
if not isinstance(client, BybitClient) or str(getattr(client, "category", "linear")) != "linear":

0 commit comments

Comments
 (0)