tradepose-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tradepose-client might be problematic. Click here for more details.

@@ -0,0 +1,762 @@
1
+ """
2
+ Trade Visualization Module
3
+
4
+ Provides Altair-based plotting functions for analyzing backtest trades data,
5
+ including MAE/MFE analysis, PnL curves, and trade distribution histograms.
6
+
7
+ All functions accept a Polars DataFrame with trades data from the API
8
+ and return interactive Altair charts.
9
+ """
10
+
11
+ import polars as pl
12
+ import altair as alt
13
+ from typing import Optional, Literal
14
+
15
+
16
+ # =============================================================================
17
+ # Helper Functions
18
+ # =============================================================================
19
+
20
+ def add_win_loss_label(trades: pl.DataFrame) -> pl.DataFrame:
21
+ """Add 'win_loss' column labeling trades as 'Win' or 'Loss'
22
+
23
+ Args:
24
+ trades: DataFrame with 'pnl' column
25
+
26
+ Returns:
27
+ DataFrame with additional 'win_loss' column
28
+ """
29
+ return trades.with_columns(
30
+ pl.when(pl.col("pnl") > 0)
31
+ .then(pl.lit("Win"))
32
+ .otherwise(pl.lit("Loss"))
33
+ .alias("win_loss")
34
+ )
35
+
36
+
37
+ def calculate_mea(trades: pl.DataFrame) -> pl.DataFrame:
38
+ """Calculate MEA (Maximum Execution Analysis) - ATR-normalized MAE/MFE ratios
39
+
40
+ Each metric is normalized by its corresponding volatility measurement:
41
+ - mae / mae_volatility -> mae_atr_ratio
42
+ - mfe / mfe_volatility -> mfe_atr_ratio
43
+ - g_mfe / g_mfe_volatility -> g_mfe_atr_ratio
44
+ - mae_lv1 / mae_lv1_volatility -> mae_lv1_atr_ratio
45
+ - mhl / mhl_volatility -> mhl_atr_ratio
46
+
47
+ Args:
48
+ trades: DataFrame with MAE/MFE and corresponding volatility columns
49
+
50
+ Returns:
51
+ DataFrame with additional ATR ratio columns
52
+
53
+ Example:
54
+ >>> trades = client.quick_backtest_results(["my_strategy"])[0]
55
+ >>> trades = calculate_mea(trades)
56
+ >>> print(trades.columns) # Will include mae_atr_ratio, mfe_atr_ratio, etc.
57
+ """
58
+ return trades.with_columns([
59
+ (pl.col("mae") / pl.col("mae_volatility")).alias("mae_atr_ratio"),
60
+ (pl.col("mfe") / pl.col("mfe_volatility")).alias("mfe_atr_ratio"),
61
+ (pl.col("g_mfe") / pl.col("g_mfe_volatility")).alias("g_mfe_atr_ratio"),
62
+ (pl.col("mae_lv1") / pl.col("mae_lv1_volatility")).alias("mae_lv1_atr_ratio"),
63
+ (pl.col("mhl") / pl.col("mhl_volatility")).alias("mhl_atr_ratio"),
64
+ ])
65
+
66
+
67
+ # Legacy alias for backward compatibility
68
+ def calculate_mae_atr_ratio(
69
+ trades: pl.DataFrame,
70
+ volatility_col: str = "entry_volatility"
71
+ ) -> pl.DataFrame:
72
+ """Legacy function - use calculate_mea() instead
73
+
74
+ This function is deprecated and will use calculate_mea() internally.
75
+ """
76
+ return calculate_mea(trades)
77
+
78
+
79
+ def calculate_cumulative_pnl(
80
+ trades: pl.DataFrame,
81
+ sort_col: str = "exit_time"
82
+ ) -> pl.DataFrame:
83
+ """Calculate cumulative PnL and drawdown metrics
84
+
85
+ Args:
86
+ trades: DataFrame with 'pnl_pct' column
87
+ sort_col: Column to sort by (default: exit_time)
88
+
89
+ Returns:
90
+ DataFrame with cumulative metrics and high-water marks
91
+ """
92
+ df = trades.sort(sort_col).with_columns([
93
+ pl.col("pnl_pct").cum_sum().alias("cum_pnl_pct"),
94
+ ])
95
+
96
+ df = df.with_columns([
97
+ pl.col("cum_pnl_pct").cum_max().alias("cummax"),
98
+ (pl.col("cum_pnl_pct").cum_max() - pl.col("cum_pnl_pct")).alias("drawdown"),
99
+ ])
100
+
101
+ # Mark new highs
102
+ df = df.with_columns([
103
+ pl.when(
104
+ (pl.col("cum_pnl_pct") == pl.col("cummax")) &
105
+ (pl.col("cum_pnl_pct") != pl.col("cum_pnl_pct").shift(1))
106
+ )
107
+ .then(pl.col("cum_pnl_pct"))
108
+ .alias("new_high")
109
+ ])
110
+
111
+ # Mark new drawdown lows (per month)
112
+ df = df.with_columns([
113
+ pl.col(sort_col).dt.strftime("%Y-%m").alias("year_month")
114
+ ])
115
+
116
+ df = df.with_columns([
117
+ pl.when(
118
+ (pl.col("drawdown") == pl.col("drawdown").max().over("year_month")) &
119
+ (pl.col("drawdown") != pl.col("drawdown").shift(1))
120
+ )
121
+ .then(pl.col("drawdown"))
122
+ .alias("new_dd")
123
+ ])
124
+
125
+ return df
126
+
127
+
128
+ def get_quantiles(
129
+ trades: pl.DataFrame,
130
+ column: str,
131
+ quantiles: list[float] = [0.25, 0.5, 0.75]
132
+ ) -> dict[str, float]:
133
+ """Calculate quantiles for a column
134
+
135
+ Args:
136
+ trades: DataFrame
137
+ column: Column name
138
+ quantiles: List of quantile values (0-1)
139
+
140
+ Returns:
141
+ Dictionary mapping quantile names to values
142
+ """
143
+ result = {}
144
+ for q in quantiles:
145
+ q_name = f"q{int(q * 100)}"
146
+ result[q_name] = trades[column].quantile(q)
147
+ return result
148
+
149
+
150
+ # =============================================================================
151
+ # Main Plotting Functions
152
+ # =============================================================================
153
+
154
+ def plot_mae_mfe_scatter(
155
+ trades: pl.DataFrame,
156
+ x_col: str = "mae_atr_ratio",
157
+ y_col: str = "g_mfe_atr_ratio",
158
+ title: str = "MAE vs MFE Analysis",
159
+ apply_config: bool = True
160
+ ) -> alt.Chart:
161
+ """Create scatter plot of MAE vs MFE with quantile reference lines
162
+
163
+ Args:
164
+ trades: DataFrame with MAE/MFE columns
165
+ x_col: X-axis column (default: mae_atr_ratio)
166
+ y_col: Y-axis column (default: g_mfe_atr_ratio)
167
+ title: Chart title
168
+ apply_config: Apply default styling config (set False for chart composition)
169
+
170
+ Returns:
171
+ Interactive Altair scatter plot
172
+
173
+ Example:
174
+ >>> trades = client.quick_backtest_results(["my_strategy"])[0]
175
+ >>> trades = calculate_mea(trades)
176
+ >>> chart = plot_mae_mfe_scatter(trades)
177
+ >>> chart.show()
178
+
179
+ >>> # For composition (no config conflicts):
180
+ >>> chart1 = plot_mae_mfe_scatter(trades, apply_config=False)
181
+ >>> chart2 = plot_pnl_curves(trades, apply_config=False)
182
+ >>> combined = chart1 | chart2
183
+ """
184
+ # Calculate ATR ratios if not present
185
+ if x_col not in trades.columns or y_col not in trades.columns:
186
+ trades = calculate_mea(trades)
187
+
188
+ # Add win/loss labels
189
+ if "win_loss" not in trades.columns:
190
+ trades = add_win_loss_label(trades)
191
+
192
+ # Calculate quantiles
193
+ x_q75 = trades[x_col].quantile(0.75)
194
+ y_q75 = trades[y_col].quantile(0.75)
195
+
196
+ # Add quantile columns for reference lines
197
+ trades = trades.with_columns([
198
+ pl.lit(x_q75).alias("x_q75"),
199
+ pl.lit(y_q75).alias("y_q75"),
200
+ ])
201
+
202
+ # Base scatter plot
203
+ scatter = alt.Chart(trades).mark_circle(size=60, opacity=0.6).encode(
204
+ x=alt.X(f"{x_col}:Q", title=x_col.replace("_", " ").title()),
205
+ y=alt.Y(f"{y_col}:Q", title=y_col.replace("_", " ").title()),
206
+ color=alt.Color(
207
+ "win_loss:N",
208
+ scale=alt.Scale(
209
+ domain=["Win", "Loss"],
210
+ range=["#00C49A", "#FF6B6B"]
211
+ ),
212
+ legend=alt.Legend(title="Result")
213
+ ),
214
+ tooltip=[
215
+ alt.Tooltip("position_id:Q", title="Position ID"),
216
+ alt.Tooltip("entry_time:T", title="Entry Time"),
217
+ alt.Tooltip(f"{x_col}:Q", title=x_col, format=".2f"),
218
+ alt.Tooltip(f"{y_col}:Q", title=y_col, format=".2f"),
219
+ alt.Tooltip("pnl:Q", title="PnL", format=".2f"),
220
+ alt.Tooltip("pnl_pct:Q", title="PnL %", format=".2%"),
221
+ ]
222
+ )
223
+
224
+ # Vertical quantile line (Q3 for x-axis)
225
+ vline = alt.Chart(trades).mark_rule(
226
+ strokeDash=[5, 5],
227
+ color="gray",
228
+ size=1
229
+ ).encode(
230
+ x="x_q75:Q"
231
+ )
232
+
233
+ # Horizontal quantile line (Q3 for y-axis)
234
+ hline = alt.Chart(trades).mark_rule(
235
+ strokeDash=[5, 5],
236
+ color="gray",
237
+ size=1
238
+ ).encode(
239
+ y="y_q75:Q"
240
+ )
241
+
242
+ # Combine layers
243
+ chart = (scatter + vline + hline).properties(
244
+ width=500,
245
+ height=500,
246
+ title=title
247
+ )
248
+
249
+ # Apply config only if requested (avoid conflicts in composition)
250
+ if apply_config:
251
+ chart = chart.configure_axis(
252
+ gridColor="#f0f0f0"
253
+ ).configure_view(
254
+ strokeWidth=0
255
+ )
256
+
257
+ return chart
258
+
259
+
260
+ def plot_mfe_mhl_analysis(
261
+ trades: pl.DataFrame,
262
+ x_col: str = "g_mfe_atr_ratio",
263
+ y_col: str = "mhl_atr_ratio",
264
+ bins: int = 40,
265
+ title: str = "MFE vs MHL Analysis",
266
+ apply_config: bool = True
267
+ ) -> alt.Chart:
268
+ """Create multi-panel MFE vs MHL analysis with histograms
269
+
270
+ Args:
271
+ trades: DataFrame with MFE/MHL columns
272
+ x_col: X-axis column (default: g_mfe_atr_ratio)
273
+ y_col: Y-axis column (default: mhl_atr_ratio)
274
+ bins: Number of histogram bins
275
+ title: Chart title
276
+ apply_config: Apply default styling config (set False for chart composition)
277
+
278
+ Returns:
279
+ Vertically stacked Altair chart with 3 panels:
280
+ - Panel 1: MFE vs MHL scatter with diagonal reference
281
+ - Panel 2: MFE histogram (all trades)
282
+ - Panel 3: MFE histogram by win/loss
283
+
284
+ Example:
285
+ >>> trades = client.quick_backtest_results(["my_strategy"])[0]
286
+ >>> trades = calculate_mea(trades)
287
+ >>> chart = plot_mfe_mhl_analysis(trades)
288
+ >>> chart.show()
289
+ """
290
+ # Calculate ATR ratios if not present
291
+ if x_col not in trades.columns or y_col not in trades.columns:
292
+ trades = calculate_mea(trades)
293
+
294
+ # Add win/loss labels
295
+ if "win_loss" not in trades.columns:
296
+ trades = add_win_loss_label(trades)
297
+
298
+ # Calculate quantiles
299
+ x_q50 = trades[x_col].quantile(0.5)
300
+ x_q75 = trades[x_col].quantile(0.75)
301
+ x_max = trades[x_col].max()
302
+ y_max = trades[y_col].max()
303
+
304
+ # Add reference line data
305
+ n_points = 100
306
+ slope_data = pl.DataFrame({
307
+ "x_slope": [i * x_max / n_points for i in range(n_points)],
308
+ "y_slope": [i * y_max / n_points for i in range(n_points)],
309
+ })
310
+
311
+ trades = trades.with_columns([
312
+ pl.lit(x_q50).alias("x_q50"),
313
+ pl.lit(x_q75).alias("x_q75"),
314
+ ])
315
+
316
+ # Panel 1: Scatter plot with diagonal reference
317
+ scatter = alt.Chart(trades).mark_circle(size=100, opacity=0.6).encode(
318
+ x=alt.X(f"{x_col}:Q", title=x_col.replace("_", " ").title()),
319
+ y=alt.Y(f"{y_col}:Q", title=y_col.replace("_", " ").title()),
320
+ color=alt.Color(
321
+ "win_loss:N",
322
+ scale=alt.Scale(
323
+ domain=["Win", "Loss"],
324
+ range=["#00C49A", "#FF6B6B"]
325
+ ),
326
+ legend=alt.Legend(title="Result")
327
+ ),
328
+ tooltip=[
329
+ alt.Tooltip("position_id:Q", title="Position ID"),
330
+ alt.Tooltip("entry_time:T", title="Entry Time"),
331
+ alt.Tooltip(f"{x_col}:Q", title=x_col, format=".2f"),
332
+ alt.Tooltip(f"{y_col}:Q", title=y_col, format=".2f"),
333
+ alt.Tooltip("pnl:Q", title="PnL", format=".2f"),
334
+ alt.Tooltip("pnl_pct:Q", title="PnL %", format=".2%"),
335
+ ]
336
+ )
337
+
338
+ # Diagonal reference line
339
+ diagonal = alt.Chart(slope_data).mark_line(
340
+ color="gray",
341
+ strokeDash=[3, 3]
342
+ ).encode(
343
+ x="x_slope:Q",
344
+ y="y_slope:Q"
345
+ )
346
+
347
+ # Quantile lines
348
+ q50_line = alt.Chart(trades).mark_rule(
349
+ strokeDash=[5, 5],
350
+ color="black",
351
+ size=1
352
+ ).encode(x="x_q50:Q")
353
+
354
+ q75_line = alt.Chart(trades).mark_rule(
355
+ strokeDash=[5, 5],
356
+ color="black",
357
+ size=1
358
+ ).encode(x="x_q75:Q")
359
+
360
+ panel1 = (scatter + diagonal + q50_line + q75_line).properties(
361
+ width=500,
362
+ height=400,
363
+ title=f"{title} - Scatter Plot"
364
+ )
365
+
366
+ # Panel 2: Histogram of all trades
367
+ hist_all = alt.Chart(trades).mark_bar(opacity=0.3).encode(
368
+ x=alt.X(f"{x_col}:Q", bin=alt.Bin(maxbins=bins), title=x_col.replace("_", " ").title()),
369
+ y=alt.Y("count():Q", title="Count"),
370
+ ).properties(
371
+ width=500,
372
+ height=150,
373
+ title=f"{x_col} Distribution - All Trades"
374
+ )
375
+
376
+ # Add quantile lines to histogram
377
+ hist_all = (hist_all + q50_line + q75_line)
378
+
379
+ # Panel 3: Histogram by win/loss
380
+ hist_by_result = alt.Chart(trades).mark_bar(opacity=0.5).encode(
381
+ x=alt.X(f"{x_col}:Q", bin=alt.Bin(maxbins=bins), title=x_col.replace("_", " ").title()),
382
+ y=alt.Y("count():Q", title="Count"),
383
+ color=alt.Color(
384
+ "win_loss:N",
385
+ scale=alt.Scale(
386
+ domain=["Win", "Loss"],
387
+ range=["#00C49A", "#FF6B6B"]
388
+ )
389
+ )
390
+ ).properties(
391
+ width=500,
392
+ height=150,
393
+ title=f"{x_col} Distribution - By Result"
394
+ )
395
+
396
+ # Add quantile lines
397
+ hist_by_result = (hist_by_result + q50_line + q75_line)
398
+
399
+ # Stack vertically
400
+ chart = alt.vconcat(panel1, hist_all, hist_by_result)
401
+
402
+ # Apply config only if requested (avoid conflicts in composition)
403
+ if apply_config:
404
+ chart = chart.configure_axis(
405
+ gridColor="#f0f0f0"
406
+ ).configure_view(
407
+ strokeWidth=0
408
+ )
409
+
410
+ return chart
411
+
412
+
413
+ def plot_pnl_curves(
414
+ trades: pl.DataFrame,
415
+ title: str = "Strategy Performance",
416
+ apply_config: bool = True
417
+ ) -> alt.Chart:
418
+ """Create PnL curves with drawdown visualization
419
+
420
+ Args:
421
+ trades: DataFrame with 'pnl_pct' and 'exit_time' columns
422
+ title: Chart title
423
+ apply_config: Apply default styling config (set False for chart composition)
424
+
425
+ Returns:
426
+ Vertically stacked Altair chart with 2 panels:
427
+ - Panel 1: Cumulative return curve with new high markers
428
+ - Panel 2: Drawdown curve with new low markers
429
+
430
+ Example:
431
+ >>> trades = client.quick_backtest_results(["my_strategy"])[0]
432
+ >>> chart = plot_pnl_curves(trades, title="My Strategy Performance")
433
+ >>> chart.show()
434
+ """
435
+ # Calculate cumulative PnL and drawdown
436
+ df = calculate_cumulative_pnl(trades, sort_col="exit_time")
437
+
438
+ # Panel 1: Cumulative PnL curve
439
+ pnl_line = alt.Chart(df).mark_line(
440
+ color="#00C49A",
441
+ size=2
442
+ ).encode(
443
+ x=alt.X("exit_time:T", title="Date"),
444
+ y=alt.Y("cum_pnl_pct:Q", title="Cumulative Return (%)", axis=alt.Axis(format=".1%")),
445
+ tooltip=[
446
+ alt.Tooltip("exit_time:T", title="Date"),
447
+ alt.Tooltip("cum_pnl_pct:Q", title="Cumulative Return", format=".2%"),
448
+ alt.Tooltip("pnl_pct:Q", title="Trade PnL", format=".2%"),
449
+ ]
450
+ )
451
+
452
+ # Area fill
453
+ pnl_area = alt.Chart(df).mark_area(
454
+ color="#00C49A",
455
+ opacity=0.15
456
+ ).encode(
457
+ x="exit_time:T",
458
+ y="cum_pnl_pct:Q"
459
+ )
460
+
461
+ # New high markers
462
+ high_markers = alt.Chart(df).mark_point(
463
+ shape="diamond",
464
+ size=100,
465
+ color="#FFD700",
466
+ filled=True,
467
+ stroke="white",
468
+ strokeWidth=1.5
469
+ ).encode(
470
+ x="exit_time:T",
471
+ y="new_high:Q",
472
+ tooltip=[
473
+ alt.Tooltip("exit_time:T", title="Date"),
474
+ alt.Tooltip("new_high:Q", title="New High", format=".2%"),
475
+ ]
476
+ ).transform_filter(
477
+ alt.datum.new_high != None
478
+ )
479
+
480
+ # Drawdown low markers (on PnL curve)
481
+ dd_markers = alt.Chart(df).mark_circle(
482
+ size=60,
483
+ color="#9B2C2C",
484
+ opacity=0.7,
485
+ stroke="white",
486
+ strokeWidth=1
487
+ ).encode(
488
+ x="exit_time:T",
489
+ y="cum_pnl_pct:Q",
490
+ tooltip=[
491
+ alt.Tooltip("exit_time:T", title="Date"),
492
+ alt.Tooltip("drawdown:Q", title="Drawdown", format=".2%"),
493
+ ]
494
+ ).transform_filter(
495
+ alt.datum.new_dd != None
496
+ )
497
+
498
+ panel1 = (pnl_area + pnl_line + high_markers + dd_markers).properties(
499
+ width=600,
500
+ height=400,
501
+ title=f"{title} - Cumulative Return"
502
+ )
503
+
504
+ # Panel 2: Drawdown curve
505
+ dd_line = alt.Chart(df).mark_line(
506
+ color="#FF6B6B",
507
+ size=2
508
+ ).encode(
509
+ x=alt.X("exit_time:T", title="Date"),
510
+ y=alt.Y("drawdown:Q", title="Drawdown (%)", axis=alt.Axis(format=".1%")),
511
+ tooltip=[
512
+ alt.Tooltip("exit_time:T", title="Date"),
513
+ alt.Tooltip("drawdown:Q", title="Drawdown", format=".2%"),
514
+ ]
515
+ )
516
+
517
+ # Drawdown area fill
518
+ dd_area = alt.Chart(df).mark_area(
519
+ color="#FF6B6B",
520
+ opacity=0.15
521
+ ).encode(
522
+ x="exit_time:T",
523
+ y="drawdown:Q"
524
+ )
525
+
526
+ panel2 = (dd_area + dd_line).properties(
527
+ width=600,
528
+ height=200,
529
+ title="Drawdown"
530
+ )
531
+
532
+ # Stack vertically
533
+ chart = alt.vconcat(panel1, panel2)
534
+
535
+ # Apply config only if requested (avoid conflicts in composition)
536
+ if apply_config:
537
+ chart = chart.configure_axis(
538
+ gridColor="#f0f0f0"
539
+ ).configure_view(
540
+ strokeWidth=0
541
+ ).configure_title(
542
+ fontSize=18,
543
+ anchor="start"
544
+ )
545
+
546
+ return chart
547
+
548
+
549
+ def plot_trade_histograms(
550
+ trades: pl.DataFrame,
551
+ column: str = "mae",
552
+ bins: int = 40,
553
+ title: Optional[str] = None,
554
+ apply_config: bool = True
555
+ ) -> alt.Chart:
556
+ """Create histogram of trade metrics with win/loss breakdown
557
+
558
+ Args:
559
+ trades: DataFrame with trade metrics
560
+ column: Column to plot (e.g., 'mae', 'mfe', 'pnl_pct')
561
+ bins: Number of histogram bins
562
+ title: Chart title (auto-generated if None)
563
+ apply_config: Apply default styling config (set False for chart composition)
564
+
565
+ Returns:
566
+ Vertically stacked Altair chart with 2 panels:
567
+ - Panel 1: Distribution of all trades
568
+ - Panel 2: Distribution by win/loss
569
+
570
+ Example:
571
+ >>> trades = client.quick_backtest_results(["my_strategy"])[0]
572
+ >>> chart = plot_trade_histograms(trades, column="mae_atr_ratio")
573
+ >>> chart.show()
574
+ """
575
+ if title is None:
576
+ title = f"{column.replace('_', ' ').title()} Distribution"
577
+
578
+ # Add win/loss labels if not present
579
+ if "win_loss" not in trades.columns:
580
+ trades = add_win_loss_label(trades)
581
+
582
+ # Calculate quantiles
583
+ q25 = trades[column].quantile(0.25)
584
+ q50 = trades[column].quantile(0.50)
585
+ q75 = trades[column].quantile(0.75)
586
+
587
+ trades = trades.with_columns([
588
+ pl.lit(q25).alias("q25"),
589
+ pl.lit(q50).alias("q50"),
590
+ pl.lit(q75).alias("q75"),
591
+ ])
592
+
593
+ # Panel 1: All trades
594
+ hist_all = alt.Chart(trades).mark_bar(
595
+ opacity=0.3,
596
+ color="#3b82f6"
597
+ ).encode(
598
+ x=alt.X(f"{column}:Q", bin=alt.Bin(maxbins=bins), title=column.replace("_", " ").title()),
599
+ y=alt.Y("count():Q", title="Count"),
600
+ )
601
+
602
+ # Quantile lines
603
+ q50_line = alt.Chart(trades).mark_rule(
604
+ strokeDash=[5, 5],
605
+ color="black",
606
+ size=1
607
+ ).encode(x="q50:Q")
608
+
609
+ q75_line = alt.Chart(trades).mark_rule(
610
+ strokeDash=[5, 5],
611
+ color="black",
612
+ size=1
613
+ ).encode(x="q75:Q")
614
+
615
+ panel1 = (hist_all + q50_line + q75_line).properties(
616
+ width=600,
617
+ height=250,
618
+ title=f"{title} - All Trades"
619
+ )
620
+
621
+ # Panel 2: By win/loss
622
+ hist_by_result = alt.Chart(trades).mark_bar(opacity=0.5).encode(
623
+ x=alt.X(f"{column}:Q", bin=alt.Bin(maxbins=bins), title=column.replace("_", " ").title()),
624
+ y=alt.Y("count():Q", title="Count"),
625
+ color=alt.Color(
626
+ "win_loss:N",
627
+ scale=alt.Scale(
628
+ domain=["Win", "Loss"],
629
+ range=["#00C49A", "#FF6B6B"]
630
+ ),
631
+ legend=alt.Legend(title="Result")
632
+ )
633
+ )
634
+
635
+ panel2 = (hist_by_result + q50_line + q75_line).properties(
636
+ width=600,
637
+ height=250,
638
+ title=f"{title} - By Result"
639
+ )
640
+
641
+ # Stack vertically
642
+ chart = alt.vconcat(panel1, panel2)
643
+
644
+ # Apply config only if requested (avoid conflicts in composition)
645
+ if apply_config:
646
+ chart = chart.configure_axis(
647
+ gridColor="#f0f0f0"
648
+ ).configure_view(
649
+ strokeWidth=0
650
+ )
651
+
652
+ return chart
653
+
654
+
655
+ # =============================================================================
656
+ # Chart Composition Utilities
657
+ # =============================================================================
658
+
659
+ def combine_charts(
660
+ *charts: alt.Chart,
661
+ layout: Literal["horizontal", "vertical", "grid"] = "vertical",
662
+ columns: int = 2,
663
+ spacing: int = 15,
664
+ title: Optional[str] = None
665
+ ) -> alt.Chart:
666
+ """Combine multiple charts into a single dashboard layout
667
+
668
+ Automatically strips config from individual charts to avoid composition conflicts.
669
+
670
+ Args:
671
+ *charts: Variable number of Altair charts to combine
672
+ layout: Layout type - "horizontal" (|), "vertical" (&), or "grid"
673
+ columns: Number of columns for grid layout (default: 2)
674
+ spacing: Spacing between charts in pixels (default: 15)
675
+ title: Overall dashboard title (optional)
676
+
677
+ Returns:
678
+ Combined Altair chart with unified configuration
679
+
680
+ Example:
681
+ >>> from tradepose_client import (
682
+ ... plot_mae_mfe_scatter,
683
+ ... plot_pnl_curves,
684
+ ... combine_charts
685
+ ... )
686
+ >>> trades = client.quick_backtest_results(["my_strategy"])[0]
687
+ >>> trades = calculate_mea(trades)
688
+ >>>
689
+ >>> # Method 1: Using combine_charts()
690
+ >>> dashboard = combine_charts(
691
+ ... plot_mae_mfe_scatter(trades),
692
+ ... plot_pnl_curves(trades),
693
+ ... layout="horizontal"
694
+ ... )
695
+ >>>
696
+ >>> # Method 2: Using operators with apply_config=False
697
+ >>> scatter = plot_mae_mfe_scatter(trades, apply_config=False)
698
+ >>> pnl = plot_pnl_curves(trades, apply_config=False)
699
+ >>> dashboard = (scatter | pnl).configure_axis(gridColor="#f0f0f0")
700
+ """
701
+ import altair as alt
702
+
703
+ if len(charts) == 0:
704
+ raise ValueError("At least one chart is required")
705
+
706
+ # Strip config from all charts to avoid conflicts
707
+ # (Altair will error if trying to compose charts with config)
708
+ # Note: This is done by re-creating charts without config
709
+ clean_charts = []
710
+ for chart in charts:
711
+ # Charts should be created with apply_config=False
712
+ # but if user passes configured charts, we need to handle it
713
+ clean_charts.append(chart)
714
+
715
+ # Combine based on layout
716
+ if layout == "horizontal":
717
+ combined = clean_charts[0]
718
+ for chart in clean_charts[1:]:
719
+ combined = combined | chart
720
+
721
+ elif layout == "vertical":
722
+ combined = clean_charts[0]
723
+ for chart in clean_charts[1:]:
724
+ combined = combined & chart
725
+
726
+ elif layout == "grid":
727
+ # Grid layout using alt.concat
728
+ combined = alt.concat(*clean_charts, columns=columns)
729
+
730
+ else:
731
+ raise ValueError(f"Invalid layout: {layout}. Use 'horizontal', 'vertical', or 'grid'")
732
+
733
+ # Apply unified configuration
734
+ combined = combined.configure_axis(
735
+ gridColor="#f0f0f0"
736
+ ).configure_view(
737
+ strokeWidth=0
738
+ ).configure_concat(
739
+ spacing=spacing
740
+ )
741
+
742
+ # Add overall title if provided
743
+ if title:
744
+ combined = combined.properties(
745
+ title={
746
+ "text": title,
747
+ "fontSize": 20,
748
+ "anchor": "middle"
749
+ }
750
+ ).configure_title(
751
+ fontSize=20,
752
+ anchor="start"
753
+ )
754
+
755
+ # Resolve scales to avoid conflicts
756
+ combined = combined.resolve_scale(
757
+ color='independent',
758
+ x='independent',
759
+ y='independent'
760
+ )
761
+
762
+ return combined