kbs 0.0.1
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.
- checksums.yaml +7 -0
- data/.envrc +3 -0
- data/CHANGELOG.md +5 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +481 -0
- data/Rakefile +8 -0
- data/examples/README.md +531 -0
- data/examples/advanced_example.rb +270 -0
- data/examples/ai_enhanced_kbs.rb +523 -0
- data/examples/blackboard_demo.rb +50 -0
- data/examples/car_diagnostic.rb +64 -0
- data/examples/concurrent_inference_demo.rb +363 -0
- data/examples/csv_trading_system.rb +559 -0
- data/examples/iot_demo_using_dsl.rb +83 -0
- data/examples/portfolio_rebalancing_system.rb +651 -0
- data/examples/redis_trading_demo.rb +177 -0
- data/examples/sample_stock_data.csv +46 -0
- data/examples/stock_trading_advanced.rb +469 -0
- data/examples/stock_trading_system.rb.bak +563 -0
- data/examples/timestamped_trading.rb +286 -0
- data/examples/trading_demo.rb +334 -0
- data/examples/working_demo.rb +176 -0
- data/lib/kbs/alpha_memory.rb +37 -0
- data/lib/kbs/beta_memory.rb +57 -0
- data/lib/kbs/blackboard/audit_log.rb +115 -0
- data/lib/kbs/blackboard/engine.rb +83 -0
- data/lib/kbs/blackboard/fact.rb +65 -0
- data/lib/kbs/blackboard/memory.rb +191 -0
- data/lib/kbs/blackboard/message_queue.rb +96 -0
- data/lib/kbs/blackboard/persistence/hybrid_store.rb +118 -0
- data/lib/kbs/blackboard/persistence/redis_store.rb +218 -0
- data/lib/kbs/blackboard/persistence/sqlite_store.rb +242 -0
- data/lib/kbs/blackboard/persistence/store.rb +55 -0
- data/lib/kbs/blackboard/redis_audit_log.rb +107 -0
- data/lib/kbs/blackboard/redis_message_queue.rb +111 -0
- data/lib/kbs/blackboard.rb +23 -0
- data/lib/kbs/condition.rb +26 -0
- data/lib/kbs/dsl/condition_helpers.rb +57 -0
- data/lib/kbs/dsl/knowledge_base.rb +86 -0
- data/lib/kbs/dsl/pattern_evaluator.rb +69 -0
- data/lib/kbs/dsl/rule_builder.rb +115 -0
- data/lib/kbs/dsl/variable.rb +35 -0
- data/lib/kbs/dsl.rb +18 -0
- data/lib/kbs/fact.rb +43 -0
- data/lib/kbs/join_node.rb +117 -0
- data/lib/kbs/negation_node.rb +88 -0
- data/lib/kbs/production_node.rb +28 -0
- data/lib/kbs/rete_engine.rb +108 -0
- data/lib/kbs/rule.rb +46 -0
- data/lib/kbs/token.rb +37 -0
- data/lib/kbs/version.rb +5 -0
- data/lib/kbs/working_memory.rb +32 -0
- data/lib/kbs.rb +20 -0
- metadata +164 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require_relative '../lib/kbs'
|
|
4
|
+
require 'csv'
|
|
5
|
+
require 'date'
|
|
6
|
+
|
|
7
|
+
class PortfolioRebalancingSystem
|
|
8
|
+
def initialize(csv_file = 'sample_stock_data.csv')
|
|
9
|
+
@engine = KBS::ReteEngine.new
|
|
10
|
+
@csv_file = csv_file
|
|
11
|
+
@portfolio = {
|
|
12
|
+
cash: 100_000,
|
|
13
|
+
positions: {},
|
|
14
|
+
target_allocations: {
|
|
15
|
+
"Technology" => 0.40, # 40% target
|
|
16
|
+
"Healthcare" => 0.25, # 25% target
|
|
17
|
+
"Finance" => 0.20, # 20% target
|
|
18
|
+
"Consumer" => 0.15 # 15% target
|
|
19
|
+
},
|
|
20
|
+
sector_mappings: {
|
|
21
|
+
"AAPL" => "Technology",
|
|
22
|
+
"GOOGL" => "Technology",
|
|
23
|
+
"MSFT" => "Technology",
|
|
24
|
+
"NVDA" => "Technology",
|
|
25
|
+
"TSLA" => "Consumer",
|
|
26
|
+
"META" => "Technology",
|
|
27
|
+
"JNJ" => "Healthcare",
|
|
28
|
+
"PFE" => "Healthcare",
|
|
29
|
+
"JPM" => "Finance",
|
|
30
|
+
"BAC" => "Finance"
|
|
31
|
+
},
|
|
32
|
+
replacement_candidates: {
|
|
33
|
+
"Technology" => ["AAPL", "GOOGL", "MSFT", "NVDA", "META"],
|
|
34
|
+
"Healthcare" => ["JNJ", "PFE", "UNH", "ABT"],
|
|
35
|
+
"Finance" => ["JPM", "BAC", "WFC", "GS"],
|
|
36
|
+
"Consumer" => ["TSLA", "AMZN", "DIS", "NFLX"]
|
|
37
|
+
},
|
|
38
|
+
trades: [],
|
|
39
|
+
rebalancing_history: []
|
|
40
|
+
}
|
|
41
|
+
@current_prices = {}
|
|
42
|
+
@performance_data = Hash.new { |h, k| h[k] = [] }
|
|
43
|
+
setup_rebalancing_rules
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def setup_rebalancing_rules
|
|
47
|
+
# Rule 1: Sector Allocation Drift Detection
|
|
48
|
+
allocation_drift_rule = KBS::Rule.new(
|
|
49
|
+
"sector_allocation_drift",
|
|
50
|
+
conditions: [
|
|
51
|
+
KBS::Condition.new(:portfolio_allocation, {
|
|
52
|
+
sector: ->(s) { s && s.length > 0 },
|
|
53
|
+
current_weight: ->(w) { w && w > 0 },
|
|
54
|
+
target_weight: ->(t) { t && t > 0 },
|
|
55
|
+
drift_percentage: ->(d) { d && d.abs > 5 } # >5% drift from target
|
|
56
|
+
})
|
|
57
|
+
],
|
|
58
|
+
action: lambda do |facts, bindings|
|
|
59
|
+
allocation = facts.find { |f| f.type == :portfolio_allocation }
|
|
60
|
+
sector = allocation[:sector]
|
|
61
|
+
current = allocation[:current_weight]
|
|
62
|
+
target = allocation[:target_weight]
|
|
63
|
+
drift = allocation[:drift_percentage]
|
|
64
|
+
|
|
65
|
+
action = drift > 0 ? "REDUCE" : "INCREASE"
|
|
66
|
+
puts "⚖️ ALLOCATION DRIFT: #{sector}"
|
|
67
|
+
puts " Current: #{(current * 100).round(1)}%"
|
|
68
|
+
puts " Target: #{(target * 100).round(1)}%"
|
|
69
|
+
puts " Drift: #{drift > 0 ? '+' : ''}#{drift.round(1)}%"
|
|
70
|
+
puts " Action: #{action} #{sector} allocation"
|
|
71
|
+
|
|
72
|
+
trigger_sector_rebalancing(sector, target, current)
|
|
73
|
+
end,
|
|
74
|
+
priority: 15
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Rule 2: Underperforming Position Replacement
|
|
78
|
+
underperformer_replacement_rule = KBS::Rule.new(
|
|
79
|
+
"replace_underperformer",
|
|
80
|
+
conditions: [
|
|
81
|
+
KBS::Condition.new(:position_performance, {
|
|
82
|
+
symbol: ->(s) { s && s.length > 0 },
|
|
83
|
+
relative_performance: ->(p) { p && p < -10 }, # 10% underperformance
|
|
84
|
+
days_held: ->(d) { d && d > 30 }, # Held for more than 30 days
|
|
85
|
+
sector: ->(s) { s && s.length > 0 }
|
|
86
|
+
}),
|
|
87
|
+
KBS::Condition.new(:replacement_candidate, {
|
|
88
|
+
sector: ->(s) { s && s.length > 0 },
|
|
89
|
+
relative_performance: ->(p) { p && p > 5 }, # 5% outperformance
|
|
90
|
+
momentum_score: ->(m) { m && m > 0.7 }
|
|
91
|
+
})
|
|
92
|
+
],
|
|
93
|
+
action: lambda do |facts, bindings|
|
|
94
|
+
underperformer = facts.find { |f| f.type == :position_performance }
|
|
95
|
+
candidate = facts.find { |f| f.type == :replacement_candidate }
|
|
96
|
+
|
|
97
|
+
if underperformer[:sector] == candidate[:sector]
|
|
98
|
+
puts "🔄 POSITION REPLACEMENT: #{underperformer[:symbol]} → #{candidate[:symbol]}"
|
|
99
|
+
puts " Sector: #{underperformer[:sector]}"
|
|
100
|
+
puts " Underperformer: #{underperformer[:relative_performance].round(1)}% vs sector"
|
|
101
|
+
puts " Replacement: #{candidate[:relative_performance].round(1)}% vs sector"
|
|
102
|
+
puts " Momentum Score: #{candidate[:momentum_score].round(2)}"
|
|
103
|
+
|
|
104
|
+
execute_position_replacement(underperformer[:symbol], candidate[:symbol])
|
|
105
|
+
end
|
|
106
|
+
end,
|
|
107
|
+
priority: 12
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Rule 3: Correlation Risk Reduction
|
|
111
|
+
correlation_replacement_rule = KBS::Rule.new(
|
|
112
|
+
"reduce_correlation_risk",
|
|
113
|
+
conditions: [
|
|
114
|
+
KBS::Condition.new(:correlation_risk, {
|
|
115
|
+
correlation_coefficient: ->(c) { c && c > 0.8 }, # High correlation
|
|
116
|
+
combined_allocation: ->(a) { a && a > 0.25 }, # >25% combined weight
|
|
117
|
+
sector: ->(s) { s && s.length > 0 }
|
|
118
|
+
}),
|
|
119
|
+
KBS::Condition.new(:replacement_candidate, {
|
|
120
|
+
correlation_with_portfolio: ->(c) { c && c < 0.5 }, # Low correlation
|
|
121
|
+
sector: ->(s) { s && s.length > 0 }
|
|
122
|
+
})
|
|
123
|
+
],
|
|
124
|
+
action: lambda do |facts, bindings|
|
|
125
|
+
risk = facts.find { |f| f.type == :correlation_risk }
|
|
126
|
+
candidate = facts.find { |f| f.type == :replacement_candidate }
|
|
127
|
+
|
|
128
|
+
if risk[:sector] == candidate[:sector]
|
|
129
|
+
puts "📊 CORRELATION RISK REDUCTION"
|
|
130
|
+
puts " High Correlation: #{(risk[:correlation_coefficient] * 100).round(1)}%"
|
|
131
|
+
puts " Combined Weight: #{(risk[:combined_allocation] * 100).round(1)}%"
|
|
132
|
+
puts " Replacement Correlation: #{(candidate[:correlation_with_portfolio] * 100).round(1)}%"
|
|
133
|
+
puts " Action: Replace correlated position"
|
|
134
|
+
|
|
135
|
+
# Replace the weaker performing stock in the correlated pair
|
|
136
|
+
weaker_symbol = identify_weaker_performer(risk[:symbols])
|
|
137
|
+
execute_position_replacement(weaker_symbol, candidate[:symbol])
|
|
138
|
+
end
|
|
139
|
+
end,
|
|
140
|
+
priority: 14
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Rule 4: Momentum-Based Rotation
|
|
144
|
+
momentum_rotation_rule = KBS::Rule.new(
|
|
145
|
+
"momentum_rotation",
|
|
146
|
+
conditions: [
|
|
147
|
+
KBS::Condition.new(:sector_momentum, {
|
|
148
|
+
sector: ->(s) { s && s.length > 0 },
|
|
149
|
+
momentum_trend: "declining",
|
|
150
|
+
momentum_score: ->(m) { m && m < 0.3 }, # Weak momentum
|
|
151
|
+
duration_days: ->(d) { d && d > 20 } # Trend persisting
|
|
152
|
+
}),
|
|
153
|
+
KBS::Condition.new(:sector_momentum, {
|
|
154
|
+
sector: ->(s) { s && s.length > 0 },
|
|
155
|
+
momentum_trend: "rising",
|
|
156
|
+
momentum_score: ->(m) { m && m > 0.8 } # Strong momentum
|
|
157
|
+
})
|
|
158
|
+
],
|
|
159
|
+
action: lambda do |facts, bindings|
|
|
160
|
+
declining = facts.find { |f| f.type == :sector_momentum && f[:momentum_trend] == "declining" }
|
|
161
|
+
rising = facts.find { |f| f.type == :sector_momentum && f[:momentum_trend] == "rising" }
|
|
162
|
+
|
|
163
|
+
if declining && rising && declining[:sector] != rising[:sector]
|
|
164
|
+
puts "🔀 MOMENTUM ROTATION"
|
|
165
|
+
puts " From: #{declining[:sector]} (momentum: #{declining[:momentum_score].round(2)})"
|
|
166
|
+
puts " To: #{rising[:sector]} (momentum: #{rising[:momentum_score].round(2)})"
|
|
167
|
+
puts " Action: Rotate allocation between sectors"
|
|
168
|
+
|
|
169
|
+
execute_sector_rotation(declining[:sector], rising[:sector], 0.10) # 10% rotation
|
|
170
|
+
end
|
|
171
|
+
end,
|
|
172
|
+
priority: 10
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Rule 5: Quality Score Replacement
|
|
176
|
+
quality_replacement_rule = KBS::Rule.new(
|
|
177
|
+
"quality_score_replacement",
|
|
178
|
+
conditions: [
|
|
179
|
+
KBS::Condition.new(:position_quality, {
|
|
180
|
+
symbol: ->(s) { s && s.length > 0 },
|
|
181
|
+
quality_score: ->(q) { q && q < 0.4 }, # Low quality score
|
|
182
|
+
sector: ->(s) { s && s.length > 0 }
|
|
183
|
+
}),
|
|
184
|
+
KBS::Condition.new(:replacement_candidate, {
|
|
185
|
+
quality_score: ->(q) { q && q > 0.8 }, # High quality score
|
|
186
|
+
sector: ->(s) { s && s.length > 0 }
|
|
187
|
+
})
|
|
188
|
+
],
|
|
189
|
+
action: lambda do |facts, bindings|
|
|
190
|
+
low_quality = facts.find { |f| f.type == :position_quality }
|
|
191
|
+
high_quality = facts.find { |f| f.type == :replacement_candidate }
|
|
192
|
+
|
|
193
|
+
if low_quality[:sector] == high_quality[:sector]
|
|
194
|
+
puts "⭐ QUALITY UPGRADE: #{low_quality[:symbol]} → #{high_quality[:symbol]}"
|
|
195
|
+
puts " Current Quality: #{low_quality[:quality_score].round(2)}"
|
|
196
|
+
puts " Replacement Quality: #{high_quality[:quality_score].round(2)}"
|
|
197
|
+
puts " Sector: #{low_quality[:sector]}"
|
|
198
|
+
|
|
199
|
+
execute_position_replacement(low_quality[:symbol], high_quality[:symbol])
|
|
200
|
+
end
|
|
201
|
+
end,
|
|
202
|
+
priority: 8
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Rule 6: Risk-Adjusted Return Optimization
|
|
206
|
+
risk_adjusted_optimization_rule = KBS::Rule.new(
|
|
207
|
+
"risk_adjusted_optimization",
|
|
208
|
+
conditions: [
|
|
209
|
+
KBS::Condition.new(:position_metrics, {
|
|
210
|
+
symbol: ->(s) { s && s.length > 0 },
|
|
211
|
+
sharpe_ratio: ->(sr) { sr && sr < 0.5 }, # Low risk-adjusted return
|
|
212
|
+
volatility: ->(v) { v && v > 0.3 } # High volatility
|
|
213
|
+
}),
|
|
214
|
+
KBS::Condition.new(:replacement_candidate, {
|
|
215
|
+
sharpe_ratio: ->(sr) { sr && sr > 1.0 }, # Better risk-adjusted return
|
|
216
|
+
volatility: ->(v) { v && v < 0.2 } # Lower volatility
|
|
217
|
+
})
|
|
218
|
+
],
|
|
219
|
+
action: lambda do |facts, bindings|
|
|
220
|
+
poor_performer = facts.find { |f| f.type == :position_metrics }
|
|
221
|
+
better_candidate = facts.find { |f| f.type == :replacement_candidate }
|
|
222
|
+
|
|
223
|
+
puts "📈 RISK-ADJUSTED OPTIMIZATION"
|
|
224
|
+
puts " Replace: #{poor_performer[:symbol]}"
|
|
225
|
+
puts " Sharpe Ratio: #{poor_performer[:sharpe_ratio].round(2)}"
|
|
226
|
+
puts " Volatility: #{(poor_performer[:volatility] * 100).round(1)}%"
|
|
227
|
+
puts " With: #{better_candidate[:symbol]}"
|
|
228
|
+
puts " Sharpe Ratio: #{better_candidate[:sharpe_ratio].round(2)}"
|
|
229
|
+
puts " Volatility: #{(better_candidate[:volatility] * 100).round(1)}%"
|
|
230
|
+
|
|
231
|
+
execute_position_replacement(poor_performer[:symbol], better_candidate[:symbol])
|
|
232
|
+
end,
|
|
233
|
+
priority: 11
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Rule 7: Quarterly Rebalancing Trigger
|
|
237
|
+
quarterly_rebalancing_rule = KBS::Rule.new(
|
|
238
|
+
"quarterly_rebalancing",
|
|
239
|
+
conditions: [
|
|
240
|
+
KBS::Condition.new(:calendar_event, {
|
|
241
|
+
event_type: "quarter_end",
|
|
242
|
+
days_since_last_rebalance: ->(d) { d && d > 85 } # >85 days
|
|
243
|
+
}),
|
|
244
|
+
KBS::Condition.new(:portfolio_metrics, {
|
|
245
|
+
total_drift: ->(d) { d && d > 3 } # >3% total portfolio drift
|
|
246
|
+
})
|
|
247
|
+
],
|
|
248
|
+
action: lambda do |facts, bindings|
|
|
249
|
+
event = facts.find { |f| f.type == :calendar_event }
|
|
250
|
+
metrics = facts.find { |f| f.type == :portfolio_metrics }
|
|
251
|
+
|
|
252
|
+
puts "📅 QUARTERLY REBALANCING TRIGGER"
|
|
253
|
+
puts " Days Since Last Rebalance: #{event[:days_since_last_rebalance]}"
|
|
254
|
+
puts " Total Portfolio Drift: #{metrics[:total_drift].round(1)}%"
|
|
255
|
+
puts " Action: Full portfolio rebalancing"
|
|
256
|
+
|
|
257
|
+
execute_full_portfolio_rebalancing
|
|
258
|
+
end,
|
|
259
|
+
priority: 16
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
@engine.add_rule(allocation_drift_rule)
|
|
263
|
+
@engine.add_rule(underperformer_replacement_rule)
|
|
264
|
+
@engine.add_rule(correlation_replacement_rule)
|
|
265
|
+
@engine.add_rule(momentum_rotation_rule)
|
|
266
|
+
@engine.add_rule(quality_replacement_rule)
|
|
267
|
+
@engine.add_rule(risk_adjusted_optimization_rule)
|
|
268
|
+
@engine.add_rule(quarterly_rebalancing_rule)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def calculate_sector_allocations
|
|
272
|
+
total_value = calculate_total_portfolio_value
|
|
273
|
+
return {} if total_value <= 0
|
|
274
|
+
|
|
275
|
+
sector_values = Hash.new(0)
|
|
276
|
+
|
|
277
|
+
@portfolio[:positions].each do |symbol, position|
|
|
278
|
+
next unless position[:status] == "open"
|
|
279
|
+
sector = @portfolio[:sector_mappings][symbol] || "Unknown"
|
|
280
|
+
current_value = position[:shares] * @current_prices[symbol] if @current_prices[symbol]
|
|
281
|
+
sector_values[sector] += current_value if current_value
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
sector_allocations = {}
|
|
285
|
+
sector_values.each do |sector, value|
|
|
286
|
+
sector_allocations[sector] = value / total_value.to_f
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
sector_allocations
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def calculate_total_portfolio_value
|
|
293
|
+
total = @portfolio[:cash]
|
|
294
|
+
@portfolio[:positions].each do |symbol, position|
|
|
295
|
+
if position[:status] == "open" && @current_prices[symbol]
|
|
296
|
+
total += position[:shares] * @current_prices[symbol]
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
total
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def trigger_sector_rebalancing(sector, target_weight, current_weight)
|
|
303
|
+
puts " 🔄 Triggering sector rebalancing for #{sector}"
|
|
304
|
+
|
|
305
|
+
# Calculate the dollar amount to adjust
|
|
306
|
+
total_value = calculate_total_portfolio_value
|
|
307
|
+
target_value = total_value * target_weight
|
|
308
|
+
current_value = total_value * current_weight
|
|
309
|
+
adjustment_needed = target_value - current_value
|
|
310
|
+
|
|
311
|
+
puts " 💰 Adjustment needed: $#{adjustment_needed.round(2)}"
|
|
312
|
+
|
|
313
|
+
if adjustment_needed > 0
|
|
314
|
+
# Need to buy more of this sector
|
|
315
|
+
buy_sector_positions(sector, adjustment_needed)
|
|
316
|
+
else
|
|
317
|
+
# Need to sell some of this sector
|
|
318
|
+
sell_sector_positions(sector, adjustment_needed.abs)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def execute_position_replacement(old_symbol, new_symbol)
|
|
323
|
+
old_position = @portfolio[:positions][old_symbol]
|
|
324
|
+
return unless old_position && old_position[:status] == "open"
|
|
325
|
+
|
|
326
|
+
old_price = @current_prices[old_symbol]
|
|
327
|
+
new_price = @current_prices[new_symbol]
|
|
328
|
+
return unless old_price && new_price
|
|
329
|
+
|
|
330
|
+
# Sell old position
|
|
331
|
+
proceeds = old_position[:shares] * old_price
|
|
332
|
+
@portfolio[:cash] += proceeds
|
|
333
|
+
old_position[:status] = "closed"
|
|
334
|
+
old_position[:exit_price] = old_price
|
|
335
|
+
|
|
336
|
+
# Buy new position with proceeds
|
|
337
|
+
new_shares = (proceeds / new_price).to_i
|
|
338
|
+
if new_shares > 0
|
|
339
|
+
cost = new_shares * new_price
|
|
340
|
+
@portfolio[:cash] -= cost
|
|
341
|
+
|
|
342
|
+
@portfolio[:positions][new_symbol] = {
|
|
343
|
+
symbol: new_symbol,
|
|
344
|
+
shares: new_shares,
|
|
345
|
+
entry_price: new_price,
|
|
346
|
+
status: "open"
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
puts " ✅ REPLACEMENT EXECUTED:"
|
|
350
|
+
puts " Sold: #{old_position[:shares]} shares of #{old_symbol} at $#{old_price}"
|
|
351
|
+
puts " Bought: #{new_shares} shares of #{new_symbol} at $#{new_price}"
|
|
352
|
+
puts " Cash: $#{@portfolio[:cash].round(2)}"
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def execute_sector_rotation(from_sector, to_sector, rotation_percentage)
|
|
357
|
+
total_value = calculate_total_portfolio_value
|
|
358
|
+
rotation_amount = total_value * rotation_percentage
|
|
359
|
+
|
|
360
|
+
puts " 🔀 Rotating $#{rotation_amount.round(2)} from #{from_sector} to #{to_sector}"
|
|
361
|
+
|
|
362
|
+
# Sell positions from declining sector
|
|
363
|
+
sell_sector_positions(from_sector, rotation_amount)
|
|
364
|
+
|
|
365
|
+
# Buy positions in rising sector
|
|
366
|
+
buy_sector_positions(to_sector, rotation_amount)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def execute_full_portfolio_rebalancing
|
|
370
|
+
puts " 🔄 Executing full portfolio rebalancing"
|
|
371
|
+
|
|
372
|
+
current_allocations = calculate_sector_allocations
|
|
373
|
+
total_value = calculate_total_portfolio_value
|
|
374
|
+
|
|
375
|
+
@portfolio[:target_allocations].each do |sector, target_weight|
|
|
376
|
+
current_weight = current_allocations[sector] || 0
|
|
377
|
+
drift = ((current_weight - target_weight) / target_weight * 100) rescue 0
|
|
378
|
+
|
|
379
|
+
if drift.abs > 2 # Rebalance if >2% drift
|
|
380
|
+
adjustment = total_value * (target_weight - current_weight)
|
|
381
|
+
|
|
382
|
+
if adjustment > 0
|
|
383
|
+
buy_sector_positions(sector, adjustment)
|
|
384
|
+
else
|
|
385
|
+
sell_sector_positions(sector, adjustment.abs)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
@portfolio[:rebalancing_history] << {
|
|
391
|
+
date: Date.today,
|
|
392
|
+
type: "full_rebalancing",
|
|
393
|
+
allocations_before: current_allocations.dup,
|
|
394
|
+
allocations_after: calculate_sector_allocations
|
|
395
|
+
}
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def buy_sector_positions(sector, amount)
|
|
399
|
+
candidates = @portfolio[:replacement_candidates][sector] || []
|
|
400
|
+
return if candidates.empty? || amount <= 0
|
|
401
|
+
|
|
402
|
+
# Simple equal-weight allocation among candidates
|
|
403
|
+
amount_per_stock = amount / candidates.length
|
|
404
|
+
|
|
405
|
+
candidates.each do |symbol|
|
|
406
|
+
price = @current_prices[symbol]
|
|
407
|
+
next unless price
|
|
408
|
+
|
|
409
|
+
shares = (amount_per_stock / price).to_i
|
|
410
|
+
next if shares <= 0
|
|
411
|
+
|
|
412
|
+
cost = shares * price
|
|
413
|
+
if @portfolio[:cash] >= cost
|
|
414
|
+
@portfolio[:cash] -= cost
|
|
415
|
+
|
|
416
|
+
if @portfolio[:positions][symbol] && @portfolio[:positions][symbol][:status] == "open"
|
|
417
|
+
# Add to existing position
|
|
418
|
+
existing = @portfolio[:positions][symbol]
|
|
419
|
+
total_shares = existing[:shares] + shares
|
|
420
|
+
total_cost = (existing[:shares] * existing[:entry_price]) + cost
|
|
421
|
+
|
|
422
|
+
existing[:shares] = total_shares
|
|
423
|
+
existing[:entry_price] = total_cost / total_shares
|
|
424
|
+
else
|
|
425
|
+
# Create new position
|
|
426
|
+
@portfolio[:positions][symbol] = {
|
|
427
|
+
symbol: symbol,
|
|
428
|
+
shares: shares,
|
|
429
|
+
entry_price: price,
|
|
430
|
+
status: "open"
|
|
431
|
+
}
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
puts " ✅ Bought #{shares} shares of #{symbol} at $#{price}"
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def sell_sector_positions(sector, amount)
|
|
440
|
+
sector_positions = @portfolio[:positions].select do |symbol, position|
|
|
441
|
+
position[:status] == "open" && @portfolio[:sector_mappings][symbol] == sector
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
return if sector_positions.empty? || amount <= 0
|
|
445
|
+
|
|
446
|
+
# Sell proportionally from sector positions
|
|
447
|
+
total_sector_value = sector_positions.sum do |symbol, position|
|
|
448
|
+
position[:shares] * @current_prices[symbol] if @current_prices[symbol]
|
|
449
|
+
end.compact.sum
|
|
450
|
+
|
|
451
|
+
return if total_sector_value <= 0
|
|
452
|
+
|
|
453
|
+
sector_positions.each do |symbol, position|
|
|
454
|
+
position_value = position[:shares] * @current_prices[symbol]
|
|
455
|
+
proportion = position_value / total_sector_value
|
|
456
|
+
shares_to_sell = ((amount * proportion) / @current_prices[symbol]).to_i
|
|
457
|
+
|
|
458
|
+
if shares_to_sell > 0 && shares_to_sell <= position[:shares]
|
|
459
|
+
proceeds = shares_to_sell * @current_prices[symbol]
|
|
460
|
+
@portfolio[:cash] += proceeds
|
|
461
|
+
|
|
462
|
+
position[:shares] -= shares_to_sell
|
|
463
|
+
if position[:shares] <= 0
|
|
464
|
+
position[:status] = "closed"
|
|
465
|
+
position[:exit_price] = @current_prices[symbol]
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
puts " ✅ Sold #{shares_to_sell} shares of #{symbol} at $#{@current_prices[symbol]}"
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def identify_weaker_performer(symbols)
|
|
474
|
+
performances = symbols.map do |symbol|
|
|
475
|
+
position = @portfolio[:positions][symbol]
|
|
476
|
+
if position && position[:status] == "open" && @current_prices[symbol]
|
|
477
|
+
current_value = position[:shares] * @current_prices[symbol]
|
|
478
|
+
entry_value = position[:shares] * position[:entry_price]
|
|
479
|
+
performance = ((current_value - entry_value) / entry_value) * 100
|
|
480
|
+
[symbol, performance]
|
|
481
|
+
end
|
|
482
|
+
end.compact
|
|
483
|
+
|
|
484
|
+
# Return the symbol with the worst performance
|
|
485
|
+
performances.min_by { |symbol, perf| perf }&.first || symbols.first
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def simulate_rebalancing_scenarios
|
|
489
|
+
puts "🔄 PORTFOLIO REBALANCING SYSTEM DEMONSTRATION"
|
|
490
|
+
puts "=" * 70
|
|
491
|
+
|
|
492
|
+
# Initialize some positions
|
|
493
|
+
@current_prices = {
|
|
494
|
+
"AAPL" => 185.50, "GOOGL" => 161.90, "MSFT" => 320.00, "NVDA" => 425.80,
|
|
495
|
+
"TSLA" => 238.90, "META" => 298.50, "JNJ" => 165.20, "PFE" => 35.80,
|
|
496
|
+
"JPM" => 145.60, "BAC" => 28.90
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
# Create initial portfolio
|
|
500
|
+
initial_positions = {
|
|
501
|
+
"AAPL" => { symbol: "AAPL", shares: 200, entry_price: 180.00, status: "open" },
|
|
502
|
+
"GOOGL" => { symbol: "GOOGL", shares: 150, entry_price: 160.00, status: "open" },
|
|
503
|
+
"TSLA" => { symbol: "TSLA", shares: 100, entry_price: 240.00, status: "open" },
|
|
504
|
+
"JNJ" => { symbol: "JNJ", shares: 80, entry_price: 170.00, status: "open" }
|
|
505
|
+
}
|
|
506
|
+
@portfolio[:positions] = initial_positions
|
|
507
|
+
@portfolio[:cash] = 25_000
|
|
508
|
+
|
|
509
|
+
puts "\n📊 Initial Portfolio:"
|
|
510
|
+
print_portfolio_status
|
|
511
|
+
|
|
512
|
+
# Scenario 1: Allocation Drift
|
|
513
|
+
puts "\n🎯 SCENARIO 1: Sector Allocation Drift"
|
|
514
|
+
puts "-" * 50
|
|
515
|
+
@engine.working_memory.facts.clear
|
|
516
|
+
|
|
517
|
+
allocations = calculate_sector_allocations
|
|
518
|
+
allocations.each do |sector, current_weight|
|
|
519
|
+
target_weight = @portfolio[:target_allocations][sector] || 0
|
|
520
|
+
drift = ((current_weight - target_weight) / target_weight * 100) rescue 0
|
|
521
|
+
|
|
522
|
+
if drift.abs > 5
|
|
523
|
+
@engine.add_fact(:portfolio_allocation, {
|
|
524
|
+
sector: sector,
|
|
525
|
+
current_weight: current_weight,
|
|
526
|
+
target_weight: target_weight,
|
|
527
|
+
drift_percentage: drift
|
|
528
|
+
})
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
@engine.run
|
|
532
|
+
|
|
533
|
+
# Scenario 2: Underperformer Replacement
|
|
534
|
+
puts "\n🎯 SCENARIO 2: Underperformer Replacement"
|
|
535
|
+
puts "-" * 50
|
|
536
|
+
@engine.working_memory.facts.clear
|
|
537
|
+
|
|
538
|
+
@engine.add_fact(:position_performance, {
|
|
539
|
+
symbol: "TSLA",
|
|
540
|
+
relative_performance: -15.2,
|
|
541
|
+
days_held: 45,
|
|
542
|
+
sector: "Consumer"
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
@engine.add_fact(:replacement_candidate, {
|
|
546
|
+
symbol: "AMZN",
|
|
547
|
+
sector: "Consumer",
|
|
548
|
+
relative_performance: 8.3,
|
|
549
|
+
momentum_score: 0.85
|
|
550
|
+
})
|
|
551
|
+
@engine.run
|
|
552
|
+
|
|
553
|
+
# Scenario 3: Correlation Risk
|
|
554
|
+
puts "\n🎯 SCENARIO 3: Correlation Risk Reduction"
|
|
555
|
+
puts "-" * 50
|
|
556
|
+
@engine.working_memory.facts.clear
|
|
557
|
+
|
|
558
|
+
@engine.add_fact(:correlation_risk, {
|
|
559
|
+
symbols: ["AAPL", "GOOGL"],
|
|
560
|
+
correlation_coefficient: 0.87,
|
|
561
|
+
combined_allocation: 0.32,
|
|
562
|
+
sector: "Technology"
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
@engine.add_fact(:replacement_candidate, {
|
|
566
|
+
symbol: "MSFT",
|
|
567
|
+
sector: "Technology",
|
|
568
|
+
correlation_with_portfolio: 0.42
|
|
569
|
+
})
|
|
570
|
+
@engine.run
|
|
571
|
+
|
|
572
|
+
# Scenario 4: Momentum Rotation
|
|
573
|
+
puts "\n🎯 SCENARIO 4: Momentum-Based Rotation"
|
|
574
|
+
puts "-" * 50
|
|
575
|
+
@engine.working_memory.facts.clear
|
|
576
|
+
|
|
577
|
+
@engine.add_fact(:sector_momentum, {
|
|
578
|
+
sector: "Technology",
|
|
579
|
+
momentum_trend: "declining",
|
|
580
|
+
momentum_score: 0.25,
|
|
581
|
+
duration_days: 25
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
@engine.add_fact(:sector_momentum, {
|
|
585
|
+
sector: "Healthcare",
|
|
586
|
+
momentum_trend: "rising",
|
|
587
|
+
momentum_score: 0.85
|
|
588
|
+
})
|
|
589
|
+
@engine.run
|
|
590
|
+
|
|
591
|
+
# Scenario 5: Quality Replacement
|
|
592
|
+
puts "\n🎯 SCENARIO 5: Quality Score Replacement"
|
|
593
|
+
puts "-" * 50
|
|
594
|
+
@engine.working_memory.facts.clear
|
|
595
|
+
|
|
596
|
+
@engine.add_fact(:position_quality, {
|
|
597
|
+
symbol: "TSLA",
|
|
598
|
+
quality_score: 0.35,
|
|
599
|
+
sector: "Consumer"
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
@engine.add_fact(:replacement_candidate, {
|
|
603
|
+
symbol: "AMZN",
|
|
604
|
+
quality_score: 0.88,
|
|
605
|
+
sector: "Consumer"
|
|
606
|
+
})
|
|
607
|
+
@engine.run
|
|
608
|
+
|
|
609
|
+
puts "\n📊 Final Portfolio:"
|
|
610
|
+
print_portfolio_status
|
|
611
|
+
|
|
612
|
+
puts "\n" + "=" * 70
|
|
613
|
+
puts "REBALANCING DEMONSTRATION COMPLETE"
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def print_portfolio_status
|
|
617
|
+
total_value = calculate_total_portfolio_value
|
|
618
|
+
allocations = calculate_sector_allocations
|
|
619
|
+
|
|
620
|
+
puts "Total Portfolio Value: $#{total_value.round(2)}"
|
|
621
|
+
puts "Cash: $#{@portfolio[:cash].round(2)}"
|
|
622
|
+
puts ""
|
|
623
|
+
puts "Sector Allocations:"
|
|
624
|
+
|
|
625
|
+
@portfolio[:target_allocations].each do |sector, target|
|
|
626
|
+
current = allocations[sector] || 0
|
|
627
|
+
drift = ((current - target) / target * 100) rescue 0
|
|
628
|
+
|
|
629
|
+
puts " #{sector}:"
|
|
630
|
+
puts " Current: #{(current * 100).round(1)}% (Target: #{(target * 100).round(1)}%)"
|
|
631
|
+
puts " Drift: #{drift > 0 ? '+' : ''}#{drift.round(1)}%"
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
puts ""
|
|
635
|
+
puts "Positions:"
|
|
636
|
+
@portfolio[:positions].each do |symbol, position|
|
|
637
|
+
if position[:status] == "open" && @current_prices[symbol]
|
|
638
|
+
current_value = position[:shares] * @current_prices[symbol]
|
|
639
|
+
pnl = current_value - (position[:shares] * position[:entry_price])
|
|
640
|
+
pnl_pct = (pnl / (position[:shares] * position[:entry_price]) * 100)
|
|
641
|
+
|
|
642
|
+
puts " #{symbol}: #{position[:shares]} shares @ $#{@current_prices[symbol]} = $#{current_value.round(2)} (#{pnl_pct > 0 ? '+' : ''}#{pnl_pct.round(1)}%)"
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
if __FILE__ == $0
|
|
649
|
+
system = PortfolioRebalancingSystem.new
|
|
650
|
+
system.simulate_rebalancing_scenarios
|
|
651
|
+
end
|