Automatic flex query syncing
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,2 +1,9 @@
|
|||||||
.env
|
.env
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Ignore Python bytecode files
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*$py.class
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
101
backend/app.py
101
backend/app.py
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user