Automatic flex query syncing

This commit is contained in:
2026-04-01 23:28:51 -05:00
parent aa872aba67
commit cfe00f67b5
6 changed files with 145 additions and 16 deletions

7
.gitignore vendored
View File

@@ -1,2 +1,9 @@
.env .env
data/ data/
# Ignore Python bytecode files
*.pyc
__pycache__/
*.pyo
*.pyd
*$py.class

View File

@@ -16,6 +16,19 @@ from flex_query import FlexQueryError, fetch_flex_statement, get_flex_status
from ib_client import IBClient from ib_client import IBClient
import time import time
FLEX_AUTO_SYNC_MAX_AGE_SECONDS = 8 * 60 * 60
_flex_sync_lock = threading.Lock()
_flex_startup_sync_started = False
_flex_sync_state = {
"last_attempt_at": None,
"last_success_at": None,
"last_result": None,
"last_error": None,
"last_saved_to": None,
"last_reference_code": None,
"last_trigger": None,
}
def load_dotenv_file() -> None: def load_dotenv_file() -> None:
env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env")) env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env"))
@@ -43,6 +56,79 @@ load_dotenv_file()
STATIC_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "frontend")) STATIC_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "frontend"))
app = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path="/static") app = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path="/static")
def should_auto_sync_flex(status: dict) -> bool:
if not status.get("configured"):
return False
latest_timestamp = status.get("latest_timestamp")
if latest_timestamp is None:
return True
return (time.time() - latest_timestamp) > FLEX_AUTO_SYNC_MAX_AGE_SECONDS
def get_flex_status_with_sync_state() -> dict:
status = get_flex_status()
status.update(_flex_sync_state)
return status
def run_flex_sync_if_needed(reason: str) -> bool:
status = get_flex_status()
if not should_auto_sync_flex(status):
return False
if not _flex_sync_lock.acquire(blocking=False):
return False
try:
_flex_sync_state["last_attempt_at"] = time.time()
_flex_sync_state["last_trigger"] = reason
_flex_sync_state["last_result"] = "running"
_flex_sync_state["last_error"] = None
# Re-check after taking the lock so concurrent callers do not duplicate work.
status = get_flex_status()
if not should_auto_sync_flex(status):
_flex_sync_state["last_result"] = "skipped"
return False
result = fetch_flex_statement()
_flex_sync_state["last_success_at"] = time.time()
_flex_sync_state["last_result"] = "success"
_flex_sync_state["last_saved_to"] = result["saved_to"]
_flex_sync_state["last_reference_code"] = result["reference_code"]
print(
f"Flex sync completed ({reason}): saved_to={result['saved_to']} "
f"reference_code={result['reference_code']}"
)
return True
except FlexQueryError as exc:
_flex_sync_state["last_result"] = "error"
_flex_sync_state["last_error"] = str(exc)
print(f"Flex sync skipped/failed ({reason}): {exc}")
return False
except Exception as exc:
_flex_sync_state["last_result"] = "error"
_flex_sync_state["last_error"] = str(exc)
print(f"Unexpected Flex sync failure ({reason}): {exc}")
return False
finally:
_flex_sync_lock.release()
def start_flex_startup_sync_if_needed() -> None:
global _flex_startup_sync_started
if _flex_startup_sync_started:
return
_flex_startup_sync_started = True
thread = threading.Thread(
target=run_flex_sync_if_needed,
args=("startup",),
daemon=True,
)
thread.start()
# instantiate the IB client with a process-unique client id to avoid # instantiate the IB client with a process-unique client id to avoid
# collisions when Flask's reloader spawns multiple processes. # collisions when Flask's reloader spawns multiple processes.
ib = IBClient(client_id=os.getpid()) ib = IBClient(client_id=os.getpid())
@@ -53,6 +139,7 @@ if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not os.environ.get("WERKZEUG
ib.connect_and_start() ib.connect_and_start()
except Exception: except Exception:
pass pass
start_flex_startup_sync_if_needed()
@app.route("/") @app.route("/")
@@ -216,7 +303,7 @@ def api_executions_debug():
@app.route("/api/flex/status") @app.route("/api/flex/status")
def api_flex_status(): def api_flex_status():
return jsonify(get_flex_status()) return jsonify(get_flex_status_with_sync_state())
@app.route("/api/flex/lots-debug") @app.route("/api/flex/lots-debug")
@@ -236,11 +323,23 @@ def api_flex_lots_debug():
@app.route("/api/flex/sync", methods=["POST"]) @app.route("/api/flex/sync", methods=["POST"])
def api_flex_sync(): def api_flex_sync():
try: try:
_flex_sync_state["last_attempt_at"] = time.time()
_flex_sync_state["last_trigger"] = "manual"
_flex_sync_state["last_result"] = "running"
_flex_sync_state["last_error"] = None
result = fetch_flex_statement() result = fetch_flex_statement()
_flex_sync_state["last_success_at"] = time.time()
_flex_sync_state["last_result"] = "success"
_flex_sync_state["last_saved_to"] = result["saved_to"]
_flex_sync_state["last_reference_code"] = result["reference_code"]
return jsonify({"ok": True, **result}) return jsonify({"ok": True, **result})
except FlexQueryError as exc: except FlexQueryError as exc:
_flex_sync_state["last_result"] = "error"
_flex_sync_state["last_error"] = str(exc)
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
except Exception as exc: except Exception as exc:
_flex_sync_state["last_result"] = "error"
_flex_sync_state["last_error"] = str(exc)
return jsonify({"ok": False, "error": str(exc)}), 500 return jsonify({"ok": False, "error": str(exc)}), 500

View File

@@ -29,12 +29,18 @@ class FlexConfig:
data_dir: Path data_dir: Path
poll_interval_seconds: float = 2.0 poll_interval_seconds: float = 2.0
max_polls: int = 30 max_polls: int = 30
send_request_retry_seconds: float = 10.0
max_send_request_attempts: int = 6
class FlexQueryError(RuntimeError): class FlexQueryError(RuntimeError):
pass pass
class FlexQueryRetryableError(FlexQueryError):
pass
def load_flex_config() -> FlexConfig | None: def load_flex_config() -> FlexConfig | None:
token = os.environ.get("IB_FLEX_TOKEN", "").strip() token = os.environ.get("IB_FLEX_TOKEN", "").strip()
query_id = os.environ.get("IB_FLEX_QUERY_ID", "").strip() query_id = os.environ.get("IB_FLEX_QUERY_ID", "").strip()
@@ -86,7 +92,11 @@ def _parse_send_request(xml_bytes: bytes) -> tuple[str, str]:
root = ET.fromstring(xml_bytes) root = ET.fromstring(xml_bytes)
status = (root.findtext("Status") or "").strip() status = (root.findtext("Status") or "").strip()
if status.lower() != "success": if status.lower() != "success":
raise FlexQueryError(root.findtext("ErrorMessage") or "Flex send-request failed") error_code = (root.findtext("ErrorCode") or "").strip()
error_message = (root.findtext("ErrorMessage") or "Flex send-request failed").strip()
if error_code == "1004":
raise FlexQueryRetryableError(error_message)
raise FlexQueryError(error_message)
reference_code = (root.findtext("ReferenceCode") or "").strip() reference_code = (root.findtext("ReferenceCode") or "").strip()
if not reference_code: if not reference_code:
raise FlexQueryError("Flex send-request returned no ReferenceCode") raise FlexQueryError("Flex send-request returned no ReferenceCode")
@@ -110,10 +120,23 @@ def fetch_flex_statement() -> dict:
config.data_dir.mkdir(parents=True, exist_ok=True) config.data_dir.mkdir(parents=True, exist_ok=True)
_, reference_code = _parse_send_request(_http_get( reference_code = None
FLEX_SEND_REQUEST_URL, last_retryable_error = None
{"t": config.token, "q": config.query_id, "v": "3"}, for attempt in range(1, config.max_send_request_attempts + 1):
)) try:
_, reference_code = _parse_send_request(_http_get(
FLEX_SEND_REQUEST_URL,
{"t": config.token, "q": config.query_id, "v": "3"},
))
break
except FlexQueryRetryableError as exc:
last_retryable_error = str(exc)
if attempt >= config.max_send_request_attempts:
raise FlexQueryError(last_retryable_error) from exc
time.sleep(config.send_request_retry_seconds)
if not reference_code:
raise FlexQueryError(last_retryable_error or "Flex send-request returned no ReferenceCode")
statement_xml = None statement_xml = None
for _ in range(config.max_polls): for _ in range(config.max_polls):