Repository navigation
Expand file tree
/
Copy pathtrade_workflow.py
More file actions
1592 lines (1436 loc) · 98.3 KB
/
Copy pathtrade_workflow.py
File metadata and controls
1592 lines (1436 loc) · 98.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Explicit-mode Alpaca trade workflow backed by a durable SQLite execution ledger."""
import argparse
import hashlib
import json
import math
import os
import re
import sqlite3
import sys
import time as time_module
import uuid
from contextlib import contextmanager
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation, ROUND_FLOOR, ROUND_HALF_UP
from pathlib import Path
from zoneinfo import ZoneInfo
import requests
import yfinance as yf
from dotenv import load_dotenv
from alpaca.trading.enums import PositionIntent
from automation import compute_recommendation, get_todays_earnings, get_tomorrows_earnings
from alpaca_integration import (
DEFAULT_QUOTE_MAX_AGE_SECONDS, QuoteValidationError, SubmissionTimingRefusal,
close_calendar_spread_order, close_single_option_leg_order,
get_alpaca_option_chain, get_spread_quotes, init_alpaca_client,
place_calendar_spread_order, select_expiries_and_strike_alpaca,
)
load_dotenv()
EASTERN = ZoneInfo("America/New_York")
DEFAULT_DB_PATH = Path("trades.db")
TRADES_DB_PATH_SETTING = os.environ.get("TRADES_DB_PATH")
DB_PATH = Path(TRADES_DB_PATH_SETTING.strip()).expanduser() if TRADES_DB_PATH_SETTING and TRADES_DB_PATH_SETTING.strip() else DEFAULT_DB_PATH
GOOGLE_SCRIPT_URL = os.environ.get("GOOGLE_SCRIPT_URL")
GOOGLE_SCRIPT_SECRET = os.environ.get("GOOGLE_SCRIPT_SECRET")
# Fixed allocation per position as a share of equity (formerly kelly_fraction),
# used until the Kelly inputs below are configured.
POSITION_ALLOCATION_PCT = Decimal(os.environ.get("POSITION_ALLOCATION_PCT") or "0.06")
# Kelly sizing: the share of full Kelly to bet, and the strategy's expected win
# rate plus its average win and average loss as fractions of the debit paid.
KELLY_FRACTION = os.environ.get("KELLY_FRACTION") or "0.10"
KELLY_WIN_RATE = os.environ.get("KELLY_WIN_RATE")
KELLY_AVG_WIN = os.environ.get("KELLY_AVG_WIN")
KELLY_AVG_LOSS = os.environ.get("KELLY_AVG_LOSS")
# Positions last about a day, so this caps how many can open in one session.
MAX_AGGREGATE_EXPOSURE_PCT = Decimal(os.environ.get("MAX_AGGREGATE_EXPOSURE_PCT") or "0.36")
# Minutes before the broker close when new entries may start. The default
# opens entries from noon on a regular session so a delayed scheduled run can
# still trade; 25 restores the strategy's late-day entry.
ENTRY_WINDOW_MINUTES = int(os.environ.get("ENTRY_WINDOW_MINUTES") or "240")
# Share of the distance from the net midpoint to the net ask that an opening
# order may pay. The chase still starts at the midpoint; 1 allows the natural
# price, and Alpaca PAPER has not filled spreads below it.
OPEN_MAX_DEBIT_SPREAD_FRACTION = Decimal(os.environ.get("OPEN_MAX_DEBIT_SPREAD_FRACTION") or "1")
# Widest net market an entry accepts, as (net ask - net bid) / midpoint; 0.75
# means 75%. Unset accepts any width: every entry filled from August to
# October 2026 was at least 30% wide, so a tight cap stops all trading.
OPEN_MAX_SPREAD_WIDTH_PCT = Decimal(os.environ["OPEN_MAX_SPREAD_WIDTH_PCT"]) if os.environ.get("OPEN_MAX_SPREAD_WIDTH_PCT") else None
# An opening chase may follow a rising ask up to this share above the
# planning-time ceiling, still within the position and exposure limits.
OPEN_CEILING_FOLLOW_PCT = Decimal(os.environ.get("OPEN_CEILING_FOLLOW_PCT") or "0.05")
# Alpaca PAPER filled 7 of the 8 filled entries within 0.25 s of submission
# (LEVI took 10 s), so an opening price waits briefly before the next step.
OPEN_ORDER_TIMING = {"overall_timeout": 180, "attempt_timeout": 10, "cancel_timeout": 30}
# Quotes, the submit guard, submission and cancel confirmation add 0.4-1.1 s.
ORDER_ATTEMPT_OVERHEAD_SECONDS = 1
UUID_NAMESPACE = uuid.UUID("f8eaa5b8-685f-4d83-9308-b426ad5f95a1")
KNOWN_DEBITS = {"DG", "ORCL", "NKE"}
KNOWN_SINGLE_CREDITS = {"CHPT", "LULU", "DRI", "KMX", "KR", "KMI"}
KNOWN_ZERO_UNVERIFIED = {"GAP", "ASAN", "PL", "DOCU", "WOOF", "ERIC"}
TERMINAL_STATUSES = {"filled", "canceled", "expired", "rejected"}
# The Sheet holds one row per trade. The Apps Script must confirm this layout
# and every field below before an outbox event counts as synced.
SHEET_LAYOUT = "trade-rows"
SHEET_REQUIRED_TRADE_FIELDS = {
"Ticker", "Size", "Open Date", "Open Price", "Close Date", "Close Price",
"Short Symbol", "Long Symbol", "Record ID", "Trade ID", "Parent Trade ID",
"Broker Order ID", "Broker Fill ID", "Sync Type", "Fill Phase",
"Ordered Quantity", "Filled Quantity", "Remaining Quantity", "Lifecycle Status",
"Open Sync Status", "Close Sync Status", "Open Cash Flow", "Close Cash Flow",
"Fees", "Realized P&L", "Close Method", "Close Reason",
"Broker Mode", "Broker Account Fingerprint", "P&L Status",
}
class OperationalFailure(RuntimeError):
pass
class ReconciliationFailure(OperationalFailure):
pass
class SubmissionWindowClosed(OperationalFailure, SubmissionTimingRefusal):
"""Only timing stops the next order, so the chase ends and the run goes on."""
class RunTimeBudgetReached(OperationalFailure):
"""Too little run time is left to start another candidate."""
# Orders that ended only because their time ran out or the market was closing.
TIMING_STOP_REASONS = {"order_time_budget_reached", "market_closed", "close_cutoff_reached"}
def dec(value, default="0"):
if value is None or value == "":
return Decimal(default)
try:
return Decimal(str(value))
except (InvalidOperation, ValueError, TypeError) as exc:
raise OperationalFailure(f"Invalid numeric value {value!r}") from exc
def dollars_to_cents(value):
return int((dec(value) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def contract_cashflow_cents(price, quantity):
return int((dec(price) * int(quantity) * 10000).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def cents_to_dollars(value):
return None if value is None else float((Decimal(int(value)) / 100).quantize(Decimal("0.01")))
def enum_text(value):
return str(getattr(value, "value", value) or "").lower()
def is_terminal_order(order):
explicit=getattr(order,"terminal",None)
return bool(explicit) if explicit is not None else enum_text(getattr(order,"status","")) in TERMINAL_STATUSES
def stamp(value=None):
value = value or datetime.now(timezone.utc)
if isinstance(value, str):
return value
if isinstance(value, date) and not isinstance(value, datetime):
return value.isoformat()
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat()
def redact(value):
text = str(value)
for secret in (os.environ.get("APCA_API_KEY_ID"), os.environ.get("APCA_API_SECRET_KEY"), GOOGLE_SCRIPT_SECRET):
if secret:
text = text.replace(secret, "[REDACTED]")
return text
@contextmanager
def db(write=False, read_only=False):
if read_only:
conn = sqlite3.connect(f"file:{DB_PATH.resolve().as_posix()}?mode=ro", uri=True, timeout=30)
else:
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=30000")
try:
if write:
conn.execute("BEGIN IMMEDIATE")
yield conn
if write:
conn.commit()
except Exception:
if write:
conn.rollback()
raise
finally:
conn.close()
TRADE_COLUMNS = {
"trade_id": "TEXT", "parent_trade_id": "TEXT", "ordered_quantity": "INTEGER",
"filled_quantity": "INTEGER", "closed_quantity": "INTEGER", "remaining_quantity": "INTEGER",
"lifecycle_status": "TEXT", "open_sync_status": "TEXT", "close_sync_status": "TEXT",
"opening_cash_flow_cents": "INTEGER", "closing_cash_flow_cents": "INTEGER",
"opening_fees_cents": "INTEGER", "closing_fees_cents": "INTEGER",
"allocated_open_cash_flow_cents": "INTEGER", "allocated_open_fees_cents": "INTEGER",
"realized_pnl_cents": "INTEGER", "open_order_id": "TEXT", "close_order_id": "TEXT",
"open_client_order_prefix": "TEXT", "close_client_order_prefix": "TEXT",
"close_method": "TEXT", "close_reason": "TEXT", "reconciliation_status": "TEXT",
"open_generation": "INTEGER", "close_generation": "INTEGER",
"earnings_date": "TEXT",
"created_at": "TEXT", "updated_at": "TEXT",
}
def init_db():
"""Additively migrate the committed database; historical rows are never replaced."""
with db(write=True) as conn:
conn.execute('''CREATE TABLE IF NOT EXISTS trades (
"Ticker" TEXT,"Implied Move" TEXT,"Structure" TEXT,"Side" TEXT,"When" TEXT,
"Size" INTEGER,"Short Symbol" TEXT,"Long Symbol" TEXT,"Open Date" TEXT,
"Open Price" REAL,"Open Comm." REAL,"Close Date" TEXT,"Close Price" REAL,"Close Comm." REAL)''')
existing = {row[1] for row in conn.execute("PRAGMA table_info(trades)")}
if "When" not in existing:
conn.execute('ALTER TABLE trades ADD COLUMN "When" TEXT')
existing.add("When")
for name, definition in TRADE_COLUMNS.items():
if name not in existing:
conn.execute(f'ALTER TABLE trades ADD COLUMN "{name}" {definition}')
conn.execute('''CREATE TABLE IF NOT EXISTS schema_migrations(
migration_name TEXT PRIMARY KEY,applied_at TEXT NOT NULL)''')
# Populate stable parent keys and make them unique before creating any
# child table that references trades(trade_id). SQLite rejects even
# otherwise-unrelated writes when a foreign key targets a non-unique
# legacy column.
historical_migration="historical_cashflow_identity_v1"
if not conn.execute("SELECT 1 FROM schema_migrations WHERE migration_name=?",(historical_migration,)).fetchone():
migrate_historical(conn)
conn.execute("INSERT INTO schema_migrations VALUES(?,?)",(historical_migration,stamp()))
trade_index=conn.execute("SELECT sql FROM sqlite_master WHERE type='index' AND name='ux_trades_id'").fetchone()
if trade_index and " WHERE " in str(trade_index[0] or "").upper():
conn.execute("DROP INDEX ux_trades_id")
trade_index=None
if not trade_index:
conn.execute("CREATE UNIQUE INDEX ux_trades_id ON trades(trade_id)")
schema_statements = (
'''CREATE TABLE IF NOT EXISTS broker_orders(
order_id TEXT PRIMARY KEY,trade_id TEXT NOT NULL,client_order_id TEXT,phase TEXT NOT NULL,
method TEXT NOT NULL,symbol TEXT,ordered_quantity INTEGER NOT NULL DEFAULT 0,
filled_quantity INTEGER NOT NULL DEFAULT 0,canceled_quantity INTEGER NOT NULL DEFAULT 0,
remaining_quantity INTEGER NOT NULL DEFAULT 0,filled_cash_flow_cents INTEGER NOT NULL DEFAULT 0,
fees_cents INTEGER NOT NULL DEFAULT 0,commission_status TEXT,limit_price TEXT,filled_avg_price TEXT,
lifecycle_status TEXT NOT NULL,terminal INTEGER NOT NULL DEFAULT 0,stop_reason TEXT,
submitted_at TEXT,updated_at TEXT NOT NULL,FOREIGN KEY(trade_id) REFERENCES trades(trade_id))''',
'''CREATE UNIQUE INDEX IF NOT EXISTS ux_broker_orders_client ON broker_orders(client_order_id) WHERE client_order_id IS NOT NULL''',
'''CREATE TABLE IF NOT EXISTS fills(
fill_id TEXT PRIMARY KEY,trade_id TEXT NOT NULL,parent_trade_id TEXT,broker_order_id TEXT,
broker_activity_id TEXT,phase TEXT NOT NULL,method TEXT NOT NULL,
cumulative_order_filled_quantity INTEGER NOT NULL,filled_quantity INTEGER NOT NULL CHECK(filled_quantity>0),
price TEXT NOT NULL,cash_flow_cents INTEGER NOT NULL,fees_cents INTEGER NOT NULL DEFAULT 0,
allocated_open_cash_flow_cents INTEGER NOT NULL DEFAULT 0,allocated_open_fees_cents INTEGER NOT NULL DEFAULT 0,
realized_pnl_cents INTEGER,commission_status TEXT,occurred_at TEXT NOT NULL,close_reason TEXT,sync_status TEXT NOT NULL DEFAULT 'pending',
FOREIGN KEY(trade_id) REFERENCES trades(trade_id),FOREIGN KEY(broker_order_id) REFERENCES broker_orders(order_id),
UNIQUE(broker_order_id,phase,cumulative_order_filled_quantity))''',
'''CREATE TABLE IF NOT EXISTS sheet_outbox(
event_id TEXT PRIMARY KEY,trade_id TEXT NOT NULL,fill_id TEXT NOT NULL,phase TEXT NOT NULL,
payload_json TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'pending',attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,
FOREIGN KEY(trade_id) REFERENCES trades(trade_id),FOREIGN KEY(fill_id) REFERENCES fills(fill_id))''',
'''CREATE TABLE IF NOT EXISTS reconciliation_runs(
reconciliation_id TEXT PRIMARY KEY,checked_at TEXT NOT NULL,result TEXT NOT NULL,
discrepancy_count INTEGER NOT NULL,summary_json TEXT NOT NULL)''',
'''CREATE TABLE IF NOT EXISTS broker_identity(
singleton_id INTEGER PRIMARY KEY CHECK(singleton_id=1),
broker_mode TEXT NOT NULL CHECK(broker_mode IN ('PAPER','LIVE')),
account_fingerprint TEXT NOT NULL,base_url TEXT NOT NULL,
bound_at TEXT NOT NULL,updated_at TEXT NOT NULL)''',
)
for statement in schema_statements:
conn.execute(statement)
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS ux_fills_activity ON fills(broker_activity_id) WHERE broker_activity_id IS NOT NULL")
for table in ("broker_orders", "fills"):
if "commission_status" not in {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}:
conn.execute(f"ALTER TABLE {table} ADD COLUMN commission_status TEXT")
# Trades synced as one row per fill are re-sent once as one row per
# trade, which lets the Sheet fold each entry/exit pair into one row.
trade_rows_migration="sheet_trade_rows_v1"
if not conn.execute("SELECT 1 FROM schema_migrations WHERE migration_name=?",(trade_rows_migration,)).fetchone():
conn.execute("UPDATE sheet_outbox SET state='pending',last_error=NULL,updated_at=? WHERE state='synced'",(stamp(),))
conn.execute("INSERT INTO schema_migrations VALUES(?,?)",(trade_rows_migration,stamp()))
def migrate_historical(conn):
seen = set()
for row in conn.execute("SELECT rowid,* FROM trades ORDER BY rowid").fetchall():
ticker = str(row["Ticker"] or "").upper()
group = "|".join(str(row[key] or "") for key in ("Ticker", "Open Date", "Short Symbol", "Long Symbol", "When"))
parent_id = row["parent_trade_id"] or str(uuid.uuid5(UUID_NAMESPACE, f"parent|{group}"))
trade_id = row["trade_id"]
if not trade_id or trade_id in seen:
fingerprint = "|".join(str(row[key] or "") for key in ("Ticker", "Open Date", "Short Symbol", "Long Symbol", "Size", "Open Price", "Close Date", "Close Price"))
trade_id = str(uuid.uuid5(UUID_NAMESPACE, f"legacy|{row['rowid']}|{fingerprint}"))
seen.add(trade_id)
size = max(0, int(row["Size"] or 0))
closed = bool(row["Close Date"])
try:
legacy_open=datetime.strptime(row["Open Date"],"%Y-%m-%d").date()
inferred_earnings=(legacy_open+timedelta(days=1) if (row["When"] or "AMC")=="BMO" else legacy_open).isoformat()
except (TypeError,ValueError):
inferred_earnings=row["Open Date"] or None
open_cf = -abs(contract_cashflow_cents(row["Open Price"] or 0, size))
open_fees = dollars_to_cents(row["Open Comm."] or 0)
close_fees = dollars_to_cents(row["Close Comm."] or 0)
close_cf = None
method, reason, recon = row["close_method"], row["close_reason"], row["reconciliation_status"]
if closed:
price = dec(row["Close Price"] or 0)
if price < 0:
close_cf, method, recon = abs(contract_cashflow_cents(price, size)), method or "legacy_calendar_credit", recon or "HISTORICAL_SIGNED_CREDIT"
elif ticker in KNOWN_DEBITS:
close_cf, method, reason, recon = -abs(contract_cashflow_cents(price, size)), method or "legacy_calendar_debit_corrected", reason or "known_signed_multi_leg_debit", recon or "HISTORICAL_KNOWN_DEBIT_CORRECTED"
elif ticker in KNOWN_SINGLE_CREDITS:
close_cf, method, reason, recon = abs(contract_cashflow_cents(price, size)), method or "legacy_single_long_credit", reason or "short_expired_long_leg_sale_inferred", recon or "HISTORICAL_INFERRED_SINGLE_LEG_CREDIT"
elif price == 0 or ticker in KNOWN_ZERO_UNVERIFIED:
close_cf, method, reason, recon = 0, method or "legacy_zero_unverified", reason or "zero_close_not_broker_confirmed", "HISTORICAL_ZERO_UNVERIFIED"
else:
close_cf, method, recon = abs(contract_cashflow_cents(price, size)), method or "legacy_positive_close_unverified", recon or "HISTORICAL_POSITIVE_CLOSE_UNCERTAIN"
realized = open_cf + close_cf - open_fees - close_fees
lifecycle = row["lifecycle_status"] or ("CLOSED_LEGACY_UNVERIFIED" if "UNVERIFIED" in recon else "CLOSED")
else:
realized, lifecycle, recon = int(row["realized_pnl_cents"] or 0), row["lifecycle_status"] or "OPEN", recon or "PENDING_BROKER_RECONCILIATION"
conn.execute('''UPDATE trades SET trade_id=?,parent_trade_id=?,ordered_quantity=COALESCE(ordered_quantity,?),
filled_quantity=COALESCE(filled_quantity,?),closed_quantity=COALESCE(closed_quantity,?),
remaining_quantity=COALESCE(remaining_quantity,?),lifecycle_status=?,open_sync_status=COALESCE(open_sync_status,'legacy'),
close_sync_status=COALESCE(close_sync_status,?),opening_cash_flow_cents=COALESCE(opening_cash_flow_cents,?),
closing_cash_flow_cents=COALESCE(closing_cash_flow_cents,?),opening_fees_cents=COALESCE(opening_fees_cents,?),
closing_fees_cents=COALESCE(closing_fees_cents,?),allocated_open_cash_flow_cents=COALESCE(allocated_open_cash_flow_cents,?),
allocated_open_fees_cents=COALESCE(allocated_open_fees_cents,?),realized_pnl_cents=COALESCE(realized_pnl_cents,?),
close_method=?,close_reason=?,reconciliation_status=?,earnings_date=COALESCE(earnings_date,?),
created_at=COALESCE(created_at,?),updated_at=COALESCE(updated_at,?) WHERE rowid=?''',
(trade_id,parent_id,size,size,size if closed else 0,0 if closed else size,lifecycle,"legacy" if closed else "not_applicable",
open_cf,close_cf,open_fees,close_fees,open_cf if closed else 0,open_fees if closed else 0,realized,
method,reason,recon,inferred_earnings,stamp(),stamp(),row["rowid"]))
def configured_mode():
mode_setting=os.environ.get("ALPACA_PAPER")
if mode_setting not in {"true","false"}:
raise OperationalFailure("ALPACA_PAPER must be explicitly set to exactly 'true' or 'false'")
paper_mode=mode_setting=="true"
configured_url=os.environ.get("APCA_API_BASE_URL","").strip().rstrip("/")
expected_url="https://paper-api.alpaca.markets" if paper_mode else "https://api.alpaca.markets"
if configured_url!=expected_url:
raise OperationalFailure(
f"APCA_API_BASE_URL must be exactly '{expected_url}' when ALPACA_PAPER is '{mode_setting}'"
)
if not paper_mode:
explicit_path=(TRADES_DB_PATH_SETTING or "").strip()
if not explicit_path:
raise OperationalFailure("LIVE mode requires an explicit TRADES_DB_PATH distinct from trades.db")
configured_path=Path(explicit_path).expanduser().resolve()
if os.path.normcase(str(configured_path))==os.path.normcase(str(DEFAULT_DB_PATH.resolve())):
raise OperationalFailure("LIVE mode cannot use the default trades.db ledger; set a distinct TRADES_DB_PATH")
return mode_setting,"PAPER" if paper_mode else "LIVE",expected_url
def configured_broker_client():
_,mode_name,_=configured_mode()
if not os.environ.get("APCA_API_KEY_ID") or not os.environ.get("APCA_API_SECRET_KEY"):
raise OperationalFailure("Alpaca credentials are unavailable for the explicitly selected account mode")
client = init_alpaca_client()
if client is None:
raise OperationalFailure("Could not initialize the explicitly configured Alpaca client")
print(f"Confirmed Alpaca {mode_name} mode and matching endpoint.")
return client,mode_name
def broker_account_identity(client):
try:
account=client.get_account()
except Exception as exc:
raise OperationalFailure(f"Unable to identify the configured Alpaca account: {redact(exc)}") from exc
account_id=str(getattr(account,"id","") or "").strip()
if not account_id:
raise OperationalFailure("Configured Alpaca account response did not contain an account ID")
# Persist only a one-way identity binding; the raw broker account ID never
# enters the SQLite database, outbox, logs, or reconciliation reports.
return hashlib.sha256(f"alpaca-account-id:v1:{account_id}".encode("utf-8")).hexdigest()
def bind_or_validate_broker_identity(client, broker_mode, *, allow_bind):
"""Bind once, then reject mode/account/path drift before any reconciliation or submit."""
_,configured_name,base_url=configured_mode()
if broker_mode!=configured_name:
raise OperationalFailure("Configured broker mode changed during workflow startup")
fingerprint=broker_account_identity(client)
with db(write=allow_bind,read_only=not allow_bind) as conn:
table_exists=conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='broker_identity'"
).fetchone()
if not table_exists:
raise OperationalFailure(
"SQLite ledger has no broker identity metadata; run --migrate-only, then --bind-only"
)
bound=conn.execute("SELECT * FROM broker_identity WHERE singleton_id=1").fetchone()
if bound:
if bound["broker_mode"]!=broker_mode:
raise ReconciliationFailure(
f"Ledger is bound to {bound['broker_mode']} mode and cannot be used in {broker_mode} mode"
)
if bound["account_fingerprint"]!=fingerprint:
raise ReconciliationFailure(
"Configured Alpaca account does not match the account bound to this ledger "
f"(expected fingerprint {bound['account_fingerprint']}, got {fingerprint})"
)
if str(bound["base_url"]).rstrip("/")!=base_url:
raise ReconciliationFailure("Configured Alpaca endpoint does not match the endpoint bound to this ledger")
return {
"broker_mode":bound["broker_mode"],"account_fingerprint":bound["account_fingerprint"],
"base_url":bound["base_url"],
}
if not allow_bind:
raise ReconciliationFailure(
"Ledger is not bound to an Alpaca account; run --bind-only before read-only reconciliation"
)
trade_count=int(conn.execute("SELECT COUNT(*) FROM trades").fetchone()[0])
if trade_count:
if broker_mode!="PAPER":
raise ReconciliationFailure("A nonempty unbound ledger may only be adopted by a PAPER account")
incomplete=int(conn.execute('''SELECT COUNT(*) FROM trades WHERE
trade_id IS NULL OR trim(trade_id)='' OR lifecycle_status IS NULL OR trim(lifecycle_status)=''
OR reconciliation_status IS NULL OR trim(reconciliation_status)='' ''').fetchone()[0])
unresolved=int(conn.execute('''SELECT COUNT(*) FROM trades WHERE
COALESCE(remaining_quantity,0)>0 OR lifecycle_status NOT LIKE 'CLOSED%' ''').fetchone()[0])
operational=sum(int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
for table in ("broker_orders","fills","sheet_outbox","reconciliation_runs"))
if incomplete or unresolved or operational:
raise ReconciliationFailure(
"Unbound ledger contains non-historical or unresolved execution state and cannot be adopted automatically"
)
now=stamp()
conn.execute('''INSERT INTO broker_identity(singleton_id,broker_mode,account_fingerprint,
base_url,bound_at,updated_at) VALUES(1,?,?,?,?,?)''',
(broker_mode,fingerprint,base_url,now,now))
print(f"Bound SQLite ledger to Alpaca {broker_mode} account fingerprint {fingerprint}.")
return {"broker_mode":broker_mode,"account_fingerprint":fingerprint,"base_url":base_url}
def stable_trade_id(ticker, earnings_date, when, short_symbol=None, long_symbol=None):
# One logical position per account-scoped earnings event; selected contracts
# may change between retries. Historical migration deliberately keeps its
# legacy identifiers rather than rewriting user-visible history.
try:
with db(read_only=True) as conn:
identity=conn.execute(
"SELECT broker_mode,account_fingerprint FROM broker_identity WHERE singleton_id=1"
).fetchone()
except sqlite3.OperationalError as exc:
raise OperationalFailure("Cannot create a Trade ID before --migrate-only and --bind-only") from exc
if not identity:
raise OperationalFailure("Cannot create a Trade ID before the ledger is bound with --bind-only")
material=(f"trade-v2|{identity['broker_mode']}|{identity['account_fingerprint']}|"
f"{str(ticker).upper()}|{earnings_date}|{str(when).upper()}")
return str(uuid.uuid5(UUID_NAMESPACE,material))
def client_prefix(phase, trade_id, generation, method="calendar_spread"):
code = "o" if phase == "open" else ("c" if method == "calendar_spread" else "s")
return f"eta-{code}-{uuid.UUID(trade_id).hex[:20]}-{generation:04d}"
def reserve_operation_prefix(trade_id, phase, method):
"""Reserve a durable generation after proving earlier attempts are terminal."""
generation_column = "open_generation" if phase == "open" else "close_generation"
prefix_column = "open_client_order_prefix" if phase == "open" else "close_client_order_prefix"
with db(write=True) as conn:
active = conn.execute(
"SELECT order_id FROM broker_orders WHERE trade_id=? AND phase=? AND terminal=0 LIMIT 1",
(trade_id, phase),
).fetchone()
if active:
raise ReconciliationFailure(f"Trade {trade_id} still has an active {phase} order")
row = conn.execute(f"SELECT COALESCE({generation_column},0) FROM trades WHERE trade_id=?", (trade_id,)).fetchone()
if not row:
raise OperationalFailure(f"Cannot reserve order generation for unknown trade {trade_id}")
generation = int(row[0]) + 1
prefix = client_prefix(phase, trade_id, generation, method)
conn.execute(
f"UPDATE trades SET {generation_column}=?,{prefix_column}=?,updated_at=? WHERE trade_id=?",
(generation, prefix, stamp(), trade_id),
)
return prefix
def order_id(order):
return str(getattr(order, "order_id", None) or getattr(order, "id", None) or "")
def quantities(order, context=None):
context = context or {}
ordered = int(dec(getattr(order, "ordered_qty", None) or getattr(order, "qty", None) or context.get("quantity", 0)))
filled = int(dec(getattr(order, "filled_qty", 0) or 0))
canceled = int(dec(getattr(order, "canceled_qty", 0) or 0))
raw_remaining = getattr(order, "remaining_qty", None)
remaining = max(0, ordered-filled-canceled) if raw_remaining is None else int(dec(raw_remaining))
return ordered, filled, canceled, remaining
def validate_order_identity(conn, oid, trade_id, phase, method, client_id=None):
existing=conn.execute("SELECT trade_id,phase,method,client_order_id FROM broker_orders WHERE order_id=?",(oid,)).fetchone()
if existing and (existing["trade_id"]!=trade_id or existing["phase"]!=phase or existing["method"]!=method):
raise ReconciliationFailure(f"Broker order {oid} conflicts with its stored trade identity")
if existing and client_id and existing["client_order_id"] and existing["client_order_id"]!=client_id:
raise ReconciliationFailure(f"Broker order {oid} conflicts with its stored client order ID")
if client_id:
alias=conn.execute("SELECT order_id,trade_id FROM broker_orders WHERE client_order_id=? AND order_id!=?",(client_id,oid)).fetchone()
if alias:
raise ReconciliationFailure(f"Client order ID aliases broker order {alias['order_id']} on trade {alias['trade_id']}")
def create_planned_trade(data, ordered_quantity):
trade_id = data["trade_id"]
with db(write=True) as conn:
existing = conn.execute("SELECT * FROM trades WHERE trade_id=?", (trade_id,)).fetchone()
if existing:
active=conn.execute("SELECT 1 FROM broker_orders WHERE trade_id=? AND phase='open' AND terminal=0 LIMIT 1",(trade_id,)).fetchone()
retryable=int(existing["filled_quantity"] or 0)==0 and int(existing["remaining_quantity"] or 0)==0 and existing["lifecycle_status"] in {"PLANNED","CANCELED_NO_FILL"}
if active:
raise ReconciliationFailure(f"Trade {trade_id} has an unresolved active opening order")
# Earlier orders stay in the broker's history and are checked against
# this plan, so a retry keeps their contracts and the plan's size
# never drops below theirs; a smaller retry still submits its own size.
largest=int(conn.execute("SELECT COALESCE(MAX(ordered_quantity),0) FROM broker_orders WHERE trade_id=? AND phase='open'",(trade_id,)).fetchone()[0])
contracts=lambda row:(str(row["Short Symbol"] or "").upper(),str(row["Long Symbol"] or "").upper())
if retryable and largest and contracts(existing)!=contracts(data):
print(f"Skipping earnings event {trade_id}: its earlier orders were for other contracts.")
return "skipped"
if not retryable:
print(f"Skipping earnings event {trade_id}: durable state already exists.")
return "skipped"
conn.execute('''UPDATE trades SET "Implied Move"=?,"Short Symbol"=?,"Long Symbol"=?,"Open Date"=?,earnings_date=?,
ordered_quantity=?,lifecycle_status='PLANNED',reconciliation_status='ORDER_NOT_SUBMITTED',updated_at=? WHERE trade_id=?''',
(data.get("Implied Move"),data["Short Symbol"],data["Long Symbol"],data["Open Date"],data["earnings_date"],max(ordered_quantity,largest),stamp(),trade_id))
return "reused"
conn.execute('''INSERT INTO trades("Ticker","Implied Move","Structure","Side","When","Size","Short Symbol","Long Symbol",
"Open Date","Open Comm.","Close Date","Close Comm.",trade_id,parent_trade_id,ordered_quantity,filled_quantity,
closed_quantity,remaining_quantity,lifecycle_status,open_sync_status,close_sync_status,opening_cash_flow_cents,
closing_cash_flow_cents,opening_fees_cents,closing_fees_cents,allocated_open_cash_flow_cents,
allocated_open_fees_cents,realized_pnl_cents,open_client_order_prefix,reconciliation_status,earnings_date,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(data["Ticker"],data.get("Implied Move"),"Calendar Spread","debit",data["When"],0,data["Short Symbol"],
data["Long Symbol"],data["Open Date"],0,"",0,trade_id,trade_id,ordered_quantity,0,0,0,"PLANNED",
"not_applicable","not_applicable",0,0,0,0,0,0,0,None,"ORDER_NOT_SUBMITTED",data["earnings_date"],stamp(),stamp()))
return "created"
def upsert_submitted_order(trade_id, phase, method, order, context=None):
oid = order_id(order)
if not oid:
raise OperationalFailure("Broker submission returned no order ID")
context = context or {}
ordered, filled, canceled, remaining = quantities(order, context)
if context.get("submission_only"):
# The terminal callback owns fill accounting, even if submit already reports a fill.
filled,canceled,remaining=0,0,ordered
client_id = str(getattr(order,"client_order_id",None) or context.get("client_order_id") or "") or None
with db(write=True) as conn:
if not conn.execute("SELECT 1 FROM trades WHERE trade_id=?",(trade_id,)).fetchone():
raise OperationalFailure(f"Unknown trade {trade_id} for submitted order")
validate_order_identity(conn,oid,trade_id,phase,method,client_id)
conn.execute('''INSERT INTO broker_orders(order_id,trade_id,client_order_id,phase,method,symbol,ordered_quantity,
filled_quantity,canceled_quantity,remaining_quantity,lifecycle_status,terminal,limit_price,submitted_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(order_id) DO UPDATE SET
client_order_id=COALESCE(excluded.client_order_id,broker_orders.client_order_id),ordered_quantity=MAX(broker_orders.ordered_quantity,excluded.ordered_quantity),
filled_quantity=MAX(broker_orders.filled_quantity,excluded.filled_quantity),canceled_quantity=MAX(broker_orders.canceled_quantity,excluded.canceled_quantity),
remaining_quantity=excluded.remaining_quantity,lifecycle_status=excluded.lifecycle_status,terminal=excluded.terminal,updated_at=excluded.updated_at''',
(oid,trade_id,client_id,phase,method,str(getattr(order,"symbol",None) or context.get("symbol") or ""),ordered,filled,canceled,
remaining,enum_text(getattr(order,"status","submitted")) or "submitted",int(is_terminal_order(order)),
str(context.get("limit_price", "")),stamp(getattr(order,"submitted_at",None)),stamp()))
column = "open_order_id" if phase=="open" else "close_order_id"
if not is_terminal_order(order):
conn.execute(f"UPDATE trades SET {column}=?,lifecycle_status=?,updated_at=? WHERE trade_id=?",
(oid,"OPEN_ORDER_SUBMITTED" if phase=="open" else "CLOSE_ORDER_SUBMITTED",stamp(),trade_id))
else:
conn.execute(f"UPDATE trades SET {column}=?,updated_at=? WHERE trade_id=?",(oid,stamp(),trade_id))
return oid
def finalize_execution(trade_id, result):
"""Persist terminal state for zero-fill and filled attempts alike."""
for attempt in getattr(result, "attempts", ()) or ():
oid=order_id(attempt)
if not oid: continue
ordered,filled,canceled,remaining=quantities(attempt)
with db(write=True) as conn:
conn.execute('''UPDATE broker_orders SET ordered_quantity=?,filled_quantity=MAX(filled_quantity,?),
canceled_quantity=?,remaining_quantity=?,lifecycle_status=?,terminal=?,stop_reason=?,updated_at=?
WHERE order_id=? AND trade_id=?''',
(ordered,filled,canceled,remaining,enum_text(getattr(attempt,"status","")),int(is_terminal_order(attempt)),
str(getattr(attempt,"stop_reason","") or ""),stamp(),oid,trade_id))
def fill_cashflow(phase, method, price, quantity):
signed = contract_cashflow_cents(price, quantity)
if phase == "open":
return -abs(signed)
if method == "calendar_spread":
return -signed # Alpaca MLEG: positive debit, negative credit.
if method == "single_long_sell":
return abs(signed)
if method == "single_short_buy":
return -abs(signed)
raise OperationalFailure(f"Unknown cash-flow method {method}")
def pnl_status(realized_pnl_cents, commission_status):
if realized_pnl_cents is None:
return "NOT_REALIZED"
return "PROVISIONAL_FEES_UNKNOWN" if commission_status=="unavailable" else "CONFIRMED"
def average_price(cash_flow_cents, quantity):
"""Average per-share price of fills totalling a cash flow in cents.
Like the Sheet's earlier rows, a price paid is positive and a credit
received is negative.
"""
if not quantity:
return ""
return float((Decimal(-int(cash_flow_cents)) / Decimal(int(quantity) * 10000)).quantize(Decimal("0.0001")).normalize())
def trade_sheet_payload(conn, trade_id):
"""Build the trade's single Sheet row from the ledger's current totals.
Every fill of the trade is summed, so replaying a sync rewrites the same row
with the same values. Entry and exit share the row; while contracts remain
the exit columns stay empty and the Sheet shows the trade as OPEN.
"""
trade=conn.execute("SELECT * FROM trades WHERE trade_id=?",(trade_id,)).fetchone()
if not trade:
raise OperationalFailure(f"Cannot build a Sheet row for unknown trade {trade_id}")
identity=conn.execute("SELECT broker_mode,account_fingerprint FROM broker_identity WHERE singleton_id=1").fetchone()
if not identity:
raise OperationalFailure("Cannot sync a Sheet row before the ledger is bound to a broker account")
fills=conn.execute("SELECT * FROM fills WHERE trade_id=? ORDER BY occurred_at,fill_id",(trade_id,)).fetchall()
opening=[fill for fill in fills if fill["phase"]=="open"]
closing=[fill for fill in fills if fill["phase"]=="close"]
opened=sum(int(fill["filled_quantity"]) for fill in opening)
closed=sum(int(fill["filled_quantity"]) for fill in closing)
if not opened:
raise OperationalFailure(f"Trade {trade_id} has no opening fill to show in the Sheet")
open_cf=sum(int(fill["cash_flow_cents"]) for fill in opening)
close_cf=sum(int(fill["cash_flow_cents"]) for fill in closing)
open_fees=sum(int(fill["fees_cents"]) for fill in opening)
close_fees=sum(int(fill["fees_cents"]) for fill in closing)
fees_unknown=any(fill["commission_status"]=="unavailable" for fill in fills)
realized=sum(int(fill["realized_pnl_cents"] or 0) for fill in closing) if closing else None
remaining=opened-closed
order_ids=list(dict.fromkeys(fill["broker_order_id"] for fill in fills if fill["broker_order_id"]))
return {
"action":"upsert","Sync Type":"trade","Trade ID":trade["trade_id"],
"Parent Trade ID":trade["parent_trade_id"] or trade["trade_id"],
# Clearing the per-fill keys lets the Sheet fold older fill rows into this one.
"Record ID":"","Broker Fill ID":"","Fill Phase":"",
"Broker Order ID":", ".join(order_ids),
"Broker Mode":identity["broker_mode"],"Broker Account Fingerprint":identity["account_fingerprint"],
"Ticker":trade["Ticker"],"Implied Move":trade["Implied Move"] or "","Structure":trade["Structure"] or "Calendar Spread",
"Side":trade["Side"] or "debit","When":trade["When"] or "","Size":opened,
"Short Symbol":trade["Short Symbol"] or "","Long Symbol":trade["Long Symbol"] or "",
"Open Date":trade["Open Date"] or "","Open Price":average_price(open_cf,opened),
"Open Comm.":cents_to_dollars(open_fees),
"Close Date":(trade["Close Date"] or "") if remaining==0 else "",
"Close Price":average_price(close_cf,closed),"Close Comm.":cents_to_dollars(close_fees),
"Ordered Quantity":int(trade["ordered_quantity"] or opened),"Filled Quantity":opened,
"Remaining Quantity":remaining,"Lifecycle Status":trade["lifecycle_status"] or "",
"Open Sync Status":"synced","Close Sync Status":"synced" if closed else "not_applicable",
"Open Cash Flow":cents_to_dollars(open_cf),"Close Cash Flow":cents_to_dollars(close_cf),
"Fees":"" if fees_unknown else cents_to_dollars(open_fees+close_fees),
"Realized P&L":"" if realized is None else cents_to_dollars(realized),
"P&L Status":pnl_status(realized if remaining==0 else None,"unavailable" if fees_unknown else "confirmed"),
"Close Method":trade["close_method"] or "","Close Reason":trade["close_reason"] or "",
}
def enqueue_fill(conn, trade_id, fill_id, phase):
payload = json.dumps(trade_sheet_payload(conn,trade_id),separators=(",",":"))
conn.execute('''INSERT INTO sheet_outbox(event_id,trade_id,fill_id,phase,payload_json,state,attempts,created_at,updated_at)
VALUES(?,?,?,?,?,'pending',0,?,?) ON CONFLICT(event_id) DO UPDATE SET payload_json=excluded.payload_json,
state=CASE WHEN sheet_outbox.state='synced' THEN 'synced' ELSE 'pending' END,updated_at=excluded.updated_at''',
(fill_id,trade_id,fill_id,phase,payload,stamp(),stamp()))
def persist_unpriced_order_state(trade_id, phase, method, order, stop_reason):
"""Persist observed broker quantities/state without fabricating fill cashflow."""
oid=order_id(order)
ordered,cumulative,canceled,order_remaining=quantities(order)
client_id=str(getattr(order,"client_order_id","") or "") or None
with db(write=True) as conn:
if not conn.execute("SELECT 1 FROM trades WHERE trade_id=?",(trade_id,)).fetchone():
raise OperationalFailure(f"Unknown trade {trade_id} for broker order state")
validate_order_identity(conn,oid,trade_id,phase,method,client_id)
previous=conn.execute("SELECT filled_quantity FROM broker_orders WHERE order_id=?",(oid,)).fetchone()
if previous and cumulative<int(previous["filled_quantity"] or 0):
raise OperationalFailure(f"Broker order {oid} cumulative fill moved backwards")
now=stamp(getattr(order,"updated_at",None))
conn.execute('''INSERT INTO broker_orders(order_id,trade_id,client_order_id,phase,method,symbol,ordered_quantity,
filled_quantity,canceled_quantity,remaining_quantity,filled_cash_flow_cents,fees_cents,commission_status,
lifecycle_status,terminal,stop_reason,submitted_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(order_id) DO UPDATE SET
client_order_id=COALESCE(broker_orders.client_order_id,excluded.client_order_id),
ordered_quantity=excluded.ordered_quantity,filled_quantity=excluded.filled_quantity,
canceled_quantity=excluded.canceled_quantity,remaining_quantity=excluded.remaining_quantity,
lifecycle_status=excluded.lifecycle_status,terminal=excluded.terminal,
stop_reason=excluded.stop_reason,updated_at=excluded.updated_at''',
(oid,trade_id,client_id,phase,method,str(getattr(order,"symbol","") or ""),ordered,cumulative,canceled,
order_remaining,0,0,"unavailable",enum_text(getattr(order,"status","")) or "unknown",
int(is_terminal_order(order)),stop_reason,now,now))
column="open_order_id" if phase=="open" else "close_order_id"
conn.execute(f"UPDATE trades SET {column}=?,reconciliation_status=?,updated_at=? WHERE trade_id=?",
(oid,stop_reason,now,trade_id))
def record_fill(trade_id, phase, method, order, close_reason=None):
"""Persist only the newly confirmed cumulative fill delta for one broker order."""
oid = order_id(order)
if not oid:
raise OperationalFailure("Confirmed fill has no broker order ID")
ordered,cumulative,canceled,order_remaining = quantities(order)
if cumulative <= 0:
persist_unpriced_order_state(
trade_id,phase,method,order,str(getattr(order,"stop_reason","") or "no_fill")
)
return None
raw_price=getattr(order,"average_fill_price",None)
if raw_price in (None, ""):
raw_price=getattr(order,"filled_avg_price",None)
if raw_price in (None, ""):
reason="FILLED_QUANTITY_WITHOUT_BROKER_PRICE"
persist_unpriced_order_state(trade_id,phase,method,order,reason)
raise ReconciliationFailure(
f"Broker order {oid} reports {cumulative} filled contract(s) without an average fill price; cashflow accounting is deferred"
)
price = dec(raw_price)
if not price.is_finite():
reason="FILLED_QUANTITY_WITH_INVALID_BROKER_PRICE"
persist_unpriced_order_state(trade_id,phase,method,order,reason)
raise ReconciliationFailure(
f"Broker order {oid} reports a non-finite average fill price; cashflow accounting is deferred"
)
commission_value=getattr(order,"commission_amount",None)
if commission_value is None: commission_value=getattr(order,"commission",None)
fees_total=dollars_to_cents(commission_value or 0)
commission_status="confirmed" if commission_value is not None else "unavailable"
activity = str(getattr(order,"broker_fill_id",None) or getattr(order,"activity_id",None) or getattr(order,"fill_activity_id",None) or "") or None
occurred = stamp(getattr(order,"filled_at",None) or getattr(order,"updated_at",None))
with db(write=True) as conn:
trade = conn.execute("SELECT * FROM trades WHERE trade_id=?",(trade_id,)).fetchone()
if not trade:
raise OperationalFailure(f"Unknown trade {trade_id} for confirmed fill")
client_id=str(getattr(order,"client_order_id","") or "") or None
validate_order_identity(conn,oid,trade_id,phase,method,client_id)
previous = conn.execute("SELECT * FROM broker_orders WHERE order_id=?",(oid,)).fetchone()
accounted=conn.execute('''SELECT COALESCE(MAX(cumulative_order_filled_quantity),0) AS quantity,
COALESCE(SUM(cash_flow_cents),0) AS cashflow,COALESCE(SUM(fees_cents),0) AS fees
FROM fills WHERE broker_order_id=? AND phase=?''',(oid,phase)).fetchone()
old_qty=int(accounted["quantity"] or 0)
old_cf=int(accounted["cashflow"] or 0)
old_fees=int(accounted["fees"] or 0)
if (previous and cumulative<int(previous["filled_quantity"] or 0)) or cumulative < old_qty:
raise OperationalFailure(f"Broker order {oid} cumulative fill moved backwards")
delta_qty = cumulative-old_qty
cumulative_cf = fill_cashflow(phase,method,price,cumulative)
delta_cf,delta_fees = cumulative_cf-old_cf,max(0,fees_total-old_fees)
conn.execute('''INSERT INTO broker_orders(order_id,trade_id,client_order_id,phase,method,symbol,ordered_quantity,
filled_quantity,canceled_quantity,remaining_quantity,filled_cash_flow_cents,fees_cents,commission_status,filled_avg_price,
lifecycle_status,terminal,stop_reason,submitted_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(order_id) DO UPDATE SET client_order_id=COALESCE(broker_orders.client_order_id,excluded.client_order_id),
filled_quantity=excluded.filled_quantity,canceled_quantity=excluded.canceled_quantity,
remaining_quantity=excluded.remaining_quantity,filled_cash_flow_cents=excluded.filled_cash_flow_cents,fees_cents=excluded.fees_cents,
commission_status=excluded.commission_status,
filled_avg_price=excluded.filled_avg_price,lifecycle_status=excluded.lifecycle_status,terminal=excluded.terminal,
stop_reason=excluded.stop_reason,updated_at=excluded.updated_at''',
(oid,trade_id,client_id,phase,method,str(getattr(order,"symbol","") or ""),
ordered,cumulative,canceled,order_remaining,cumulative_cf,fees_total,commission_status,str(price),enum_text(getattr(order,"status","filled")) or "filled",
int(is_terminal_order(order)),str(getattr(order,"stop_reason","") or ""),occurred,occurred))
if delta_qty == 0:
if cumulative_cf!=old_cf:
raise ReconciliationFailure(f"Broker order {oid} changed its accounted cumulative fill price")
return None
if phase == "open":
available=max(0,int(trade["ordered_quantity"] or 0)-int(trade["filled_quantity"] or 0))
if delta_qty>available:
raise OperationalFailure(f"Opening fill exceeds trade {trade_id} ordered quantity")
alloc_cf=alloc_fees=0
realized=None
else:
remaining=int(trade["remaining_quantity"] or 0)
filled_total=int(trade["filled_quantity"] or 0)
if delta_qty>remaining or filled_total<=0:
raise OperationalFailure(f"Closing fill exceeds trade {trade_id} remaining quantity")
used_cf=int(trade["allocated_open_cash_flow_cents"] or 0)
used_fees=int(trade["allocated_open_fees_cents"] or 0)
open_cf=int(trade["opening_cash_flow_cents"] or 0)
open_fees=int(trade["opening_fees_cents"] or 0)
if conn.execute("SELECT 1 FROM fills WHERE trade_id=? AND phase='open' AND commission_status='unavailable' LIMIT 1",(trade_id,)).fetchone():
commission_status="unavailable"
if delta_qty==remaining:
alloc_cf,alloc_fees=open_cf-used_cf,open_fees-used_fees
else:
alloc_cf=int((Decimal(open_cf)*delta_qty/filled_total).quantize(Decimal("1"),rounding=ROUND_HALF_UP))
alloc_fees=int((Decimal(open_fees)*delta_qty/filled_total).quantize(Decimal("1"),rounding=ROUND_HALF_UP))
realized=alloc_cf+delta_cf-alloc_fees-delta_fees
fill_id=activity or str(uuid.uuid5(UUID_NAMESPACE,f"fill|{oid}|{phase}|{cumulative}"))
conn.execute('''INSERT INTO fills(fill_id,trade_id,parent_trade_id,broker_order_id,broker_activity_id,phase,method,
cumulative_order_filled_quantity,filled_quantity,price,cash_flow_cents,fees_cents,allocated_open_cash_flow_cents,
allocated_open_fees_cents,realized_pnl_cents,commission_status,occurred_at,close_reason,sync_status)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'pending')''',
(fill_id,trade_id,trade["parent_trade_id"] or trade_id,oid,activity,phase,method,cumulative,delta_qty,str(price),
delta_cf,delta_fees,alloc_cf,alloc_fees,realized,commission_status,occurred,close_reason))
if phase=="open":
new_filled=int(trade["filled_quantity"] or 0)+delta_qty
new_remaining=int(trade["remaining_quantity"] or 0)+delta_qty
new_cf=int(trade["opening_cash_flow_cents"] or 0)+delta_cf
new_fees=int(trade["opening_fees_cents"] or 0)+delta_fees
avg=Decimal(abs(new_cf))/Decimal(new_filled*10000)
conn.execute('''UPDATE trades SET "Size"=?,"Open Price"=?,"Open Comm."=?,filled_quantity=?,remaining_quantity=?,
opening_cash_flow_cents=?,opening_fees_cents=?,lifecycle_status='OPEN',open_sync_status='pending',
reconciliation_status='PENDING_RECONCILIATION',updated_at=? WHERE trade_id=?''',
(new_filled,float(avg),cents_to_dollars(new_fees),new_filled,new_remaining,new_cf,new_fees,stamp(),trade_id))
else:
new_closed=int(trade["closed_quantity"] or 0)+delta_qty
new_remaining=int(trade["remaining_quantity"] or 0)-delta_qty
close_cf=int(trade["closing_cash_flow_cents"] or 0)+delta_cf
close_fees=int(trade["closing_fees_cents"] or 0)+delta_fees
total_pnl=int(trade["realized_pnl_cents"] or 0)+int(realized)
lifecycle="CLOSED" if new_remaining==0 else "PARTIALLY_CLOSED"
close_date=datetime.now(EASTERN).date().isoformat() if new_remaining==0 else ""
conn.execute('''UPDATE trades SET "Close Date"=?,"Close Price"=?,"Close Comm."=?,closed_quantity=?,remaining_quantity=?,
closing_cash_flow_cents=?,closing_fees_cents=?,allocated_open_cash_flow_cents=allocated_open_cash_flow_cents+?,
allocated_open_fees_cents=allocated_open_fees_cents+?,realized_pnl_cents=?,lifecycle_status=?,close_sync_status='pending',
close_method=?,close_reason=?,reconciliation_status=?,updated_at=? WHERE trade_id=?''',
(close_date,float(price),cents_to_dollars(close_fees),new_closed,new_remaining,close_cf,close_fees,alloc_cf,alloc_fees,
total_pnl,lifecycle,method,close_reason or "confirmed_fill",
"PENDING_FEE_RECONCILIATION" if commission_status=="unavailable" else "PENDING_RECONCILIATION",stamp(),trade_id))
enqueue_fill(conn,trade_id,fill_id,phase)
return fill_id
# Apps Script answers in about 10 s, so the read timeout leaves room for a
# slow answer; a retried request only rewrites the same trade row.
SHEET_REQUEST_TIMEOUT=(5,30)
SHEET_REQUEST_ATTEMPTS=3
# The longest one trade can take when every attempt times out.
SHEET_TRADE_MAX_SECONDS=SHEET_REQUEST_ATTEMPTS*sum(SHEET_REQUEST_TIMEOUT)+sum(range(1,SHEET_REQUEST_ATTEMPTS))
# Time --sync-sheet may spend; the workflow step's timeout-minutes must exceed it.
SHEET_SYNC_SECONDS=300
def sync_sheet_outbox(*, deadline=None):
"""Send queued trade rows, oldest first, until none remain or the deadline is near."""
if not GOOGLE_SCRIPT_URL or not GOOGLE_SCRIPT_SECRET:
print("Google Sheet synchronization is not configured; queued events remain in SQLite.")
return True
if not re.fullmatch(r"https://script\.google\.com/macros/s/[^/]+/exec", GOOGLE_SCRIPT_URL):
raise OperationalFailure("GOOGLE_SCRIPT_URL is not a valid Apps Script web deployment URL")
with db() as conn:
events=conn.execute('''SELECT event_id,trade_id,fill_id FROM sheet_outbox
WHERE state!='synced' ORDER BY created_at''').fetchall()
# Every pending fill of a trade is covered by one snapshot of that trade's row.
trades={}
for event in events:
trades.setdefault(event["trade_id"],[]).append(event)
failures=[]
processed=0
for trade_id,trade_events in trades.items():
# Start a trade only if all of its retries can finish before the deadline.
if deadline is not None and time_module.monotonic()+SHEET_TRADE_MAX_SECONDS>=deadline:
break
processed+=1
with db() as conn:
payload=trade_sheet_payload(conn,trade_id)
payload["auth_token"]=GOOGLE_SCRIPT_SECRET
ok=False; last_error="unknown error"
for attempt in range(1,SHEET_REQUEST_ATTEMPTS+1):
try:
response=requests.post(GOOGLE_SCRIPT_URL,json=payload,timeout=SHEET_REQUEST_TIMEOUT)
response.raise_for_status()
body=response.json()
if not isinstance(body,dict) or body.get("ok") is not True or body.get("status") != 200:
raise OperationalFailure(f"Sheet logical error: {body.get('error') if isinstance(body,dict) else 'invalid JSON'}")
if body.get("layout")!=SHEET_LAYOUT:
raise OperationalFailure(
"The Sheet's Apps Script does not support one row per trade; deploy the current code.gs"
)
written_headers=set(body.get("written_headers") or ())
missing_written=SHEET_REQUIRED_TRADE_FIELDS-written_headers
if missing_written:
raise OperationalFailure(
"Sheet response did not confirm all required trade fields: "
+ ", ".join(sorted(missing_written))
)
ok=True; break
except (requests.RequestException,ValueError,OperationalFailure) as exc:
last_error=redact(exc)
if attempt<SHEET_REQUEST_ATTEMPTS: time_module.sleep(attempt)
event_ids=[event["event_id"] for event in trade_events]
marks=",".join("?"*len(event_ids))
with db(write=True) as conn:
if ok:
conn.execute(f"UPDATE sheet_outbox SET state='synced',attempts=attempts+1,last_error=NULL,updated_at=? WHERE event_id IN ({marks})",(stamp(),*event_ids))
conn.execute(f"UPDATE fills SET sync_status='synced' WHERE fill_id IN ({marks})",tuple(event["fill_id"] for event in trade_events))
for phase,column in (("open","open_sync_status"),("close","close_sync_status")):
if conn.execute("SELECT 1 FROM fills WHERE trade_id=? AND phase=? LIMIT 1",(trade_id,phase)).fetchone():
pending=conn.execute("SELECT COUNT(*) FROM sheet_outbox WHERE trade_id=? AND phase=? AND state!='synced'",(trade_id,phase)).fetchone()[0]
conn.execute(f"UPDATE trades SET {column}=? WHERE trade_id=?",("pending" if pending else "synced",trade_id))
else:
conn.execute(f"UPDATE sheet_outbox SET state='pending',attempts=attempts+1,last_error=?,updated_at=? WHERE event_id IN ({marks})",(last_error,stamp(),*event_ids))
failures.append(f"{trade_id}: {last_error}")
deferred=max(0,len(trades)-processed)
if deferred:
print(f"Deferred {deferred} trade(s) of Sheet updates to a later run: this run's Sheet sync time budget ran out.")
if failures:
print(f"{len(failures)} trade(s) of Sheet updates remain queued: {failures[0]}", file=sys.stderr)
return False
return True
def get_open_trades(read_only=False):
with db(read_only=read_only) as conn:
return [dict(row) for row in conn.execute("SELECT * FROM trades WHERE COALESCE(remaining_quantity,0)>0 AND lifecycle_status NOT LIKE 'CLOSED%' ORDER BY \"Open Date\",rowid")]
def account_snapshot(client):
try:
account=client.get_account()
equity=dec(getattr(account,"equity",None))
buying=dec(getattr(account,"options_buying_power",None) or getattr(account,"buying_power",None) or getattr(account,"cash",None))
except Exception as exc:
raise OperationalFailure(f"Unable to fetch configured Alpaca account buying power: {redact(exc)}") from exc
if equity<=0 or buying<0:
raise OperationalFailure("Configured Alpaca account returned invalid equity or buying power")
return equity,buying
def exposure_cents():
total=0
with db() as conn:
for row in conn.execute("SELECT opening_cash_flow_cents,filled_quantity,remaining_quantity FROM trades WHERE remaining_quantity>0"):
filled=int(row["filled_quantity"] or 0)
if filled:
total+=int((Decimal(abs(int(row["opening_cash_flow_cents"] or 0)))*int(row["remaining_quantity"])/filled).quantize(Decimal("1"),rounding=ROUND_HALF_UP))
for row in conn.execute("SELECT limit_price,remaining_quantity FROM broker_orders WHERE phase='open' AND terminal=0 AND remaining_quantity>0"):
if row["limit_price"]: total+=abs(contract_cashflow_cents(row["limit_price"],row["remaining_quantity"]))
return total
def submission_headroom(order_timing):
"""Run time an admitted order needs to be waited on and cancel-confirmed."""
return order_timing["attempt_timeout"]+order_timing["cancel_timeout"]
def chase_attempts(order_timing):
"""How many attempts fit before the submit guard stops admitting orders."""
window=order_timing["overall_timeout"]-submission_headroom(order_timing)
return max(1,math.ceil(window/(order_timing["attempt_timeout"]+ORDER_ATTEMPT_OVERHEAD_SECONDS)))
def before_submit_guard(allocation_cents=0, *, opening=False, close_requires_debit=True, deadline=None, order_timing=None):
"""Check limits before each order; ``order_timing`` is the adapter's timeouts."""
if deadline is not None and order_timing is None:
raise ValueError("A submission deadline needs the order timing that sets its headroom")
headroom=None if order_timing is None else submission_headroom(order_timing)
def guard(client,context):
# Timing refusals end the chase like a price limit; the adapter stops
# with no live order. Every other refusal still fails the run.
if deadline is not None and time_module.monotonic()+headroom >= deadline:
raise SubmissionWindowClosed("Insufficient run time remains to submit and cancel-confirm an order safely","order_time_budget_reached")
reconcile_broker_state(client,read_only=True)
try: clock=client.get_clock()
except Exception as exc: raise OperationalFailure(f"Unable to refresh broker market clock: {redact(exc)}") from exc
is_open=getattr(clock,"is_open",None)
if is_open is False:
raise SubmissionWindowClosed("Market closed before order submission","market_closed")
if is_open is not True:
raise OperationalFailure("Broker clock did not report whether the market is open")
next_close=getattr(clock,"next_close",None)
now=getattr(clock,"timestamp",None) or datetime.now(timezone.utc)
if isinstance(next_close,datetime) and isinstance(now,datetime) and now>=next_close-timedelta(minutes=3):
raise SubmissionWindowClosed("Order submission cutoff reached before market close","close_cutoff_reached")
equity,buying=account_snapshot(client)
price=dec(context.get("limit_price",0)); quantity=int(context.get("quantity",0))
required=(abs(price) if opening else (max(price,Decimal(0)) if close_requires_debit else Decimal(0)))*quantity*100
required_cents=dollars_to_cents(required)
if required>buying:
raise OperationalFailure(f"Insufficient configured-account buying power for {context.get('operation','order')}: required ${required:.2f}")
if opening:
if allocation_cents and required_cents>allocation_cents:
raise OperationalFailure("Order debit exceeds the reserved per-position allocation")
aggregate_cap=dollars_to_cents(equity*MAX_AGGREGATE_EXPOSURE_PCT)
if exposure_cents()+required_cents>aggregate_cap:
raise OperationalFailure("Order would exceed the aggregate exposure cap")
# The reconciliation, clock, and account requests above are network
# calls. Re-check both boundaries immediately before the adapter is
# allowed to submit a physical order.
if isinstance(next_close,datetime) and datetime.now(timezone.utc)>=next_close-timedelta(minutes=3):
raise SubmissionWindowClosed("Order submission cutoff reached before market close","close_cutoff_reached")
if deadline is not None and time_module.monotonic()+headroom>=deadline:
raise SubmissionWindowClosed("Insufficient run time remains to submit and cancel-confirm an order safely","order_time_budget_reached")
return guard
def fetch_orders(client):
try:
from alpaca.trading.enums import QueryOrderStatus
from alpaca.trading.requests import GetOrdersRequest
open_request=GetOrdersRequest(status=QueryOrderStatus.OPEN,limit=500,nested=True)
recent_request=GetOrdersRequest(status=QueryOrderStatus.ALL,after=datetime.now(timezone.utc)-timedelta(days=30),limit=500,nested=True)
open_orders=list(client.get_orders(filter=open_request))