babaTEEpe commited on
Commit
9bf5b55
·
verified ·
1 Parent(s): 7ed08dc

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +171 -20
app.py CHANGED
@@ -5,6 +5,7 @@ import ssl
5
  import struct
6
  import logging
7
  import time
 
8
  from datetime import datetime
9
  import requests
10
  from fastapi import FastAPI, Request, Form, HTTPException
@@ -65,28 +66,53 @@ def load_tokens():
65
  except Exception as e:
66
  logger.error(f"Failed to load tokens: {e}")
67
 
68
- # Fallback to CTRADER_REFRESH_TOKEN env var if set
69
  env_refresh = os.environ.get("CTRADER_REFRESH_TOKEN")
 
70
  env_acc_id = os.environ.get("CTRADER_ACCOUNT_ID")
71
- if env_refresh:
72
- logger.info("Using refresh token from environment secrets")
73
  return {
74
- "refresh_token": env_refresh.strip(),
75
- "access_token": "",
76
- "account_id": env_acc_id.strip() if env_acc_id else ""
 
77
  }
78
  return None
79
 
80
  # Helper: Exchange authorization code or refresh token
81
  def fetch_token_from_api(payload):
82
  url = "https://openapi.ctrader.com/apps/token"
83
- res = requests.get(url, params=payload)
84
- if res.status_code == 200:
85
- data = res.json()
86
- logger.info("Token request successful")
87
- return data
88
- else:
89
- logger.error(f"Token exchange failed: {res.text}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  return None
91
 
92
  # Helper: Send protocol buffer message over SSL Socket
@@ -230,6 +256,71 @@ def resolve_symbol_id(ssl_sock, account_id, symbol_name):
230
  break
231
  return None
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  # Helper: Fetch Account Balance/Details
234
  def get_trader_details(ssl_sock, account_id):
235
  req = ProtoOATraderReq()
@@ -368,8 +459,13 @@ def place_market_order(ssl_sock, account_id, symbol_id, trade_side, volume, posi
368
  logger.info(f"place_market_order: Received other payloadType: {proto_msg.payloadType}")
369
  return {"status": "error", "message": "No execution response received"}
370
 
 
 
 
 
371
  # Helper: Refresh tokens and return active ones
372
  def get_valid_tokens():
 
373
  tokens = load_tokens()
374
  if not tokens:
375
  return None
@@ -379,7 +475,14 @@ def get_valid_tokens():
379
  updated_at = tokens.get("updated_at", 0)
380
 
381
  # Only refresh if the access token is missing or older than 24 hours (86400 seconds)
 
382
  if not access_token or (time.time() - updated_at > 86400):
 
 
 
 
 
 
383
  if refresh_token:
384
  payload = {
385
  "grant_type": "refresh_token",
@@ -460,8 +563,10 @@ def get_valid_tokens():
460
 
461
  @app.get("/", response_class=HTMLResponse)
462
  async def dashboard(request: Request):
463
- tokens = get_valid_tokens()
464
- is_authenticated = tokens is not None
 
 
465
  account_id = tokens.get("account_id", "") if is_authenticated else ""
466
  access_token = tokens.get("access_token", "") if is_authenticated else ""
467
 
@@ -872,8 +977,8 @@ async def webhook(request: Request):
872
  ticker = data.get("ticker", "").upper()
873
  price = data.get("price")
874
 
875
- if action not in ["BUY", "SELL", "STOP-TRADE", "CLOSE"]:
876
- raise HTTPException(status_code=400, detail="Invalid action. Must be BUY, SELL, STOP-TRADE, or CLOSE")
877
 
878
  # Load and refresh tokens
879
  tokens = get_valid_tokens()
@@ -884,7 +989,7 @@ async def webhook(request: Request):
884
  access_token = tokens["access_token"]
885
 
886
  log_time = datetime.now().strftime("%H:%M:%S")
887
- color = "#60a5fa" if action == "BUY" else "#f87171" if action == "SELL" else "#fb923c" if action == "CLOSE" else "#94a3b8"
888
 
889
  try:
890
  # Establish SSL Socket connection to cTrader Open API
@@ -900,6 +1005,16 @@ async def webhook(request: Request):
900
  positions = get_open_positions(ssl_sock, account_id)
901
  symbol_positions = [p for p in positions if p.tradeData.symbolId == symbol_id]
902
 
 
 
 
 
 
 
 
 
 
 
903
  execution_msg = ""
904
 
905
  if action == "BUY":
@@ -913,7 +1028,7 @@ async def webhook(request: Request):
913
  else:
914
  # Open new BUY position (existing positions stay untouched — only stop-trade/end-trade closes them)
915
  logger.info(f"Opening new Long position... ({losing_count} losing BUY(s) open, limit {MAX_LOSING_POSITIONS})")
916
- res = place_market_order(ssl_sock, account_id, symbol_id, ProtoOATradeSide.BUY, current_trade_volume)
917
  execution_msg = f"Opened BUY position for {ticker}. Order Result: {res.get('status')} {res.get('message', '')}"
918
 
919
  elif action == "SELL":
@@ -927,7 +1042,7 @@ async def webhook(request: Request):
927
  else:
928
  # Open new SELL position (existing positions stay untouched — only stop-trade/end-trade closes them)
929
  logger.info(f"Opening new Short position... ({losing_count} losing SELL(s) open, limit {MAX_LOSING_POSITIONS})")
930
- res = place_market_order(ssl_sock, account_id, symbol_id, ProtoOATradeSide.SELL, current_trade_volume)
931
  execution_msg = f"Opened SELL position for {ticker}. Order Result: {res.get('status')} {res.get('message', '')}"
932
 
933
  elif action == "CLOSE":
@@ -979,6 +1094,42 @@ async def webhook(request: Request):
979
  else:
980
  execution_msg = f"No open positions on {ticker} to close."
981
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
982
  ssl_sock.close()
983
 
984
  # Log to in-memory dashboard logs
 
5
  import struct
6
  import logging
7
  import time
8
+ import asyncio
9
  from datetime import datetime
10
  import requests
11
  from fastapi import FastAPI, Request, Form, HTTPException
 
66
  except Exception as e:
67
  logger.error(f"Failed to load tokens: {e}")
68
 
69
+ # Fallback to environment secrets if token file doesn't exist
70
  env_refresh = os.environ.get("CTRADER_REFRESH_TOKEN")
71
+ env_access = os.environ.get("CTRADER_ACCESS_TOKEN")
72
  env_acc_id = os.environ.get("CTRADER_ACCOUNT_ID")
73
+ if env_refresh or env_access:
74
+ logger.info("Using tokens from environment secrets")
75
  return {
76
+ "refresh_token": env_refresh.strip() if env_refresh else "",
77
+ "access_token": env_access.strip() if env_access else "",
78
+ "account_id": env_acc_id.strip() if env_acc_id else "",
79
+ "updated_at": time.time()
80
  }
81
  return None
82
 
83
  # Helper: Exchange authorization code or refresh token
84
  def fetch_token_from_api(payload):
85
  url = "https://openapi.ctrader.com/apps/token"
86
+ headers = {
87
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
88
+ "Accept": "application/json, text/plain, */*"
89
+ }
90
+
91
+ # Attempt 1: GET request with custom headers
92
+ try:
93
+ res = requests.get(url, params=payload, headers=headers, timeout=10)
94
+ if res.status_code == 200:
95
+ data = res.json()
96
+ logger.info("Token request successful (GET)")
97
+ return data
98
+ except Exception as e:
99
+ logger.warning(f"GET token request exception: {e}")
100
+
101
+ # Attempt 2: POST request fallback with form-encoded data
102
+ try:
103
+ res = requests.post(url, data=payload, headers=headers, timeout=10)
104
+ if res.status_code == 200:
105
+ data = res.json()
106
+ logger.info("Token request successful (POST)")
107
+ return data
108
+ elif res.status_code == 429:
109
+ logger.warning("cTrader API rate limited (HTTP 429). Cloudflare/WAF blocked request.")
110
+ return None
111
+ else:
112
+ logger.error(f"Token exchange failed (HTTP {res.status_code}): {res.text[:200]}")
113
+ return None
114
+ except Exception as e:
115
+ logger.error(f"POST token request exception: {e}")
116
  return None
117
 
118
  # Helper: Send protocol buffer message over SSL Socket
 
256
  break
257
  return None
258
 
259
+ # Helper: Get volume limits for a symbol from broker
260
+ def get_symbol_volume_limits(ssl_sock, account_id, symbol_id):
261
+ """Query the broker for the symbol's volume limits.
262
+ Returns dict with maxVolume, minVolume, stepVolume in protocol units (units * 100), or empty dict."""
263
+ result = {}
264
+ try:
265
+ req = ProtoOASymbolByIdReq()
266
+ req.ctidTraderAccountId = int(account_id)
267
+ req.symbolId.append(int(symbol_id))
268
+ send_proto_message(ssl_sock, req, 2116)
269
+
270
+ while True:
271
+ proto_msg = receive_proto_message(ssl_sock)
272
+ if not proto_msg:
273
+ break
274
+ if proto_msg.payloadType == 2117: # ProtoOASymbolByIdRes
275
+ res = ProtoOASymbolByIdRes()
276
+ res.ParseFromString(proto_msg.payload)
277
+ for sym in res.symbol:
278
+ if hasattr(sym, 'maxVolume') and sym.maxVolume:
279
+ result['maxVolume'] = sym.maxVolume
280
+ if hasattr(sym, 'minVolume') and sym.minVolume:
281
+ result['minVolume'] = sym.minVolume
282
+ if hasattr(sym, 'stepVolume') and sym.stepVolume:
283
+ result['stepVolume'] = sym.stepVolume
284
+ logger.info(f"Symbol {symbol_id} volume limits: max={result.get('maxVolume', 'N/A')}, min={result.get('minVolume', 'N/A')}, step={result.get('stepVolume', 'N/A')} protocol units")
285
+ break
286
+ except Exception as e:
287
+ logger.warning(f"Could not fetch volume limits for symbol {symbol_id}: {e}")
288
+ return result
289
+
290
+ # Helper: Place market order with automatic volume reduction on "Not enough funds"
291
+ def place_market_order_smart(ssl_sock, account_id, symbol_id, trade_side, volume, min_volume=100, step_volume=100):
292
+ """Try placing an order. If 'Not enough funds', halve volume and retry until min_volume."""
293
+ attempt = 0
294
+ try_volume = volume
295
+
296
+ while try_volume >= min_volume:
297
+ attempt += 1
298
+ # Round down to nearest step
299
+ if step_volume > 0:
300
+ try_volume = (try_volume // step_volume) * step_volume
301
+ if try_volume < min_volume:
302
+ break
303
+
304
+ logger.info(f"Order attempt {attempt}: volume={try_volume} protocol units ({try_volume / 100:.2f} units)")
305
+ res = place_market_order(ssl_sock, account_id, symbol_id, trade_side, int(try_volume))
306
+
307
+ if res.get('status') == 'success':
308
+ if attempt > 1:
309
+ logger.info(f"Order succeeded on attempt {attempt} with reduced volume {try_volume}")
310
+ return res
311
+
312
+ error_msg = res.get('message', '')
313
+ if 'not enough funds' in error_msg.lower() or 'Not enough' in error_msg:
314
+ # Halve the volume and retry
315
+ try_volume = try_volume // 2
316
+ logger.info(f"Not enough funds — reducing volume to {try_volume} and retrying...")
317
+ else:
318
+ # Different error, don't retry
319
+ return res
320
+
321
+ return {"status": "error", "message": f"Not enough funds even at minimum volume ({min_volume} protocol units = {min_volume/100:.2f} units)"}
322
+
323
+
324
  # Helper: Fetch Account Balance/Details
325
  def get_trader_details(ssl_sock, account_id):
326
  req = ProtoOATraderReq()
 
459
  logger.info(f"place_market_order: Received other payloadType: {proto_msg.payloadType}")
460
  return {"status": "error", "message": "No execution response received"}
461
 
462
+ # Rate-limit guard: track last refresh attempt to avoid hammering the API
463
+ _last_refresh_attempt = 0
464
+ REFRESH_COOLDOWN = 60 # seconds between refresh attempts
465
+
466
  # Helper: Refresh tokens and return active ones
467
  def get_valid_tokens():
468
+ global _last_refresh_attempt
469
  tokens = load_tokens()
470
  if not tokens:
471
  return None
 
475
  updated_at = tokens.get("updated_at", 0)
476
 
477
  # Only refresh if the access token is missing or older than 24 hours (86400 seconds)
478
+ # AND we haven't tried refreshing in the last REFRESH_COOLDOWN seconds
479
  if not access_token or (time.time() - updated_at > 86400):
480
+ if time.time() - _last_refresh_attempt < REFRESH_COOLDOWN:
481
+ logger.info(f"Skipping token refresh (cooldown: {REFRESH_COOLDOWN - (time.time() - _last_refresh_attempt):.0f}s remaining)")
482
+ if access_token:
483
+ return tokens
484
+ return None
485
+ _last_refresh_attempt = time.time()
486
  if refresh_token:
487
  payload = {
488
  "grant_type": "refresh_token",
 
563
 
564
  @app.get("/", response_class=HTMLResponse)
565
  async def dashboard(request: Request):
566
+ # Dashboard uses load_tokens() directly — NO refresh call.
567
+ # This prevents health checks from hammering the cTrader API.
568
+ tokens = load_tokens()
569
+ is_authenticated = tokens is not None and bool(tokens.get("access_token"))
570
  account_id = tokens.get("account_id", "") if is_authenticated else ""
571
  access_token = tokens.get("access_token", "") if is_authenticated else ""
572
 
 
977
  ticker = data.get("ticker", "").upper()
978
  price = data.get("price")
979
 
980
+ if action not in ["BUY", "SELL", "STOP-TRADE", "CLOSE", "ANCHOR"]:
981
+ raise HTTPException(status_code=400, detail="Invalid action. Must be BUY, SELL, STOP-TRADE, CLOSE, or ANCHOR")
982
 
983
  # Load and refresh tokens
984
  tokens = get_valid_tokens()
 
989
  access_token = tokens["access_token"]
990
 
991
  log_time = datetime.now().strftime("%H:%M:%S")
992
+ color = "#60a5fa" if action == "BUY" else "#f87171" if action == "SELL" else "#fb923c" if action == "CLOSE" else "#FF9800" if action == "ANCHOR" else "#94a3b8"
993
 
994
  try:
995
  # Establish SSL Socket connection to cTrader Open API
 
1005
  positions = get_open_positions(ssl_sock, account_id)
1006
  symbol_positions = [p for p in positions if p.tradeData.symbolId == symbol_id]
1007
 
1008
+ # Auto-clamp volume to broker's max for this symbol and get min/step
1009
+ effective_volume = current_trade_volume
1010
+ vol_limits = get_symbol_volume_limits(ssl_sock, account_id, symbol_id)
1011
+ max_vol = vol_limits.get('maxVolume')
1012
+ min_vol = vol_limits.get('minVolume', 100) # default 1 unit
1013
+ step_vol = vol_limits.get('stepVolume', 100) # default 1 unit
1014
+ if max_vol and effective_volume > max_vol:
1015
+ logger.info(f"Clamping volume from {effective_volume} to {max_vol} (broker max for {ticker})")
1016
+ effective_volume = max_vol
1017
+
1018
  execution_msg = ""
1019
 
1020
  if action == "BUY":
 
1028
  else:
1029
  # Open new BUY position (existing positions stay untouched — only stop-trade/end-trade closes them)
1030
  logger.info(f"Opening new Long position... ({losing_count} losing BUY(s) open, limit {MAX_LOSING_POSITIONS})")
1031
+ res = place_market_order_smart(ssl_sock, account_id, symbol_id, ProtoOATradeSide.BUY, effective_volume, min_vol, step_vol)
1032
  execution_msg = f"Opened BUY position for {ticker}. Order Result: {res.get('status')} {res.get('message', '')}"
1033
 
1034
  elif action == "SELL":
 
1042
  else:
1043
  # Open new SELL position (existing positions stay untouched — only stop-trade/end-trade closes them)
1044
  logger.info(f"Opening new Short position... ({losing_count} losing SELL(s) open, limit {MAX_LOSING_POSITIONS})")
1045
+ res = place_market_order_smart(ssl_sock, account_id, symbol_id, ProtoOATradeSide.SELL, effective_volume, min_vol, step_vol)
1046
  execution_msg = f"Opened SELL position for {ticker}. Order Result: {res.get('status')} {res.get('message', '')}"
1047
 
1048
  elif action == "CLOSE":
 
1094
  else:
1095
  execution_msg = f"No open positions on {ticker} to close."
1096
 
1097
+ elif action == "ANCHOR":
1098
+ # ANCHOR = consolidation detected. Open opposite trade, wait 5 sec, then close ALL.
1099
+ anchor_direction = data.get("direction", "").upper()
1100
+
1101
+ # Step 1: Open the opposite (hedging) trade
1102
+ if anchor_direction == "BUY":
1103
+ logger.info(f"ANCHOR: Opening hedging BUY on {ticker}...")
1104
+ res = place_market_order_smart(ssl_sock, account_id, symbol_id, ProtoOATradeSide.BUY, effective_volume, min_vol, step_vol)
1105
+ logger.info(f"ANCHOR: Hedging BUY opened. Result: {res.get('status')} {res.get('message', '')}")
1106
+ elif anchor_direction == "SELL":
1107
+ logger.info(f"ANCHOR: Opening hedging SELL on {ticker}...")
1108
+ res = place_market_order_smart(ssl_sock, account_id, symbol_id, ProtoOATradeSide.SELL, effective_volume, min_vol, step_vol)
1109
+ logger.info(f"ANCHOR: Hedging SELL opened. Result: {res.get('status')} {res.get('message', '')}")
1110
+ else:
1111
+ logger.warning(f"ANCHOR: No direction specified, skipping hedge trade.")
1112
+
1113
+ # Step 2: Wait 5 seconds for the hedge trade to fully settle
1114
+ ssl_sock.close()
1115
+ logger.info(f"ANCHOR: Waiting 5 seconds before closing all positions on {ticker}...")
1116
+ await asyncio.sleep(5)
1117
+
1118
+ # Step 3: Reconnect and close ALL positions on this symbol
1119
+ ssl_sock = get_authenticated_socket(account_id, access_token)
1120
+ symbol_id = resolve_symbol_id(ssl_sock, account_id, ticker)
1121
+ positions = get_open_positions(ssl_sock, account_id)
1122
+ symbol_positions = [p for p in positions if p.tradeData.symbolId == symbol_id]
1123
+
1124
+ closed_count = 0
1125
+ for p in symbol_positions:
1126
+ side = ProtoOATradeSide.SELL if p.tradeData.tradeSide == 1 else ProtoOATradeSide.BUY
1127
+ logger.info(f"ANCHOR: Closing {'BUY' if p.tradeData.tradeSide == 1 else 'SELL'} position {p.positionId}")
1128
+ place_market_order(ssl_sock, account_id, symbol_id, side, p.tradeData.volume, position_id=p.positionId)
1129
+ closed_count += 1
1130
+
1131
+ execution_msg = f"ANCHOR: Opened hedging {anchor_direction}, then closed {closed_count} position(s) on {ticker}. Consolidation neutralized."
1132
+
1133
  ssl_sock.close()
1134
 
1135
  # Log to in-memory dashboard logs